Author SHA1 Message Date
renovate[bot] 2333491cb2 chore(deps): update ghcr.io/renovatebot/renovate docker tag to v44
Test / Lint (push) Failing after 1m55s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
Test / Lint (pull_request) Failing after 1m57s
Test / Test (pull_request) Skipped
Test / Render parity (pull_request) Skipped
2026-07-30 00:15:32 +00:00
renovate[bot] f0851576e8 chore(deps): update rust crate serde_json to v1.0.151
Test / Lint (pull_request) Failing after 3m19s
Test / Test (pull_request) Skipped
Test / Render parity (pull_request) Skipped
Docs Deploy / Build and Deploy MkDocs (push) Successful in 49s
Test / Lint (push) Failing after 12s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-21 00:08:37 +00:00
renovate[bot] fb1ec5857d chore(deps): update rust crate serde to v1.0.229
Test / Lint (pull_request) Failing after 2m0s
Test / Test (pull_request) Skipped
Test / Render parity (pull_request) Skipped
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m2s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-19 00:02:17 +00:00
Valentin Popov 33d16dfca3 docs(script): trace callback command one
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 19m21s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 22:55:31 +04:00
Valentin Popov f49010c723 feat(runtime): apply mission script outcomes 2026-07-18 22:36:38 +04:00
Valentin Popov 70c7afe25c docs: clarify Notion reconciliation 2026-07-18 22:35:34 +04:00
Valentin Popov 9e2cc1a283 docs(script): trace host callback 2026-07-18 22:30:49 +04:00
Valentin Popov bbefe65a62 feat(script): bind callbacks to runtime varsets 2026-07-18 22:25:28 +04:00
Valentin Popov 0ee635eb77 docs(render): describe all-roots Vulkan preview 2026-07-18 22:21:07 +04:00
Valentin Popov 14096788fc feat(runtime): apply clan init varsets 2026-07-18 22:18:49 +04:00
Valentin Popov d91e63fa9a feat(runtime): resolve clan init writes 2026-07-18 22:15:55 +04:00
Valentin Popov f324017151 feat(script): resolve default init writes 2026-07-18 22:04:10 +04:00
Valentin Popov 5d40ba3968 feat(script): resolve handler eight states 2026-07-18 21:53:09 +04:00
Valentin Popov d1edf4ba65 feat(script): resolve handler fifteen inputs 2026-07-18 21:47:33 +04:00
Valentin Popov a0a5a18028 fix(render): accept PowerShell camera captures 2026-07-18 21:40:01 +04:00
Valentin Popov 24ba0bdd37 feat(script): resolve handler thirty dword callbacks 2026-07-18 21:33:14 +04:00
Valentin Popov ea09804778 docs(ai): trace frequent handler thirty callback 2026-07-18 21:27:39 +04:00
Valentin Popov 837b613513 feat(runtime): load script varset defaults 2026-07-18 21:23:28 +04:00
Valentin Popov a7882545b7 feat(script): parse varset numeric defaults 2026-07-18 21:17:57 +04:00
Valentin Popov 3619f22b42 docs(ai): rule out handler two immediate dispatch 2026-07-18 21:13:40 +04:00
Valentin Popov bd225b318e feat(script): model handler two scheduler 2026-07-18 21:11:10 +04:00
Valentin Popov de715c0dfd docs(ai): trace handler two event scheduler 2026-07-18 21:05:55 +04:00
Valentin Popov 6af81522a1 docs(ai): mark handler one corpus-unreachable 2026-07-18 21:03:36 +04:00
Valentin Popov 545d661b2b docs(ai): recover numeric handler contract 2026-07-18 21:01:46 +04:00
Valentin Popov 9dcf6d456e feat(runtime): load mission script bundles 2026-07-18 20:56:34 +04:00
Valentin Popov 3592b46b0f docs: record notion reconciliation audit 2026-07-18 20:53:04 +04:00
Valentin Popov 7b0900aa1a docs(script): trace handler two contract 2026-07-18 20:46:37 +04:00
Valentin Popov 50a74cc0a6 feat(script): classify VM dispatch selectors 2026-07-18 20:44:10 +04:00
Valentin Popov d31d1f8eab feat(script): decode compiled package framing 2026-07-18 20:40:17 +04:00
Valentin Popov a7b434aeb2 docs(ai): recover expression evaluator contract 2026-07-18 20:32:56 +04:00
Valentin Popov 6c444d63c5 feat(cli): expose unit component provenance 2026-07-18 20:29:53 +04:00
Valentin Popov 2d0ded231b feat(assets): preserve unit component asset provenance 2026-07-18 20:26:15 +04:00
Valentin Popov 6e5ee47d51 feat(prototype): trace unit component edges 2026-07-18 20:23:53 +04:00
Valentin Popov d7150a58d5 feat(prototype): retain unit component provenance 2026-07-18 20:15:26 +04:00
Valentin Popov b7a102caa7 docs: reconcile Notion knowledge gaps 2026-07-18 20:10:26 +04:00
Valentin Popov 3553107514 docs(control): recover AniMesh config provenance 2026-07-18 20:02:46 +04:00
Valentin Popov 5052edbee9 docs(control): trace AniMesh loading chain 2026-07-18 20:00:50 +04:00
Valentin Popov 63ac1bd55e tools(ghidra): export Control ABI decompilation 2026-07-18 19:58:15 +04:00
Valentin Popov 369e2ead3c docs(control): record loader ABI boundaries 2026-07-18 19:53:36 +04:00
Valentin Popov 5e940f92ba feat(terrain): index surface queries with bvh 2026-07-18 19:51:19 +04:00
Valentin Popov 0736c76d87 feat(headless): expose reference movement command 2026-07-18 19:46:09 +04:00
Valentin Popov e339256776 feat(runtime): add terrain-snapped reference movement 2026-07-18 19:42:52 +04:00
Valentin Popov b1ff50f9db docs(plan): reconcile Notion Vulkan revision 2026-07-18 19:38:01 +04:00
Valentin Popov a01c21f865 feat(render): consume world snapshot transforms 2026-07-18 19:30:27 +04:00
Valentin Popov 77d38e4c12 feat(world): retain mission transform state 2026-07-18 19:27:28 +04:00
Valentin Popov 9788749650 feat(render): select static MAT0 material phases 2026-07-18 19:22:58 +04:00
Valentin Popov a339a3a635 feat(tools): summarize terrain shade profiles 2026-07-18 19:17:38 +04:00
Valentin Popov 273bd9e477 feat(tools): capture live terrain shade cache 2026-07-18 19:15:18 +04:00
Valentin Popov 5c1ccae393 feat(render): preview explicit animation frames 2026-07-18 19:08:49 +04:00
Valentin Popov 3b6dd392a7 fix(render): export final Vulkan readback image 2026-07-18 19:00:43 +04:00
Valentin Popov e96444459b fix(tools): reject non-world camera captures 2026-07-18 18:55:00 +04:00
Valentin Popov fd9c04cade feat(tools): capture live legacy camera 2026-07-18 18:47:14 +04:00
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
Valentin Popov 6cd23bf02a fix(smoke): capture final validation after explicit shutdown
Docs Deploy / Build and Deploy MkDocs (push) Successful in 39s
Test / Lint (push) Failing after 2m58s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-25 22:35:08 +04:00
Valentin Popov cbc8ef36f8 fix(smoke): bind native smoke audit to current provenance 2026-06-25 22:31:35 +04:00
Valentin Popov 7c7e91c857 build(ci): fail closed on shader provenance
Docs Deploy / Build and Deploy MkDocs (push) Successful in 39s
Test / Lint (push) Successful in 2m55s
Test / Test (push) Failing after 3m13s
Test / Render parity (push) Has been skipped
2026-06-25 13:07:58 +04:00
Valentin Popov 7c3b3a53f5 fix(smoke): drop renderer before window teardown 2026-06-25 13:07:40 +04:00
Valentin Popov 0b8776b850 chore(audit): remove stage0 audit files
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m54s
Test / Lint (push) Failing after 1m58s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-25 11:45:41 +04:00
Valentin Popov e572558d5f fix(stage0-ci): satisfy strict clippy gates 2026-06-25 11:45:40 +04:00
Valentin Popov 5a60671bd6 fix(vulkan-policy): report sampled formats and limits 2026-06-25 11:45:40 +04:00
Valentin Popov 5aff0b64e8 fix(vulkan-policy): gate requested depth formats 2026-06-25 11:45:40 +04:00
Valentin Popov 757a975d8c chore(vulkan-smoke): record window bootstrap phases 2026-06-25 11:45:40 +04:00
Valentin Popov d146953bcc fix(vulkan-smoke): track bootstrap timeout evidence 2026-06-25 11:45:40 +04:00
Valentin Popov b617e2958d fix(vulkan-smoke): harden timeout and ci closure 2026-06-25 11:45:40 +04:00
Valentin Popov 607a64ca8d refactor(vulkan-policy): narrow unsafe module boundaries 2026-06-25 11:45:39 +04:00
Valentin Popov e79d26ea68 feat(vulkan-policy): report rejected device diagnostics 2026-06-25 11:45:39 +04:00
Valentin Popov e3c74485f1 feat(platform-winit): bridge native window events 2026-06-25 11:45:39 +04:00
Valentin Popov ad21704bcc feat(platform-winit): apply lifecycle state updates 2026-06-25 11:45:39 +04:00
Valentin Popov 95391a05c6 build(vulkan-shaders): target vulkan 1.1 manifests 2026-06-25 11:45:39 +04:00
Valentin Popov 14ea45d49a build(vulkan-shaders): source tool metadata at build time 2026-06-25 11:45:39 +04:00
Valentin Popov 0caa36d923 build(vulkan-smoke): capture build toolchain metadata 2026-06-25 11:45:38 +04:00
Valentin Popov 25d26a87a4 build(vulkan-smoke): embed compiled artifact provenance 2026-06-25 11:45:38 +04:00
Valentin Popov 8f0dcd7f4d feat(vulkan-smoke): verify macos portability evidence 2026-06-25 11:45:38 +04:00
Valentin Popov 97f56c56ba fix(vulkan-smoke): rollback partial swapchain resources 2026-06-25 11:45:38 +04:00
Valentin Popov 70813154f2 fix(vulkan-smoke): emit structured timeout reports 2026-06-25 11:45:38 +04:00
Valentin Popov e40349b204 ci(vulkan-smoke): pin macos runtime provisioning 2026-06-25 11:45:38 +04:00
Valentin Popov 61638e083b test(vulkan-smoke): cover explicit teardown order 2026-06-25 11:45:38 +04:00
Valentin Popov 17c3038a36 fix(vulkan-smoke): simplify swapchain image layout transitions 2026-06-25 11:45:37 +04:00
Valentin Popov b473b100c8 refactor(vulkan-ffi): extract capability probe module 2026-06-25 11:45:37 +04:00
Valentin Popov 3c32215665 refactor(vulkan-ffi): extract swapchain probe module 2026-06-25 11:45:37 +04:00
Valentin Popov f91378b884 refactor(vulkan-ffi): extract swapchain resource module 2026-06-25 11:45:37 +04:00
Valentin Popov 8f8fa426d5 refactor(vulkan-backend): satisfy ci quality gates 2026-06-25 11:45:37 +04:00
Valentin Popov 4d0cb594a7 refactor(vulkan-ffi): move adapter tests into submodule 2026-06-25 11:45:37 +04:00
Valentin Popov 07e30cd040 refactor(vulkan-ffi): extract smoke renderer types 2026-06-25 11:45:36 +04:00
Valentin Popov 079e531166 refactor(vulkan-backend): clarify planning facade telemetry 2026-06-25 11:45:36 +04:00
Valentin Popov 6a2adbe160 refactor(vulkan-ffi): extract smoke renderer module 2026-06-25 11:45:36 +04:00
Valentin Popov b8933dd43a refactor(vulkan-ffi): extract resource setup module 2026-06-25 11:45:36 +04:00
Valentin Popov 26efa13a01 refactor(vulkan-ffi): extract runtime capability module 2026-06-25 11:45:36 +04:00
Valentin Popov dcd5417af2 refactor(vulkan-ffi): extract validation messenger module 2026-06-25 11:45:36 +04:00
Valentin Popov b6b47ae6f6 refactor(vulkan-ffi): extract surface bootstrap module 2026-06-25 11:45:35 +04:00
Valentin Popov 1eead8d597 refactor(vulkan-ffi): extract instance bootstrap module 2026-06-25 11:45:35 +04:00
Valentin Popov ce3e5ad813 refactor(vulkan-shaders): extract manifest validation module 2026-06-25 11:45:35 +04:00
Valentin Popov d0552922d9 refactor(vulkan-policy): extract pure swapchain and device policy 2026-06-25 11:45:35 +04:00
Valentin Popov 0de5118575 refactor(vulkan-backend): extract planning facade module 2026-06-25 11:45:35 +04:00
Valentin Popov 0d139b1aae fix(vulkan-instance): verify required extensions before create 2026-06-25 11:45:35 +04:00
Valentin Popov 72f6c06eca fix(vulkan-capabilities): harden swapchain capability gate 2026-06-25 11:45:35 +04:00
Valentin Popov 0a78fc2460 refactor(vulkan-reporting): remove manual json assembly 2026-06-25 11:45:34 +04:00
Valentin Popov 5c4fbff2af refactor(vulkan-errors): preserve typed vk results 2026-06-25 11:45:34 +04:00
Valentin Popov fd8b03c0bc refactor(reporting): use typed serde report schemas 2026-06-25 11:45:34 +04:00
Valentin Popov 0b0ed87650 refactor(vulkan-ffi): narrow audited unsafe boundary 2026-06-25 11:45:34 +04:00
Valentin Popov 6a6393038e refactor(vulkan-plan): clarify planning backend telemetry 2026-06-25 11:45:34 +04:00
Valentin Popov ec6645a21f fix(stage0): harden native smoke provenance audit 2026-06-25 11:45:34 +04:00
Valentin Popov d1b7b43dce feat(stage0): record artifact provenance metadata 2026-06-25 11:45:33 +04:00
Valentin Popov adc6c6149c fix(vulkan-smoke): make teardown order explicit 2026-06-25 11:45:33 +04:00
Valentin Popov 5950c62cec ci: tighten supply-chain fallback policy 2026-06-25 11:45:33 +04:00
Valentin Popov 247f86aa09 feat(vulkan-smoke): pin shader manifest provenance 2026-06-25 11:45:33 +04:00
Valentin Popov 4d8068aef0 fix(platform-winit): map lifecycle and pointer state 2026-06-25 11:45:33 +04:00
Valentin Popov 53715d0d9c fix(vulkan-smoke): clean up partial runtime resources 2026-06-25 11:45:33 +04:00
Valentin Popov c8876d65eb refactor(vulkan-smoke): remove legacy acquire-present path 2026-06-25 11:45:33 +04:00
Valentin Popov 0a2d1bcc32 chore(stage0): scope native smoke closure to macos 2026-06-25 11:45:32 +04:00
Valentin Popov ba69bdb6ea feat(stage0): close native smoke acceptance gate 2026-06-25 11:45:32 +04:00
Valentin Popov 5cc2c5819f ci: tighten stage 0 acceptance gates 2026-06-25 11:45:32 +04:00
renovate[bot] 27af3806b3 fix(deps): update all digest updates
Test / Lint (pull_request) Failing after 1m53s
Test / Test (pull_request) Has been skipped
Test / Render parity (pull_request) Has been skipped
Docs Deploy / Build and Deploy MkDocs (push) Successful in 34s
Test / Lint (push) Failing after 1m50s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-24 00:02:21 +00:00
Valentin Popov 021b1c8dac feat(vulkan-smoke): run native swapchain acquire/present
Docs Deploy / Build and Deploy MkDocs (push) Successful in 34s
Test / Lint (push) Failing after 2m24s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-24 01:53:39 +04:00
Valentin Popov 278567d6de revert: "ci: fix unreadable_directory_produces_error error"
Docs Deploy / Build and Deploy MkDocs (push) Successful in 34s
Test / Lint (push) Successful in 2m35s
Test / Test (push) Failing after 2m53s
Test / Render parity (push) Has been skipped
This reverts commit 7eced77483.
2026-06-24 01:45:15 +04:00
Valentin Popov 7eced77483 ci: fix unreadable_directory_produces_error error
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m32s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-24 01:33:57 +04:00
Valentin Popov d41add32c4 feat: create Vulkan swapchain probe
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Successful in 2m36s
Test / Test (push) Failing after 2m52s
Test / Render parity (push) Has been skipped
2026-06-24 01:05:31 +04:00
Valentin Popov 159731664f feat: probe Vulkan logical device creation 2026-06-24 00:14:26 +04:00
Valentin Popov e6b7fa1896 feat: probe live Vulkan runtime capabilities 2026-06-24 00:05:46 +04:00
Valentin Popov 0e127117e9 feat: require created Vulkan smoke surface 2026-06-23 23:59:07 +04:00
Valentin Popov 4d19728c39 feat: create native smoke window handles 2026-06-23 23:56:40 +04:00
Valentin Popov 54f07ee3be feat: audit native smoke artifacts 2026-06-23 23:51:38 +04:00
Valentin Popov ed2b540abf feat: add target triple to native smoke report 2026-06-23 23:48:31 +04:00
Valentin Popov 00ae9067d8 feat: require swapchain recreation evidence in smoke reports 2026-06-23 23:45:08 +04:00
Valentin Popov c71e706d69 feat: add native smoke window preflight 2026-06-23 23:42:20 +04:00
Valentin Popov aa2133d82b feat: add Vulkan surface preflight to smoke runner 2026-06-23 23:38:09 +04:00
Valentin Popov 71ead678c0 feat: add Vulkan instance probe to smoke runner 2026-06-23 23:35:41 +04:00
Valentin Popov f15ea95bf2 feat: add Vulkan loader probe to smoke runner 2026-06-23 23:33:42 +04:00
Valentin Popov 99bcbf388f refactor: remove xtask native smoke generator 2026-06-23 23:30:26 +04:00
Valentin Popov 227d95fc49 feat: add Vulkan smoke runner entrypoint 2026-06-23 23:27:47 +04:00
Valentin Popov dceea70122 ci: add native smoke artifact schema 2026-06-23 23:22:29 +04:00
Valentin Popov fd452f6016 ci: add acceptance artifact metadata 2026-06-23 23:18:36 +04:00
Valentin Popov 1d0244c3e4 ci: enforce reproducible Rust toolchain 2026-06-23 23:16:50 +04:00
Valentin Popov 5d9e1cbe38 feat: add Vulkan frame submission plan 2026-06-23 23:13:52 +04:00
Valentin Popov 0e76c2ed7c ci: add built-in supply chain policy fallback 2026-06-23 23:10:16 +04:00
Valentin Popov 4c1edef21b test: verify headless dependency closure 2026-06-23 23:05:01 +04:00
Valentin Popov e6778d43af feat: add Vulkan shader manifest validation 2026-06-23 23:01:34 +04:00
Valentin Popov ec8f6599fc feat: add Vulkan swapchain planning policy 2026-06-23 22:57:03 +04:00
Valentin Popov f5fae8e84a feat: add Vulkan surface bootstrap boundary 2026-06-23 22:53:54 +04:00
Valentin Popov a0a4089e4b feat: expose native window handles 2026-06-23 22:50:32 +04:00
Valentin Popov dc7e72961a feat: add Vulkan instance bootstrap plan 2026-06-23 22:47:20 +04:00
Valentin Popov 8ea1fd5c18 feat: probe Vulkan loader boundary 2026-06-23 22:43:54 +04:00
Valentin Popov 69c032acca feat: add Vulkan capability selection boundary 2026-06-23 22:40:01 +04:00
Valentin Popov 9cc24e715d fix: close stage 0-2 synthetic gates 2026-06-23 22:32:50 +04:00
Valentin Popov f8e447ffee feat: close stage 0-2 audit groundwork
Remove legacy SDL/OpenGL adapters from the workspace and introduce winit/Vulkan adapter boundaries for the rendered composition root.

Add reproducible toolchain and xtask CI coverage for formatting, tests, clippy, docs, policy, deny, acceptance auditing, and hosted OS matrix evidence.

Strengthen Stage 1 data contracts with byte-first paths, VFS hardening, structured diagnostics, RsLi writer/edit scaffolding, corpus reporting, and resource error classification.

Advance Stage 2 asset preparation by moving mission loading through assets/runtime boundaries, materializing prototype graph data, preserving provenance, and adding inspection/viewer integration.

Record the Stage 0-2 audit input, acceptance roadmap, coverage updates, and documentation notes for follow-up evidence.
2026-06-23 22:05:16 +04:00
Valentin Popov 83d763dd70 fix: trace runtime scheduler phases
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 1m15s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-22 17:32:56 +04:00
Valentin Popov 162de8ccab fix: require manifests for licensed gates
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 1m16s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-22 17:29:33 +04:00
Valentin Popov 0b23cf48e7 fix: use canonical sha256 world hashes 2026-06-22 17:11:21 +04:00
Valentin Popov 7356238ffb fix: harden render command validation 2026-06-22 17:05:45 +04:00
Valentin Popov 42441082f0 fix: cap fixed-step catch-up 2026-06-22 17:02:00 +04:00
Valentin Popov ccd61c05b0 fix: expose configurable rsli read profiles 2026-06-22 16:58:59 +04:00
Valentin Popov 813beec7be fix: preserve nres gaps during edits 2026-06-22 16:55:10 +04:00
Valentin Popov 91c7a8a14e fix: make corpus reports explicit and fallible 2026-06-22 16:49:32 +04:00
Valentin Popov 8b91a0bfbf fix: make core error displays actionable 2026-06-22 16:41:21 +04:00
Valentin Popov fb97405e0c fix: decode payloads outside resource lock 2026-06-22 16:36:50 +04:00
Valentin Popov d579b696e6 fix: cap decoded payload cache bytes 2026-06-22 16:34:14 +04:00
Valentin Popov aa1b809bd8 fix: strengthen resource fingerprints 2026-06-22 16:31:57 +04:00
Valentin Popov f69c893a40 fix: harden path lookup and mark gl backend gap 2026-06-22 16:12:57 +04:00
Valentin Popov 5436727961 docs: mark stage 4 runtime gaps explicit 2026-06-22 16:04:35 +04:00
Valentin Popov be41fa839f fix: harden resource and world state correctness 2026-06-22 16:02:16 +04:00
Valentin Popov 8e5e46b7b3 fix: make ci locked and isolate licensed tests 2026-06-22 15:55:37 +04:00
160 changed files with 40585 additions and 2603 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
jobs:
renovate:
container: ghcr.io/renovatebot/renovate:43
container: ghcr.io/renovatebot/renovate:44
runs-on: ubuntu-latest
steps:
+159
View File
@@ -0,0 +1,159 @@
name: fparkan-ci
on:
push:
branches: [devel, main]
pull_request:
branches: [devel, main]
workflow_dispatch:
jobs:
msrv-backend-neutral:
name: Pinned Rust backend-neutral crates
runs-on: ubuntu-latest
timeout-minutes: 20
env:
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
with:
toolchain: 1.97.1
- name: Test backend-neutral crates
run: >
cargo test
-p fparkan-animation
-p fparkan-binary
-p fparkan-corpus
-p fparkan-diagnostics
-p fparkan-fx
-p fparkan-inspection
-p fparkan-material
-p fparkan-mission-format
-p fparkan-msh
-p fparkan-nres
-p fparkan-path
-p fparkan-platform
-p fparkan-prototype
-p fparkan-render
-p fparkan-resource
-p fparkan-rsli
-p fparkan-runtime
-p fparkan-terrain
-p fparkan-terrain-format
-p fparkan-texm
-p fparkan-vfs
-p fparkan-world
--all-targets
--locked
stage0-matrix:
name: Stage 0 CI (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- os: macos-15
smoke_platform: macos
runner_arch: arm64
moltenvk_version: 1.4.1
vulkan_sdk_version: 1.4.350.1
env:
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
with:
toolchain: 1.97.1
components: clippy,rustfmt
- name: Provision macOS Vulkan runtime
shell: bash
run: |
set -euo pipefail
brew install molten-vk vulkan-loader vulkan-tools vulkan-validationlayers
test "$(uname -m)" = "${{ matrix.runner_arch }}"
ruby <<'RUBY'
require "json"
expected = {
"molten-vk" => "${{ matrix.moltenvk_version }}",
"vulkan-loader" => "${{ matrix.vulkan_sdk_version }}",
"vulkan-tools" => "${{ matrix.vulkan_sdk_version }}",
"vulkan-validationlayers" => "${{ matrix.vulkan_sdk_version }}",
}
payload = JSON.parse(`brew info --json=v2 #{expected.keys.join(" ")}`)
actual = payload.fetch("formulae").to_h do |formula|
[formula.fetch("name"), formula.fetch("versions").fetch("stable")]
end
mismatches = expected.each_with_object({}) do |(name, version), out|
actual_version = actual[name]
next if actual_version == version
out[name] = {
"expected" => version,
"actual" => actual_version,
}
end
unless mismatches.empty?
warn JSON.pretty_generate(mismatches)
abort "unexpected macOS Vulkan formula version"
end
RUBY
HOMEBREW_PREFIX="$(brew --prefix)"
VK_ICD_FILENAMES="$HOMEBREW_PREFIX/opt/molten-vk/etc/vulkan/icd.d/MoltenVK_icd.json"
VK_LAYER_PATH="$HOMEBREW_PREFIX/opt/vulkan-validationlayers/share/vulkan/explicit_layer.d"
DYLD_FALLBACK_LIBRARY_PATH="$HOMEBREW_PREFIX/opt/vulkan-loader/lib:$HOMEBREW_PREFIX/opt/molten-vk/lib"
test -f "$VK_ICD_FILENAMES"
test -d "$VK_LAYER_PATH"
echo "VK_ICD_FILENAMES=$VK_ICD_FILENAMES" >> "$GITHUB_ENV"
echo "VK_LAYER_PATH=$VK_LAYER_PATH" >> "$GITHUB_ENV"
echo "DYLD_FALLBACK_LIBRARY_PATH=$DYLD_FALLBACK_LIBRARY_PATH" >> "$GITHUB_ENV"
- name: Install cargo-deny
run: cargo install cargo-deny --version 0.19.9 --locked
- name: Verify shader provenance
run: cargo xtask shader-provenance
- name: Run canonical CI gate
run: cargo xtask ci
- name: Run native Vulkan smoke
run: >
cargo run -p fparkan-vulkan-smoke --locked --
--out "target/fparkan/native-smoke/${{ matrix.smoke_platform }}.json"
--timeout-seconds 120
- name: Upload acceptance audit
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: stage-0-acceptance-${{ matrix.os }}
path: target/fparkan/acceptance/stage-0-audit.json
if-no-files-found: error
- name: Upload native smoke report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: native-smoke-${{ matrix.smoke_platform }}
path: target/fparkan/native-smoke/*.json
if-no-files-found: error
native-smoke-audit:
name: Native smoke audit
runs-on: ubuntu-latest
timeout-minutes: 15
needs: stage0-matrix
env:
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
with:
toolchain: 1.97.1
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
pattern: native-smoke-*
path: target/fparkan/native-smoke-artifacts
merge-multiple: true
- name: Aggregate native smoke reports
run: >
cargo xtask native-smoke audit
--dir target/fparkan/native-smoke-artifacts
-4
View File
@@ -69,10 +69,6 @@ $RECYCLE.BIN/
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
Generated
+1884
View File
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -7,6 +7,7 @@ members = [
"crates/fparkan-corpus",
"crates/fparkan-diagnostics",
"crates/fparkan-fx",
"crates/fparkan-inspection",
"crates/fparkan-material",
"crates/fparkan-mission-format",
"crates/fparkan-msh",
@@ -17,6 +18,7 @@ members = [
"crates/fparkan-render",
"crates/fparkan-resource",
"crates/fparkan-rsli",
"crates/fparkan-script",
"crates/fparkan-runtime",
"crates/fparkan-terrain",
"crates/fparkan-terrain-format",
@@ -24,11 +26,12 @@ members = [
"crates/fparkan-texm",
"crates/fparkan-vfs",
"crates/fparkan-world",
"adapters/fparkan-platform-sdl",
"adapters/fparkan-render-gl",
"adapters/fparkan-platform-winit",
"adapters/fparkan-render-vulkan",
"apps/fparkan-cli",
"apps/fparkan-game",
"apps/fparkan-headless",
"apps/fparkan-vulkan-smoke",
"apps/fparkan-viewer",
"xtask",
]
@@ -36,7 +39,8 @@ members = [
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"
rust-version = "1.97"
license = "GPL-2.0-only"
repository = "https://github.com/valentineus/fparkan"
[workspace.lints.rust]
+61 -3
View File
@@ -24,7 +24,17 @@ Open source проект с реализацией компонентов игр
- [crates/fparkan-rsli](crates/fparkan-rsli) — чтение, lookup и lossless roundtrip архивов RsLi.
- [crates/fparkan-msh](crates/fparkan-msh) — validated static MSH geometry.
- [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).
## Тестирование
@@ -40,12 +50,60 @@ cargo xtask ci
- разместите игровые каталоги в [`testdata/`](testdata);
- игровые ресурсы в репозиторий не включаются, так как защищены авторским правом.
Локальный licensed gate:
Локальный licensed gate использует некоммитимый manifest:
```bash
cargo xtask acceptance report --suite licensed --stage 5 --root testdata
cat > /private/tmp/fparkan-corpora.toml <<'EOF'
schema = 1
[[corpus]]
id = "part1-local"
kind = "part1"
root = "/absolute/path/to/IS"
expected_profile = "parkan-is-part1"
[[corpus]]
id = "part2-local"
kind = "part2"
root = "/absolute/path/to/IS2"
expected_profile = "parkan-is-part2"
EOF
FPARKAN_CORPORA_MANIFEST=/private/tmp/fparkan-corpora.toml \
cargo xtask acceptance report --suite licensed --stage 5
```
## Stage 0 Vulkan smoke
Локальный Stage 0 smoke запускает реальный `winit` lifecycle и Vulkan triangle path с включёнными validation layers. Успешный прогон обязан:
- отрисовать 300 кадров;
- выполнить как минимум один реальный resize;
- пересоздать swapchain после resize;
- завершиться без validation warnings/errors.
Команда запуска:
```bash
cargo run -p fparkan-vulkan-smoke --locked -- \
--out target/fparkan/native-smoke/local.json \
--timeout-seconds 120
```
Поддерживается только Windows. Перед запуском убедитесь, что установлен Vulkan runtime
от GPU vendor или LunarG Vulkan SDK и активный runtime предоставляет
`VK_LAYER_KHRONOS_validation`. Linux, macOS/MoltenVK и их smoke-пути не входят в
проектный scope.
Для полного локального closure gate используйте:
```bash
cargo xtask ci
```
Windows native smoke — единственный platform acceptance path. Вопросы hosted CI
и других операционных систем намеренно находятся вне scope разработки.
## Contributing & Support
Проект активно поддерживается и открыт для contribution. Issues и pull requests можно создавать в обоих репозиториях:
-12
View File
@@ -1,12 +0,0 @@
[package]
name = "fparkan-platform-sdl"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-platform = { path = "../../crates/fparkan-platform" }
[lints]
workspace = true
-123
View File
@@ -1,123 +0,0 @@
#![forbid(unsafe_code)]
//! SDL platform adapter proof behind safe `FParkan` ports.
use fparkan_platform::{
EventSource, GraphicsContextRequest, GraphicsProfile, PhysicalSize, PlatformError,
PlatformEvent, Version, WindowPort,
};
/// Adapter capabilities compiled into this package.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SdlAdapterCapabilities {
/// Supported graphics context requests in preference order.
pub graphics: Vec<GraphicsContextRequest>,
/// Whether adapter-owned code is free of `unsafe`.
pub project_owned_unsafe_free: bool,
}
impl Default for SdlAdapterCapabilities {
fn default() -> Self {
Self {
graphics: vec![
GraphicsContextRequest {
profile: GraphicsProfile::DesktopCore,
version: Version { major: 3, minor: 3 },
},
GraphicsContextRequest {
profile: GraphicsProfile::Embedded,
version: Version { major: 2, minor: 0 },
},
],
project_owned_unsafe_free: true,
}
}
}
/// Returns adapter readiness status for the safe project-owned layer.
#[must_use]
pub fn safe_adapter_ready() -> bool {
SdlAdapterCapabilities::default().project_owned_unsafe_free
}
/// In-memory event source used by adapter smoke tests and composition roots
/// before a concrete SDL runtime is injected.
#[derive(Clone, Debug, Default)]
pub struct SdlEventSourceProof {
pending: Vec<PlatformEvent>,
}
impl SdlEventSourceProof {
/// Creates an event source with deterministic pending events.
#[must_use]
pub fn new(pending: Vec<PlatformEvent>) -> Self {
Self { pending }
}
}
impl EventSource for SdlEventSourceProof {
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError> {
out.append(&mut self.pending);
Ok(())
}
}
/// Safe window-port proof with SDL-compatible drawable-size semantics.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SdlWindowProof {
size: PhysicalSize,
presents: u64,
}
impl SdlWindowProof {
/// Creates a proof window with a fixed drawable size.
#[must_use]
pub fn new(size: PhysicalSize) -> Self {
Self { size, presents: 0 }
}
/// Number of successful present calls.
#[must_use]
pub fn presents(&self) -> u64 {
self.presents
}
}
impl WindowPort for SdlWindowProof {
fn drawable_size(&self) -> PhysicalSize {
self.size
}
fn present(&mut self) -> Result<(), PlatformError> {
self.presents = self.presents.saturating_add(1);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adapter_reports_safe_project_layer_ready() {
assert!(safe_adapter_ready());
assert_eq!(SdlAdapterCapabilities::default().graphics.len(), 2);
}
#[test]
fn event_source_and_window_ports_are_deterministic() -> Result<(), PlatformError> {
let mut source = SdlEventSourceProof::new(vec![PlatformEvent::Quit]);
let mut events = Vec::new();
source.poll(&mut events)?;
source.poll(&mut events)?;
assert_eq!(events, vec![PlatformEvent::Quit]);
let mut window = SdlWindowProof::new(PhysicalSize {
width: 320,
height: 240,
});
assert_eq!(window.drawable_size().width, 320);
window.present()?;
assert_eq!(window.presents(), 1);
Ok(())
}
}
@@ -0,0 +1,14 @@
[package]
name = "fparkan-platform-winit"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
raw-window-handle = "0.6"
winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
[lints]
workspace = true
+576
View File
@@ -0,0 +1,576 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Minimal `winit`-backed platform adapter shim.
use fparkan_platform::{
EventSource, MonotonicClock, MonotonicInstant, NativeWindowHandles, PhysicalSize,
PlatformError, PlatformEvent, RenderRequest, WindowHandle, WindowPort,
};
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
use std::collections::VecDeque;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::OnceLock;
use std::time::Instant;
use winit::event::{Event, MouseButton, WindowEvent};
use winit::platform::scancode::PhysicalKeyExtScancode;
use winit::window::Window;
static NEXT_WINDOW_HANDLE_ID: AtomicU64 = AtomicU64::new(1);
static CLOCK_START: OnceLock<Instant> = OnceLock::new();
const DEFAULT_SMOKE_WIDTH: u32 = 1280;
const DEFAULT_SMOKE_HEIGHT: u32 = 720;
fn next_window_id() -> u64 {
NEXT_WINDOW_HANDLE_ID.fetch_add(1, Ordering::Relaxed)
}
/// Simple monotonic clock for windowing abstractions.
#[derive(Clone, Copy, Debug)]
pub struct WinitClock;
impl MonotonicClock for WinitClock {
fn now(&self) -> MonotonicInstant {
let elapsed = CLOCK_START.get_or_init(Instant::now).elapsed();
MonotonicInstant(elapsed.as_millis().try_into().unwrap_or(u64::MAX))
}
}
/// Event source backed by pre-buffered platform events.
#[derive(Clone, Debug, Default)]
pub struct WinitEventSource {
queue: VecDeque<PlatformEvent>,
cursor_position: Option<(f64, f64)>,
minimized: Option<bool>,
occluded: Option<bool>,
}
impl WinitEventSource {
/// Creates an empty source.
#[must_use]
pub const fn new() -> Self {
Self {
queue: VecDeque::new(),
cursor_position: None,
minimized: None,
occluded: None,
}
}
/// Pushes a synthetic event (used by tests and smoke stubs).
pub fn push(&mut self, event: PlatformEvent) {
self.queue.push_back(event);
}
/// Pushes a mapped native window event.
pub fn push_window_event(&mut self, event: &WindowEvent) {
match event {
WindowEvent::KeyboardInput { event, .. } => {
if let Some(scancode) = event.physical_key.to_scancode() {
self.queue.push_back(PlatformEvent::KeyboardInput {
scancode,
pressed: event.state.is_pressed(),
});
}
}
WindowEvent::MouseInput { state, button, .. } => {
let (x, y) = self.cursor_position.unwrap_or((0.0, 0.0));
self.queue.push_back(PlatformEvent::MouseInput {
button: mouse_button_code(*button),
pressed: state.is_pressed(),
x,
y,
});
}
WindowEvent::CursorMoved { position, .. } => {
self.cursor_position = Some((position.x, position.y));
self.queue.push_back(PlatformEvent::CursorMoved {
x: position.x,
y: position.y,
});
}
WindowEvent::Resized(size) => {
self.queue.push_back(PlatformEvent::Resize {
width: size.width,
height: size.height,
});
let minimized = size.width == 0 || size.height == 0;
if self.minimized != Some(minimized) {
self.minimized = Some(minimized);
self.queue.push_back(PlatformEvent::Minimized { minimized });
}
}
WindowEvent::Focused(focused) => {
self.queue
.push_back(PlatformEvent::FocusChanged { focused: *focused });
}
WindowEvent::Occluded(occluded) => {
if self.occluded != Some(*occluded) {
self.occluded = Some(*occluded);
self.queue.push_back(PlatformEvent::Occluded {
occluded: *occluded,
});
}
}
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
self.queue.push_back(PlatformEvent::DpiChanged {
scale: *scale_factor,
});
}
WindowEvent::CloseRequested => {
self.queue.push_back(PlatformEvent::QuitRequested);
}
_ => {}
}
}
/// Pushes events from an event loop event.
pub fn push_event<T>(&mut self, event: &Event<T>) {
match event {
Event::Resumed => self.queue.push_back(PlatformEvent::Resumed),
Event::Suspended => self.queue.push_back(PlatformEvent::Suspended),
Event::WindowEvent { event, .. } => self.push_window_event(event),
_ => {}
}
}
}
fn mouse_button_code(button: MouseButton) -> u16 {
match button {
MouseButton::Left => 0,
MouseButton::Right => 1,
MouseButton::Middle => 2,
MouseButton::Back => 3,
MouseButton::Forward => 4,
MouseButton::Other(index) => 100_u16.saturating_add(index),
}
}
impl EventSource for WinitEventSource {
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError> {
while let Some(event) = self.queue.pop_front() {
out.push(event);
}
Ok(())
}
}
/// Window creation plan for native smoke entrypoints.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct WinitWindowPlan {
/// Requested drawable width in physical pixels.
pub width: u32,
/// Requested drawable height in physical pixels.
pub height: u32,
/// Whether native window/display handles are required by the caller.
pub requires_native_handles: bool,
}
impl WinitWindowPlan {
/// Returns the Stage 0 native smoke window plan.
#[must_use]
pub const fn smoke() -> Self {
Self {
width: DEFAULT_SMOKE_WIDTH,
height: DEFAULT_SMOKE_HEIGHT,
requires_native_handles: true,
}
}
/// Validates the window plan before a native event loop is entered.
///
/// # Errors
///
/// Returns [`PlatformError`] when the drawable extent is zero.
pub fn validate(self) -> Result<Self, PlatformError> {
if self.width == 0 || self.height == 0 {
return Err(PlatformError::Backend {
context: "winit window plan",
message: "drawable extent must be non-zero".to_string(),
});
}
Ok(self)
}
}
/// Minimal window view over a `winit` window.
#[derive(Clone, Copy, Debug)]
pub struct WinitWindow {
handle: WindowHandle,
width: u32,
height: u32,
scale: f64,
focused: bool,
minimized: bool,
occluded: bool,
native_handles: Option<NativeWindowHandles>,
}
impl WinitWindow {
/// Builds a stable descriptor from a `winit` window.
#[must_use]
pub fn from_window(window: &Window) -> Self {
let scale = window.scale_factor();
let size = window.inner_size();
Self {
handle: WindowHandle {
id: next_window_id(),
},
width: size.width,
height: size.height,
scale,
focused: true,
minimized: false,
occluded: false,
native_handles: window_native_handles(window),
}
}
/// Returns conservative defaults if a native window is not available yet.
#[must_use]
pub fn synthetic(width: u32, height: u32) -> Self {
Self {
handle: WindowHandle {
id: next_window_id(),
},
width,
height,
scale: 1.0,
focused: true,
minimized: false,
occluded: false,
native_handles: None,
}
}
/// Returns requested default render profile for integration points.
#[must_use]
pub const fn default_render_request() -> RenderRequest {
RenderRequest::conservative()
}
/// Applies one platform event to the cached window descriptor state.
pub fn apply_event(&mut self, event: &PlatformEvent) {
match event {
PlatformEvent::Resize { width, height } => {
self.width = *width;
self.height = *height;
}
PlatformEvent::DpiChanged { scale } => {
self.scale = *scale;
}
PlatformEvent::FocusChanged { focused } => {
self.focused = *focused;
}
PlatformEvent::Minimized { minimized } => {
self.minimized = *minimized;
}
PlatformEvent::Occluded { occluded } => {
self.occluded = *occluded;
}
PlatformEvent::Suspended
| PlatformEvent::Resumed
| PlatformEvent::QuitRequested
| PlatformEvent::KeyboardInput { .. }
| PlatformEvent::MouseInput { .. }
| PlatformEvent::CursorMoved { .. } => {}
}
}
/// Applies a sequence of platform events to the cached window descriptor state.
pub fn apply_events<'a>(&mut self, events: impl IntoIterator<Item = &'a PlatformEvent>) {
for event in events {
self.apply_event(event);
}
}
/// Applies one native `winit` window event to the cached window descriptor state.
pub fn apply_window_event(&mut self, event: &WindowEvent) {
match event {
WindowEvent::Resized(size) => {
self.width = size.width;
self.height = size.height;
self.minimized = size.width == 0 || size.height == 0;
}
WindowEvent::Focused(focused) => {
self.focused = *focused;
}
WindowEvent::Occluded(occluded) => {
self.occluded = *occluded;
}
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
self.scale = *scale_factor;
}
_ => {}
}
}
}
impl WindowPort for WinitWindow {
fn drawable_size(&self) -> PhysicalSize {
PhysicalSize {
width: self.width,
height: self.height,
}
}
fn dpi_scale(&self) -> f64 {
self.scale
}
fn has_focus(&self) -> bool {
self.focused
}
fn is_minimized(&self) -> bool {
self.minimized
}
fn is_occluded(&self) -> bool {
self.occluded
}
fn handle(&self) -> WindowHandle {
self.handle
}
fn native_handles(&self) -> Option<NativeWindowHandles> {
self.native_handles
}
}
/// Extracts raw handles from a live `winit::Window`.
#[must_use]
pub fn window_native_handles(window: &Window) -> Option<NativeWindowHandles> {
let display = window.display_handle().ok()?.as_raw();
let window = window.window_handle().ok()?.as_raw();
Some(NativeWindowHandles { display, window })
}
#[cfg(test)]
mod tests {
use super::*;
use winit::event::{DeviceId, ElementState};
#[test]
fn event_source_buffers_synthetic_events() -> Result<(), PlatformError> {
let mut source = WinitEventSource::new();
source.push(PlatformEvent::Resumed);
source.push(PlatformEvent::QuitRequested);
let mut events = Vec::new();
source.poll(&mut events)?;
assert_eq!(
events,
vec![PlatformEvent::Resumed, PlatformEvent::QuitRequested]
);
Ok(())
}
#[test]
fn window_port_reports_default_request_profile() {
let window = WinitWindow::synthetic(640, 360);
let request = WinitWindow::default_render_request();
assert_eq!(
request.presentation,
fparkan_platform::PresentationMode::Fifo
);
assert_eq!(
window.drawable_size(),
PhysicalSize {
width: 640,
height: 360
}
);
assert!(window.native_handles().is_none());
}
#[test]
fn smoke_window_plan_requires_native_handles_and_nonzero_extent() -> Result<(), PlatformError> {
let plan = WinitWindowPlan::smoke().validate()?;
assert_eq!(plan.width, DEFAULT_SMOKE_WIDTH);
assert_eq!(plan.height, DEFAULT_SMOKE_HEIGHT);
assert!(plan.requires_native_handles);
Ok(())
}
#[test]
fn smoke_window_plan_rejects_zero_extent() {
let plan = WinitWindowPlan {
width: 0,
height: DEFAULT_SMOKE_HEIGHT,
requires_native_handles: true,
};
assert!(matches!(
plan.validate(),
Err(PlatformError::Backend {
context: "winit window plan",
..
})
));
}
#[test]
fn monotonic_clock_uses_process_local_epoch() {
let clock = WinitClock;
let first = clock.now();
let second = clock.now();
assert!(second >= first);
}
#[test]
fn window_events_push_expected_platform_events() {
let mut source = WinitEventSource::new();
let size = winit::dpi::PhysicalSize::new(1024u32, 768u32);
source.push_window_event(&WindowEvent::Resized(size));
source.push_window_event(&WindowEvent::Focused(false));
source.push_window_event(&WindowEvent::CloseRequested);
let mut events = Vec::new();
source
.poll(&mut events)
.expect("platform event pump should never fail");
assert!(events.contains(&PlatformEvent::Resize {
width: 1024,
height: 768,
}));
assert!(events.contains(&PlatformEvent::FocusChanged { focused: false }));
assert!(events.contains(&PlatformEvent::QuitRequested));
}
#[test]
fn push_event_maps_lifecycle_resumed_and_suspended() -> Result<(), PlatformError> {
let mut source = WinitEventSource::new();
source.push_event(&Event::<()>::Resumed);
source.push_event(&Event::<()>::Suspended);
let mut events = Vec::new();
source.poll(&mut events)?;
assert_eq!(
events,
vec![PlatformEvent::Resumed, PlatformEvent::Suspended]
);
Ok(())
}
#[test]
fn cursor_position_and_occlusion_are_preserved_for_mouse_input() -> Result<(), PlatformError> {
let mut source = WinitEventSource::new();
source.push_window_event(&WindowEvent::CursorMoved {
device_id: DeviceId::dummy(),
position: (320.0, 240.0).into(),
});
source.push_window_event(&WindowEvent::MouseInput {
device_id: DeviceId::dummy(),
state: ElementState::Pressed,
button: MouseButton::Other(u16::MAX),
});
source.push_window_event(&WindowEvent::Occluded(true));
let mut events = Vec::new();
source.poll(&mut events)?;
assert!(events.contains(&PlatformEvent::CursorMoved { x: 320.0, y: 240.0 }));
assert!(events.contains(&PlatformEvent::MouseInput {
button: u16::MAX,
pressed: true,
x: 320.0,
y: 240.0,
}));
assert!(events.contains(&PlatformEvent::Occluded { occluded: true }));
Ok(())
}
#[test]
fn zero_extent_resize_updates_minimized_state() -> Result<(), PlatformError> {
let mut source = WinitEventSource::new();
source.push_window_event(&WindowEvent::Resized(winit::dpi::PhysicalSize::new(
0u32, 720u32,
)));
source.push_window_event(&WindowEvent::Resized(winit::dpi::PhysicalSize::new(
1280u32, 720u32,
)));
let mut events = Vec::new();
source.poll(&mut events)?;
assert!(events.contains(&PlatformEvent::Minimized { minimized: true }));
assert!(events.contains(&PlatformEvent::Minimized { minimized: false }));
Ok(())
}
#[test]
fn window_descriptor_applies_lifecycle_and_resize_events() {
let mut window = WinitWindow::synthetic(640, 360);
let events = [
PlatformEvent::Resize {
width: 1280,
height: 720,
},
PlatformEvent::DpiChanged { scale: 2.0 },
PlatformEvent::FocusChanged { focused: false },
PlatformEvent::Minimized { minimized: true },
PlatformEvent::Occluded { occluded: true },
];
window.apply_events(events.iter());
assert_eq!(
window.drawable_size(),
PhysicalSize {
width: 1280,
height: 720,
}
);
assert_eq!(window.dpi_scale(), 2.0);
assert!(!window.has_focus());
assert!(window.is_minimized());
assert!(window.is_occluded());
}
#[test]
fn window_descriptor_applies_native_window_events() {
let mut window = WinitWindow::synthetic(640, 360);
window.apply_window_event(&WindowEvent::Resized(winit::dpi::PhysicalSize::new(
0u32, 720u32,
)));
window.apply_window_event(&WindowEvent::Focused(false));
window.apply_window_event(&WindowEvent::Occluded(true));
assert_eq!(
window.drawable_size(),
PhysicalSize {
width: 0,
height: 720,
}
);
assert!(!window.has_focus());
assert!(window.is_minimized());
assert!(window.is_occluded());
}
}
// SAFETY: no unsafe usage in this crate.
-242
View File
@@ -1,242 +0,0 @@
#![forbid(unsafe_code)]
//! OpenGL render adapter proof behind safe `FParkan` render ports.
use fparkan_render::{
canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError,
};
/// Portable OpenGL profile requested by the game composition root.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GlProfile {
/// Desktop OpenGL 3.3 Core.
DesktopCore33,
/// OpenGL ES 2.0 portable baseline.
Gles2,
}
/// Shader stage used in diagnostics.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ShaderStage {
/// Vertex shader.
Vertex,
/// Fragment shader.
Fragment,
}
/// Shader compilation diagnostic surfaced by the adapter.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ShaderCompileError {
/// Requested GL profile.
pub profile: GlProfile,
/// Shader stage.
pub stage: ShaderStage,
/// Backend compiler log.
pub log: String,
}
impl std::fmt::Display for ShaderCompileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{:?} {:?} shader compile failed: {}",
self.profile, self.stage, self.log
)
}
}
impl std::error::Error for ShaderCompileError {}
/// Adapter capabilities compiled into this package.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GlAdapterCapabilities {
/// Supported profiles in preference order.
pub profiles: Vec<GlProfile>,
/// Whether adapter-owned code is free of `unsafe`.
pub project_owned_unsafe_free: bool,
}
impl Default for GlAdapterCapabilities {
fn default() -> Self {
Self {
profiles: vec![GlProfile::DesktopCore33, GlProfile::Gles2],
project_owned_unsafe_free: true,
}
}
}
/// Returns adapter readiness status for the safe project-owned layer.
#[must_use]
pub fn safe_adapter_ready() -> bool {
GlAdapterCapabilities::default().project_owned_unsafe_free
}
/// Validates shader source through the adapter diagnostic contract.
///
/// # Errors
///
/// Returns [`ShaderCompileError`] when the source is empty or contains a
/// deterministic synthetic failure marker.
pub fn compile_shader_source(
profile: GlProfile,
stage: ShaderStage,
source: &str,
) -> Result<(), ShaderCompileError> {
if source.trim().is_empty() {
return Err(ShaderCompileError {
profile,
stage,
log: "empty shader source".to_string(),
});
}
if source.contains("#error") {
return Err(ShaderCompileError {
profile,
stage,
log: "synthetic compiler failure marker".to_string(),
});
}
Ok(())
}
/// Safe render backend facade used for adapter-level command validation.
///
/// A concrete OpenGL implementation can be injected behind the same
/// [`RenderBackend`] port once an audited safe GL facade is selected. This type
/// keeps the project-owned adapter API executable without introducing local FFI.
#[derive(Clone, Debug)]
pub struct SafeGlCommandBackend {
profile: GlProfile,
captures: Vec<Vec<u8>>,
}
impl SafeGlCommandBackend {
/// Creates a backend proof for a requested GL profile.
#[must_use]
pub fn new(profile: GlProfile) -> Self {
Self {
profile,
captures: Vec::new(),
}
}
/// Active GL profile.
#[must_use]
pub fn profile(&self) -> GlProfile {
self.profile
}
/// Deterministic command captures produced by executed frames.
#[must_use]
pub fn captures(&self) -> &[Vec<u8>] {
&self.captures
}
}
impl RenderBackend for SafeGlCommandBackend {
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
self.captures.push(canonical_capture(commands)?);
Ok(FrameOutput)
}
}
#[cfg(test)]
mod tests {
use super::*;
use fparkan_render::{
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderCommand, RenderPhase,
};
#[test]
fn adapter_reports_safe_project_layer_ready() {
assert!(safe_adapter_ready());
assert_eq!(GlAdapterCapabilities::default().profiles.len(), 2);
}
#[test]
fn backend_executes_and_captures_commands() -> Result<(), RenderError> {
let mut backend = SafeGlCommandBackend::new(GlProfile::Gles2);
let commands = RenderCommandList {
commands: vec![
RenderCommand::BeginFrame,
RenderCommand::Draw(DrawCommand {
id: DrawId(7),
phase: RenderPhase::Opaque,
object_id: None,
mesh: GpuMeshId(11),
material: GpuMaterialId(13),
transform: [0.0; 16],
range: IndexRange { start: 0, count: 3 },
stable_order: 17,
}),
RenderCommand::EndFrame,
],
};
backend.execute(&commands)?;
assert_eq!(backend.profile(), GlProfile::Gles2);
assert_eq!(backend.captures().len(), 1);
Ok(())
}
#[test]
fn desktop_gl33_triangle_command_capture() -> Result<(), RenderError> {
let mut backend = SafeGlCommandBackend::new(GlProfile::DesktopCore33);
let commands = triangle_commands();
backend.execute(&commands)?;
assert_eq!(backend.profile(), GlProfile::DesktopCore33);
assert_eq!(
backend.captures(),
&[b"B\nD,Opaque,7,11,13,17\nE\n".to_vec()]
);
Ok(())
}
#[test]
fn gles2_triangle_command_capture() -> Result<(), RenderError> {
let mut backend = SafeGlCommandBackend::new(GlProfile::Gles2);
let commands = triangle_commands();
backend.execute(&commands)?;
assert_eq!(backend.profile(), GlProfile::Gles2);
assert_eq!(
backend.captures(),
&[b"B\nD,Opaque,7,11,13,17\nE\n".to_vec()]
);
Ok(())
}
#[test]
fn shader_compile_failure_diagnostic_contains_profile_and_log() {
let err = compile_shader_source(GlProfile::Gles2, ShaderStage::Fragment, "#error")
.expect_err("shader failure");
assert_eq!(err.profile, GlProfile::Gles2);
assert_eq!(err.stage, ShaderStage::Fragment);
assert!(err.log.contains("synthetic compiler failure"));
assert!(err.to_string().contains("Gles2"));
assert!(err.to_string().contains("synthetic compiler failure"));
}
fn triangle_commands() -> RenderCommandList {
RenderCommandList {
commands: vec![
RenderCommand::BeginFrame,
RenderCommand::Draw(DrawCommand {
id: DrawId(7),
phase: RenderPhase::Opaque,
object_id: None,
mesh: GpuMeshId(11),
material: GpuMaterialId(13),
transform: [0.0; 16],
range: IndexRange { start: 0, count: 3 },
stable_order: 17,
}),
RenderCommand::EndFrame,
],
}
}
}
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "fparkan-render-vulkan"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
build = "build.rs"
[dependencies]
ash = "0.38"
ash-window = "0.13"
fparkan-animation = { path = "../../crates/fparkan-animation", version = "0.1.0" }
fparkan-binary = { path = "../../crates/fparkan-binary", version = "0.1.0" }
fparkan-msh = { path = "../../crates/fparkan-msh", version = "0.1.0" }
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
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_json = "1.0"
[lints.rust]
unsafe_code = "allow"
missing_docs = "warn"
unreachable_pub = "warn"
unused_must_use = "deny"
[lints.clippy]
all = { level = "deny", priority = -1 }
pedantic = { level = "warn", priority = -1 }
unwrap_used = "deny"
expect_used = "deny"
panic = "deny"
todo = "deny"
unimplemented = "deny"
dbg_macro = "deny"
print_stdout = "warn"
print_stderr = "warn"
lossy_float_literal = "deny"
+92
View File
@@ -0,0 +1,92 @@
//! Build-time shader tool metadata for Vulkan reports.
use std::env;
use std::path::Path;
use std::process::Command;
const SHADER_COMPILER_NAME: &str = "glslangValidator";
const SPIRV_VALIDATOR_NAME: &str = "spirv-val";
fn main() {
println!("cargo:rerun-if-env-changed=PATH");
println!("cargo:rerun-if-env-changed=FPARKAN_GLSLANG_VALIDATOR");
println!("cargo:rerun-if-env-changed=FPARKAN_SPIRV_VAL");
emit_tool_metadata(
"FPARKAN_BUILD_SHADER_COMPILER",
&tool_path("FPARKAN_GLSLANG_VALIDATOR", SHADER_COMPILER_NAME),
);
emit_tool_metadata(
"FPARKAN_BUILD_SPIRV_VALIDATOR",
&tool_path("FPARKAN_SPIRV_VAL", SPIRV_VALIDATOR_NAME),
);
}
fn tool_path(env_var: &str, fallback: &str) -> String {
env::var(env_var).unwrap_or_else(|_| fallback.to_string())
}
fn emit_tool_metadata(prefix: &str, tool: &str) {
let Some(path) = resolve_tool(tool) else {
return;
};
println!("cargo:rerun-if-changed={}", path.display());
let Some(version) = tool_version(&path) else {
return;
};
let Some(binary_sha256) = tool_sha256(&path) else {
return;
};
let name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(tool)
.to_string();
println!("cargo:rustc-env={prefix}_NAME={name}");
println!("cargo:rustc-env={prefix}_VERSION={version}");
println!("cargo:rustc-env={prefix}_SHA256={binary_sha256}");
}
fn resolve_tool(tool: &str) -> Option<std::path::PathBuf> {
let candidate = Path::new(tool);
if candidate.components().count() > 1 {
return candidate.is_file().then(|| candidate.to_path_buf());
}
let output = Command::new("which").arg(tool).output().ok()?;
if !output.status.success() {
return None;
}
let path = String::from_utf8(output.stdout).ok()?;
let path = path.trim();
(!path.is_empty()).then(|| path.into())
}
fn tool_version(path: &Path) -> Option<String> {
let output = Command::new(path).arg("--version").output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
stdout
.lines()
.find(|line| !line.trim().is_empty())
.map(str::trim)
.map(|line| {
line.strip_prefix("Glslang Version: ")
.unwrap_or(line)
.to_string()
})
}
fn tool_sha256(path: &Path) -> Option<String> {
let output = Command::new("shasum")
.args(["-a", "256"])
.arg(path)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
stdout.split_whitespace().next().map(ToString::to_string)
}
@@ -0,0 +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":"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"}
@@ -0,0 +1,19 @@
#version 450
layout(location = 0) in vec3 in_color;
layout(location = 1) in vec2 in_uv;
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() {
out_color = texture(base_color, in_uv) * vec4(in_color, 1.0);
if (out_color.a < alpha_test.alpha_cutoff) {
discard;
}
}
@@ -0,0 +1,18 @@
#version 450
layout(location = 0) in vec3 in_position;
layout(location = 1) in vec3 in_color;
layout(location = 2) in vec2 in_uv;
layout(location = 0) out vec3 out_color;
layout(location = 1) out vec2 out_uv;
layout(push_constant) uniform CameraConstants {
mat4 clip_from_world;
} camera;
void main() {
out_color = in_color;
out_uv = in_uv;
gl_Position = camera.clip_from_world * vec4(in_position, 1.0);
}
File diff suppressed because it is too large Load Diff
+515
View File
@@ -0,0 +1,515 @@
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
#![deny(unsafe_op_in_unsafe_fn)]
//! Vulkan adapter facade and migration-ready backend surface contract.
//!
//! This module intentionally keeps backend-agnostic command validation in the
//! shared render crate while exposing deterministic lifecycle telemetry used by
//! Stage 0 acceptance evidence.
//!
//! This crate is the declared low-level Vulkan boundary.
#[path = "asset_mesh.rs"]
mod asset_mesh;
mod capabilities;
mod instance;
mod resources;
mod runtime;
mod smoke;
mod smoke_types;
mod surface;
mod swapchain;
mod swapchain_resources;
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::{
probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request,
VulkanRuntimeCapabilityError, VulkanRuntimeCapabilityProbe,
};
pub use self::instance::{
create_vulkan_instance_probe, plan_vulkan_instance, probe_vulkan_loader,
render_instance_plan_json, render_loader_probe_report_json, vulkan_entry_symbol_name,
VulkanInstanceConfig, VulkanInstanceError, VulkanInstancePlan, VulkanInstanceProbe,
VulkanLoaderError, VulkanLoaderProbeReport,
};
#[cfg(test)]
use self::instance::{cstring_vec, ensure_instance_extensions_available};
use self::resources::{
color_subresource_range, create_command_pool, create_depth_attachment, create_frame_sync,
create_readback_buffer, create_static_mesh_index_buffer, create_static_mesh_vertex_buffer,
create_static_texture_image, destroy_allocated_buffer, destroy_allocated_image,
destroy_depth_attachment, readback_buffer_bytes, VulkanAllocatedBuffer, VulkanAllocatedImage,
VulkanDepthAttachment, VulkanFrameSync,
};
pub use self::runtime::{
create_vulkan_logical_device_probe, create_vulkan_logical_device_probe_for_request,
VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanLogicalDeviceReport,
};
pub use self::smoke_types::{
VulkanReadbackArtifact, VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot,
VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport,
VulkanStaticCamera, VulkanStaticDrawRange, VulkanStaticMaterial, VulkanStaticMesh,
VulkanStaticTexture, VulkanStaticVertex, VulkanValidationReport,
};
#[cfg(test)]
use self::surface::extension_name;
pub use self::surface::{
create_vulkan_surface_probe, plan_vulkan_surface, render_surface_plan_json, VulkanSurfaceError,
VulkanSurfacePlan, VulkanSurfaceProbe,
};
pub use self::swapchain::{
create_vulkan_swapchain_probe, create_vulkan_swapchain_probe_for_extent, VulkanSwapchainProbe,
VulkanSwapchainProbeError, VulkanSwapchainReport,
};
use self::swapchain_resources::{
create_swapchain_resources, destroy_swapchain_resources, VulkanSwapchainResources,
};
use self::validation::{create_validation_messenger, VulkanValidationMessenger};
use ash::vk;
/// Minimum Vulkan API version accepted by the Stage 0 backend.
pub const MIN_VULKAN_API_VERSION: u32 = vk::API_VERSION_1_1;
const KHR_PORTABILITY_ENUMERATION_EXTENSION: &str = "VK_KHR_portability_enumeration";
const EXT_DEBUG_UTILS_EXTENSION: &str = "VK_EXT_debug_utils";
const VALIDATION_LAYER_NAME: &str = "VK_LAYER_KHRONOS_validation";
pub(crate) const SPIRV_MAGIC: u32 = 0x0723_0203;
pub(crate) const SPIRV_VERSION_1_0: u32 = 0x0001_0000;
#[allow(dead_code)]
const LEGACY_TRIANGLE_VERTEX_SHADER_WORDS: &[u32] = &[
0x0723_0203,
0x0001_0300,
0x0008_000b,
0x0000_0021,
0x0000_0000,
0x0002_0011,
0x0000_0001,
0x0006_000b,
0x0000_0001,
0x4c53_4c47,
0x6474_732e,
0x3035_342e,
0x0000_0000,
0x0003_000e,
0x0000_0000,
0x0000_0001,
0x0009_000f,
0x0000_0000,
0x0000_0004,
0x6e69_616d,
0x0000_0000,
0x0000_0009,
0x0000_000b,
0x0000_0013,
0x0000_0018,
0x0003_0003,
0x0000_0002,
0x0000_01c2,
0x0004_0005,
0x0000_0004,
0x6e69_616d,
0x0000_0000,
0x0005_0005,
0x0000_0009,
0x5f74_756f,
0x6f6c_6f63,
0x0000_0072,
0x0005_0005,
0x0000_000b,
0x635f_6e69,
0x726f_6c6f,
0x0000_0000,
0x0006_0005,
0x0000_0011,
0x505f_6c67,
0x6556_7265,
0x7865_7472,
0x0000_0000,
0x0006_0006,
0x0000_0011,
0x0000_0000,
0x505f_6c67,
0x7469_736f,
0x006e_6f69,
0x0007_0006,
0x0000_0011,
0x0000_0001,
0x505f_6c67,
0x746e_696f,
0x657a_6953,
0x0000_0000,
0x0007_0006,
0x0000_0011,
0x0000_0002,
0x435f_6c67,
0x4470_696c,
0x6174_7369,
0x0065_636e,
0x0007_0006,
0x0000_0011,
0x0000_0003,
0x435f_6c67,
0x446c_6c75,
0x6174_7369,
0x0065_636e,
0x0003_0005,
0x0000_0013,
0x0000_0000,
0x0005_0005,
0x0000_0018,
0x705f_6e69,
0x7469_736f,
0x006e_6f69,
0x0004_0047,
0x0000_0009,
0x0000_001e,
0x0000_0000,
0x0004_0047,
0x0000_000b,
0x0000_001e,
0x0000_0001,
0x0003_0047,
0x0000_0011,
0x0000_0002,
0x0005_0048,
0x0000_0011,
0x0000_0000,
0x0000_000b,
0x0000_0000,
0x0005_0048,
0x0000_0011,
0x0000_0001,
0x0000_000b,
0x0000_0001,
0x0005_0048,
0x0000_0011,
0x0000_0002,
0x0000_000b,
0x0000_0003,
0x0005_0048,
0x0000_0011,
0x0000_0003,
0x0000_000b,
0x0000_0004,
0x0004_0047,
0x0000_0018,
0x0000_001e,
0x0000_0000,
0x0002_0013,
0x0000_0002,
0x0003_0021,
0x0000_0003,
0x0000_0002,
0x0003_0016,
0x0000_0006,
0x0000_0020,
0x0004_0017,
0x0000_0007,
0x0000_0006,
0x0000_0003,
0x0004_0020,
0x0000_0008,
0x0000_0003,
0x0000_0007,
0x0004_003b,
0x0000_0008,
0x0000_0009,
0x0000_0003,
0x0004_0020,
0x0000_000a,
0x0000_0001,
0x0000_0007,
0x0004_003b,
0x0000_000a,
0x0000_000b,
0x0000_0001,
0x0004_0017,
0x0000_000d,
0x0000_0006,
0x0000_0004,
0x0004_0015,
0x0000_000e,
0x0000_0020,
0x0000_0000,
0x0004_002b,
0x0000_000e,
0x0000_000f,
0x0000_0001,
0x0004_001c,
0x0000_0010,
0x0000_0006,
0x0000_000f,
0x0006_001e,
0x0000_0011,
0x0000_000d,
0x0000_0006,
0x0000_0010,
0x0000_0010,
0x0004_0020,
0x0000_0012,
0x0000_0003,
0x0000_0011,
0x0004_003b,
0x0000_0012,
0x0000_0013,
0x0000_0003,
0x0004_0015,
0x0000_0014,
0x0000_0020,
0x0000_0001,
0x0004_002b,
0x0000_0014,
0x0000_0015,
0x0000_0000,
0x0004_0017,
0x0000_0016,
0x0000_0006,
0x0000_0002,
0x0004_0020,
0x0000_0017,
0x0000_0001,
0x0000_0016,
0x0004_003b,
0x0000_0017,
0x0000_0018,
0x0000_0001,
0x0004_002b,
0x0000_0006,
0x0000_001a,
0x0000_0000,
0x0004_002b,
0x0000_0006,
0x0000_001b,
0x3f80_0000,
0x0004_0020,
0x0000_001f,
0x0000_0003,
0x0000_000d,
0x0005_0036,
0x0000_0002,
0x0000_0004,
0x0000_0000,
0x0000_0003,
0x0002_00f8,
0x0000_0005,
0x0004_003d,
0x0000_0007,
0x0000_000c,
0x0000_000b,
0x0003_003e,
0x0000_0009,
0x0000_000c,
0x0004_003d,
0x0000_0016,
0x0000_0019,
0x0000_0018,
0x0005_0051,
0x0000_0006,
0x0000_001c,
0x0000_0019,
0x0000_0000,
0x0005_0051,
0x0000_0006,
0x0000_001d,
0x0000_0019,
0x0000_0001,
0x0007_0050,
0x0000_000d,
0x0000_001e,
0x0000_001c,
0x0000_001d,
0x0000_001a,
0x0000_001b,
0x0005_0041,
0x0000_001f,
0x0000_0020,
0x0000_0013,
0x0000_0015,
0x0003_003e,
0x0000_0020,
0x0000_001e,
0x0001_00fd,
0x0001_0038,
];
#[allow(dead_code)]
const LEGACY_TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &[
0x0723_0203,
0x0001_0300,
0x0008_000b,
0x0000_0013,
0x0000_0000,
0x0002_0011,
0x0000_0001,
0x0006_000b,
0x0000_0001,
0x4c53_4c47,
0x6474_732e,
0x3035_342e,
0x0000_0000,
0x0003_000e,
0x0000_0000,
0x0000_0001,
0x0007_000f,
0x0000_0004,
0x0000_0004,
0x6e69_616d,
0x0000_0000,
0x0000_0009,
0x0000_000c,
0x0003_0010,
0x0000_0004,
0x0000_0007,
0x0003_0003,
0x0000_0002,
0x0000_01c2,
0x0004_0005,
0x0000_0004,
0x6e69_616d,
0x0000_0000,
0x0005_0005,
0x0000_0009,
0x5f74_756f,
0x6f6c_6f63,
0x0000_0072,
0x0005_0005,
0x0000_000c,
0x635f_6e69,
0x726f_6c6f,
0x0000_0000,
0x0004_0047,
0x0000_0009,
0x0000_001e,
0x0000_0000,
0x0004_0047,
0x0000_000c,
0x0000_001e,
0x0000_0000,
0x0002_0013,
0x0000_0002,
0x0003_0021,
0x0000_0003,
0x0000_0002,
0x0003_0016,
0x0000_0006,
0x0000_0020,
0x0004_0017,
0x0000_0007,
0x0000_0006,
0x0000_0004,
0x0004_0020,
0x0000_0008,
0x0000_0003,
0x0000_0007,
0x0004_003b,
0x0000_0008,
0x0000_0009,
0x0000_0003,
0x0004_0017,
0x0000_000a,
0x0000_0006,
0x0000_0003,
0x0004_0020,
0x0000_000b,
0x0000_0001,
0x0000_000a,
0x0004_003b,
0x0000_000b,
0x0000_000c,
0x0000_0001,
0x0004_002b,
0x0000_0006,
0x0000_000e,
0x3f80_0000,
0x0005_0036,
0x0000_0002,
0x0000_0004,
0x0000_0000,
0x0000_0003,
0x0002_00f8,
0x0000_0005,
0x0004_003d,
0x0000_000a,
0x0000_000d,
0x0000_000c,
0x0005_0051,
0x0000_0006,
0x0000_000f,
0x0000_000d,
0x0000_0000,
0x0005_0051,
0x0000_0006,
0x0000_0010,
0x0000_000d,
0x0000_0001,
0x0005_0051,
0x0000_0006,
0x0000_0011,
0x0000_000d,
0x0000_0002,
0x0007_0050,
0x0000_0007,
0x0000_0012,
0x0000_000f,
0x0000_0010,
0x0000_0011,
0x0000_000e,
0x0003_003e,
0x0000_0009,
0x0000_0012,
0x0001_00fd,
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)]
mod tests;
@@ -0,0 +1,508 @@
#![allow(unsafe_code)]
use ash::vk;
use fparkan_platform::RenderRequest;
use std::ffi::CStr;
use super::{VulkanInstanceProbe, VulkanSurfaceProbe};
use crate::policy::{
compare_reports, plan_vulkan_swapchain, validate_device_for_request, VulkanCapabilityError,
VulkanCapabilityReport, VulkanDeviceLimits, VulkanDeviceType, VulkanPhysicalDeviceRecord,
VulkanQueueFamily, VulkanSurfaceFormat, VulkanSwapchainError, VulkanSwapchainPlan,
VulkanSwapchainRequest, VulkanSwapchainSurfaceCapabilities,
};
/// Live Vulkan device/surface capability probe.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanRuntimeCapabilityProbe {
/// Selected device/queue capability report.
pub capability: VulkanCapabilityReport,
/// Swapchain plan built from the selected device and live surface capabilities.
pub swapchain: VulkanSwapchainPlan,
}
/// Live Vulkan device/surface capability probe error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VulkanRuntimeCapabilityError {
/// Physical device enumeration failed.
EnumerateDevicesFailed {
/// Vulkan result.
result: vk::Result,
},
/// Device extension enumeration failed.
EnumerateDeviceExtensionsFailed {
/// Device name or index context.
device: String,
/// Vulkan result.
result: vk::Result,
},
/// Queue-family present support query failed.
PresentSupportFailed {
/// Device name.
device: String,
/// Queue-family index.
queue_family: u32,
/// Vulkan result.
result: vk::Result,
},
/// Surface format query failed.
SurfaceFormatsFailed {
/// Device name.
device: String,
/// Vulkan result.
result: vk::Result,
},
/// Surface capability query failed.
SurfaceCapabilitiesFailed {
/// Device name.
device: String,
/// Vulkan result.
result: vk::Result,
},
/// Present mode query failed.
PresentModesFailed {
/// Device name.
device: String,
/// Vulkan result.
result: vk::Result,
},
/// No device satisfied Stage 0 capability policy.
Capability(VulkanCapabilityError),
/// Live surface capabilities could not produce a swapchain plan.
Swapchain(VulkanSwapchainError),
}
impl std::fmt::Display for VulkanRuntimeCapabilityError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EnumerateDevicesFailed { result } => {
write!(f, "Vulkan physical device enumeration failed: {result:?}")
}
Self::EnumerateDeviceExtensionsFailed { device, result } => write!(
f,
"Vulkan device {device} extension enumeration failed: {result:?}"
),
Self::PresentSupportFailed {
device,
queue_family,
result,
} => write!(
f,
"Vulkan device {device} queue family {queue_family} present support query failed: {result:?}"
),
Self::SurfaceFormatsFailed { device, result } => write!(
f,
"Vulkan device {device} surface format query failed: {result:?}"
),
Self::SurfaceCapabilitiesFailed { device, result } => write!(
f,
"Vulkan device {device} surface capabilities query failed: {result:?}"
),
Self::PresentModesFailed { device, result } => write!(
f,
"Vulkan device {device} present mode query failed: {result:?}"
),
Self::Capability(error) => write!(f, "{error}"),
Self::Swapchain(error) => write!(f, "{error}"),
}
}
}
impl std::error::Error for VulkanRuntimeCapabilityError {}
pub(super) struct SelectedLiveDevice {
pub(super) physical_device: vk::PhysicalDevice,
pub(super) runtime: VulkanRuntimeCapabilityProbe,
}
struct LiveDeviceCandidate {
physical_device: vk::PhysicalDevice,
capability: VulkanCapabilityReport,
surface_formats: Vec<VulkanSurfaceFormat>,
present_modes: Vec<i32>,
surface_capabilities: VulkanSwapchainSurfaceCapabilities,
}
/// Probes live Vulkan device, queue, surface and swapchain capabilities.
///
/// # Errors
///
/// Returns [`VulkanRuntimeCapabilityError`] when device enumeration, surface
/// capability queries, Stage 0 device selection, or swapchain planning fails.
pub fn probe_vulkan_runtime_capabilities(
instance: &VulkanInstanceProbe,
surface: &VulkanSurfaceProbe,
drawable_extent: (u32, u32),
) -> Result<VulkanRuntimeCapabilityProbe, VulkanRuntimeCapabilityError> {
let selected = select_live_device_candidate_for_request(
instance,
surface,
drawable_extent,
RenderRequest::conservative(),
)?;
Ok(selected.runtime)
}
/// Probes live Vulkan device, queue, surface and swapchain capabilities for a
/// specific Stage 0 render request.
///
/// # Errors
///
/// Returns [`VulkanRuntimeCapabilityError`] when device enumeration, surface
/// capability queries, Stage 0 device selection, or swapchain planning fails.
pub fn probe_vulkan_runtime_capabilities_for_request(
instance: &VulkanInstanceProbe,
surface: &VulkanSurfaceProbe,
drawable_extent: (u32, u32),
render_request: RenderRequest,
) -> Result<VulkanRuntimeCapabilityProbe, VulkanRuntimeCapabilityError> {
let selected = select_live_device_candidate_for_request(
instance,
surface,
drawable_extent,
render_request,
)?;
Ok(selected.runtime)
}
pub(super) fn select_live_device_candidate_for_request(
instance: &VulkanInstanceProbe,
surface: &VulkanSurfaceProbe,
drawable_extent: (u32, u32),
render_request: RenderRequest,
) -> Result<SelectedLiveDevice, VulkanRuntimeCapabilityError> {
let devices = {
// SAFETY: The Vulkan instance is live for this query and no handles are retained.
unsafe { instance.instance.enumerate_physical_devices() }.map_err(|error| {
VulkanRuntimeCapabilityError::EnumerateDevicesFailed { result: error }
})?
};
let mut best: Option<LiveDeviceCandidate> = None;
let mut last_error = None;
for (index, device) in devices.iter().copied().enumerate() {
let candidate =
match live_device_candidate(instance, surface, device, index, render_request) {
Ok(candidate) => candidate,
Err(err) => {
last_error = Some(err);
continue;
}
};
match &best {
Some(existing)
if compare_reports(&candidate.capability, &existing.capability)
!= std::cmp::Ordering::Greater => {}
_ => best = Some(candidate),
}
}
let best = best.ok_or_else(|| {
last_error.unwrap_or(VulkanRuntimeCapabilityError::Capability(
VulkanCapabilityError::NoPhysicalDevice,
))
})?;
let swapchain = plan_vulkan_swapchain(&VulkanSwapchainRequest {
drawable_extent,
formats: best.surface_formats,
present_modes: best.present_modes,
capabilities: best.surface_capabilities,
preferred_present_mode: vk::PresentModeKHR::MAILBOX.as_raw(),
})
.map_err(VulkanRuntimeCapabilityError::Swapchain)?;
Ok(SelectedLiveDevice {
physical_device: best.physical_device,
runtime: VulkanRuntimeCapabilityProbe {
capability: best.capability,
swapchain,
},
})
}
fn live_device_candidate(
instance: &VulkanInstanceProbe,
surface: &VulkanSurfaceProbe,
device: vk::PhysicalDevice,
index: usize,
render_request: RenderRequest,
) -> Result<LiveDeviceCandidate, VulkanRuntimeCapabilityError> {
let properties = {
// SAFETY: `device` was returned by this live instance and the result is copied by value.
unsafe { instance.instance.get_physical_device_properties(device) }
};
let name = physical_device_name(&properties, index);
let queue_properties = {
// SAFETY: `device` was returned by this live instance and the result is owned by Rust.
unsafe {
instance
.instance
.get_physical_device_queue_family_properties(device)
}
};
let extensions = live_device_extensions(instance, device, &name)?;
let surface_formats = live_surface_formats(surface, device, &name)?;
let present_modes = live_present_modes(surface, device, &name)?;
let surface_capabilities = live_surface_capabilities(surface, device, &name)?;
let supported_depth_stencil_formats = live_depth_stencil_formats(instance, device);
let sampled_image_formats = live_sampled_image_formats(instance, device);
let queue_families = queue_properties
.iter()
.enumerate()
.map(|(queue_index, properties)| {
let index = u32::try_from(queue_index).unwrap_or(u32::MAX);
let present = {
// SAFETY: The physical device, surface and queue-family index are live query inputs.
unsafe {
surface.loader.get_physical_device_surface_support(
device,
index,
surface.surface,
)
}
}
.map_err(|error| VulkanRuntimeCapabilityError::PresentSupportFailed {
device: name.clone(),
queue_family: index,
result: error,
})?;
Ok(VulkanQueueFamily {
index,
graphics: properties.queue_flags.contains(vk::QueueFlags::GRAPHICS),
present,
})
})
.collect::<Result<Vec<_>, VulkanRuntimeCapabilityError>>()?;
let record = VulkanPhysicalDeviceRecord {
name,
api_version: properties.api_version,
device_type: match properties.device_type {
vk::PhysicalDeviceType::DISCRETE_GPU => VulkanDeviceType::DiscreteGpu,
vk::PhysicalDeviceType::INTEGRATED_GPU => VulkanDeviceType::IntegratedGpu,
vk::PhysicalDeviceType::CPU => VulkanDeviceType::Cpu,
_ => VulkanDeviceType::Other,
},
extensions,
queue_families,
surface_formats: surface_formats.clone(),
present_modes: present_modes.clone(),
surface_capabilities,
supported_depth_stencil_formats,
sampled_image_formats,
limits: VulkanDeviceLimits {
max_image_dimension_2d: properties.limits.max_image_dimension2_d,
max_sampler_allocation_count: properties.limits.max_sampler_allocation_count,
max_per_stage_descriptor_samplers: properties.limits.max_per_stage_descriptor_samplers,
max_bound_descriptor_sets: properties.limits.max_bound_descriptor_sets,
},
};
let capability = validate_device_for_request(&record, render_request)
.map_err(VulkanRuntimeCapabilityError::Capability)?;
Ok(LiveDeviceCandidate {
physical_device: device,
capability,
surface_formats,
present_modes,
surface_capabilities,
})
}
pub(super) fn unique_queue_families(graphics: u32, present: u32) -> Vec<u32> {
if graphics == present {
vec![graphics]
} else {
vec![graphics, present]
}
}
fn physical_device_name(properties: &vk::PhysicalDeviceProperties, index: usize) -> String {
// SAFETY: Vulkan device names are fixed-size NUL-terminated C strings per the spec.
let name = unsafe { CStr::from_ptr(properties.device_name.as_ptr()) }
.to_string_lossy()
.trim()
.to_string();
if name.is_empty() {
format!("physical-device-{index}")
} else {
name
}
}
fn live_device_extensions(
instance: &VulkanInstanceProbe,
device: vk::PhysicalDevice,
name: &str,
) -> Result<Vec<String>, VulkanRuntimeCapabilityError> {
let properties = {
// SAFETY: `device` was returned by this live instance and no borrowed data escapes.
unsafe {
instance
.instance
.enumerate_device_extension_properties(device)
}
}
.map_err(
|error| VulkanRuntimeCapabilityError::EnumerateDeviceExtensionsFailed {
device: name.to_string(),
result: error,
},
)?;
let mut extensions = properties
.iter()
.map(|property| {
// SAFETY: Vulkan extension names are fixed-size NUL-terminated C strings per the spec.
unsafe { CStr::from_ptr(property.extension_name.as_ptr()) }
.to_string_lossy()
.into_owned()
})
.collect::<Vec<_>>();
extensions.sort();
extensions.dedup();
Ok(extensions)
}
pub(super) fn live_surface_formats(
surface: &VulkanSurfaceProbe,
device: vk::PhysicalDevice,
name: &str,
) -> Result<Vec<VulkanSurfaceFormat>, VulkanRuntimeCapabilityError> {
let formats = {
// SAFETY: The physical device and surface are live query inputs and no handles are retained.
unsafe {
surface
.loader
.get_physical_device_surface_formats(device, surface.surface)
}
}
.map_err(|error| VulkanRuntimeCapabilityError::SurfaceFormatsFailed {
device: name.to_string(),
result: error,
})?;
Ok(formats
.into_iter()
.map(|format| VulkanSurfaceFormat {
format: format.format.as_raw(),
color_space: format.color_space.as_raw(),
})
.collect())
}
pub(super) fn live_present_modes(
surface: &VulkanSurfaceProbe,
device: vk::PhysicalDevice,
name: &str,
) -> Result<Vec<i32>, VulkanRuntimeCapabilityError> {
let modes = {
// SAFETY: The physical device and surface are live query inputs and no handles are retained.
unsafe {
surface
.loader
.get_physical_device_surface_present_modes(device, surface.surface)
}
}
.map_err(|error| VulkanRuntimeCapabilityError::PresentModesFailed {
device: name.to_string(),
result: error,
})?;
Ok(modes.into_iter().map(vk::PresentModeKHR::as_raw).collect())
}
pub(super) fn live_surface_capabilities(
surface: &VulkanSurfaceProbe,
device: vk::PhysicalDevice,
name: &str,
) -> Result<VulkanSwapchainSurfaceCapabilities, VulkanRuntimeCapabilityError> {
let capabilities = {
// SAFETY: The physical device and surface are live query inputs and no handles are retained.
unsafe {
surface
.loader
.get_physical_device_surface_capabilities(device, surface.surface)
}
}
.map_err(
|error| VulkanRuntimeCapabilityError::SurfaceCapabilitiesFailed {
device: name.to_string(),
result: error,
},
)?;
Ok(VulkanSwapchainSurfaceCapabilities {
current_extent: if capabilities.current_extent.width == u32::MAX {
None
} else {
Some((
capabilities.current_extent.width,
capabilities.current_extent.height,
))
},
min_extent: (
capabilities.min_image_extent.width,
capabilities.min_image_extent.height,
),
max_extent: (
capabilities.max_image_extent.width,
capabilities.max_image_extent.height,
),
min_image_count: capabilities.min_image_count,
max_image_count: capabilities.max_image_count,
supported_usage_flags: capabilities.supported_usage_flags.as_raw(),
})
}
fn live_depth_stencil_formats(
instance: &VulkanInstanceProbe,
device: vk::PhysicalDevice,
) -> Vec<i32> {
[
vk::Format::D16_UNORM,
vk::Format::X8_D24_UNORM_PACK32,
vk::Format::D32_SFLOAT,
vk::Format::S8_UINT,
vk::Format::D16_UNORM_S8_UINT,
vk::Format::D24_UNORM_S8_UINT,
vk::Format::D32_SFLOAT_S8_UINT,
]
.into_iter()
.filter(|format| {
let properties = {
// SAFETY: `device` belongs to `instance`; format-property queries copy data by value.
unsafe {
instance
.instance
.get_physical_device_format_properties(device, *format)
}
};
properties
.optimal_tiling_features
.contains(vk::FormatFeatureFlags::DEPTH_STENCIL_ATTACHMENT)
})
.map(vk::Format::as_raw)
.collect()
}
fn live_sampled_image_formats(
instance: &VulkanInstanceProbe,
device: vk::PhysicalDevice,
) -> Vec<i32> {
[
vk::Format::R8G8B8A8_SRGB,
vk::Format::B8G8R8A8_SRGB,
vk::Format::D16_UNORM,
vk::Format::D32_SFLOAT,
vk::Format::D24_UNORM_S8_UINT,
vk::Format::D32_SFLOAT_S8_UINT,
]
.into_iter()
.filter(|format| {
let properties = {
// SAFETY: `device` belongs to `instance`; format-property queries copy data by value.
unsafe {
instance
.instance
.get_physical_device_format_properties(device, *format)
}
};
properties
.optimal_tiling_features
.contains(vk::FormatFeatureFlags::SAMPLED_IMAGE)
})
.map(vk::Format::as_raw)
.collect()
}
@@ -0,0 +1,392 @@
#![allow(unsafe_code)]
use ash::vk;
use serde::Serialize;
use std::collections::BTreeSet;
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use super::{
EXT_DEBUG_UTILS_EXTENSION, KHR_PORTABILITY_ENUMERATION_EXTENSION, MIN_VULKAN_API_VERSION,
VALIDATION_LAYER_NAME,
};
use crate::policy::{format_api_version, serialize_json_or_fallback};
/// Vulkan instance bootstrap configuration.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanInstanceConfig {
/// Application name reported to the loader.
pub application_name: String,
/// Required instance extensions, usually including surface extensions.
pub required_extensions: Vec<String>,
/// Whether `VK_KHR_portability_enumeration` and its create flag are enabled.
pub enable_portability_enumeration: bool,
/// Whether validation layers are requested.
pub enable_validation: bool,
}
impl VulkanInstanceConfig {
/// Returns a conservative instance configuration for smoke probes.
#[must_use]
pub fn smoke(application_name: impl Into<String>) -> Self {
Self {
application_name: application_name.into(),
required_extensions: Vec::new(),
// The runtime is Windows-only, so portability enumeration is not part of
// the supported instance contract.
enable_portability_enumeration: false,
enable_validation: false,
}
}
}
/// Deterministic Vulkan instance creation plan.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanInstancePlan {
/// Report schema version.
pub schema: u32,
/// Instance extensions requested at creation time.
pub enabled_extensions: Vec<String>,
/// Raw Vulkan instance creation flags.
pub create_flags: u32,
/// Whether validation was requested.
pub validation_requested: bool,
}
/// Created Vulkan instance probe.
pub struct VulkanInstanceProbe {
pub(super) entry: ash::Entry,
pub(super) instance: ash::Instance,
/// Deterministic instance creation report.
pub report: VulkanInstancePlan,
}
impl Drop for VulkanInstanceProbe {
fn drop(&mut self) {
// SAFETY: The `Instance` was created by this probe and is destroyed once during drop.
unsafe { self.instance.destroy_instance(None) };
}
}
/// Vulkan instance bootstrap error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VulkanInstanceError {
/// The Vulkan loader could not be opened.
Loader(VulkanLoaderError),
/// Application name contained an interior NUL byte.
InvalidApplicationName,
/// An extension name contained an interior NUL byte.
InvalidExtensionName {
/// Invalid extension name.
extension: String,
},
/// A required instance extension is unavailable from the loader.
MissingInstanceExtension {
/// Required extension name.
extension: String,
},
/// Validation layers were requested but unavailable.
MissingValidationLayer,
/// Instance creation failed.
CreateFailed {
/// Vulkan result.
result: vk::Result,
},
}
impl std::fmt::Display for VulkanInstanceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Loader(error) => write!(f, "{error}"),
Self::InvalidApplicationName => {
write!(f, "Vulkan application name contains an interior NUL byte")
}
Self::InvalidExtensionName { extension } => {
write!(
f,
"Vulkan instance extension name contains an interior NUL byte: {extension:?}"
)
}
Self::MissingInstanceExtension { extension } => {
write!(f, "Vulkan instance extension {extension} is unavailable")
}
Self::MissingValidationLayer => {
write!(
f,
"Vulkan validation layer VK_LAYER_KHRONOS_validation is unavailable"
)
}
Self::CreateFailed { result } => {
write!(f, "Vulkan instance creation failed: {result:?}")
}
}
}
}
impl std::error::Error for VulkanInstanceError {}
/// Deterministic Vulkan loader probe report.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanLoaderProbeReport {
/// Report schema version.
pub schema: u32,
/// Whether the Vulkan loader was opened successfully.
pub loader_available: bool,
/// Reported loader instance API version.
pub instance_api_version: u32,
}
/// Vulkan loader bootstrap error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VulkanLoaderError {
/// The Vulkan loader library could not be opened.
Unavailable {
/// Loader error text.
message: String,
},
}
impl std::fmt::Display for VulkanLoaderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unavailable { message } => {
write!(f, "Vulkan loader is unavailable: {message}")
}
}
}
}
impl std::error::Error for VulkanLoaderError {}
/// Builds the deterministic instance creation plan without touching the loader.
#[must_use]
pub fn plan_vulkan_instance(config: &VulkanInstanceConfig) -> VulkanInstancePlan {
let mut enabled_extensions = config.required_extensions.clone();
if config.enable_validation
&& !enabled_extensions
.iter()
.any(|extension| extension == EXT_DEBUG_UTILS_EXTENSION)
{
enabled_extensions.push(EXT_DEBUG_UTILS_EXTENSION.to_string());
}
if config.enable_portability_enumeration
&& !enabled_extensions
.iter()
.any(|extension| extension == KHR_PORTABILITY_ENUMERATION_EXTENSION)
{
enabled_extensions.push(KHR_PORTABILITY_ENUMERATION_EXTENSION.to_string());
}
enabled_extensions.sort();
enabled_extensions.dedup();
VulkanInstancePlan {
schema: 1,
enabled_extensions,
create_flags: if config.enable_portability_enumeration {
vk::InstanceCreateFlags::ENUMERATE_PORTABILITY_KHR.as_raw()
} else {
0
},
validation_requested: config.enable_validation,
}
}
/// Creates a Vulkan instance probe from the supplied configuration.
///
/// # Errors
///
/// Returns [`VulkanInstanceError`] when the loader is unavailable, names are not
/// valid C strings, or `vkCreateInstance` fails.
pub fn create_vulkan_instance_probe(
config: &VulkanInstanceConfig,
) -> Result<VulkanInstanceProbe, VulkanInstanceError> {
// SAFETY: Loading the entry only resolves loader symbols; no raw Vulkan handles escape.
let entry = unsafe { ash::Entry::load() }.map_err(|error| {
VulkanInstanceError::Loader(VulkanLoaderError::Unavailable {
message: error.to_string(),
})
})?;
let app_name = CString::new(config.application_name.clone())
.map_err(|_| VulkanInstanceError::InvalidApplicationName)?;
let engine_name = c"fparkan";
let plan = plan_vulkan_instance(config);
let available_extensions = available_instance_extensions(&entry)?;
ensure_instance_extensions_available(&plan.enabled_extensions, &available_extensions)?;
let extension_names = cstring_vec(&plan.enabled_extensions)?;
let extension_ptrs = cstring_ptrs(&extension_names);
let layer_names = validation_layer_cstrings(&entry, config.enable_validation)?;
let layer_ptrs = cstring_ptrs(&layer_names);
let app_info = vk::ApplicationInfo::default()
.application_name(&app_name)
.application_version(0)
.engine_name(engine_name)
.engine_version(0)
.api_version(MIN_VULKAN_API_VERSION);
let create_info = vk::InstanceCreateInfo::default()
.application_info(&app_info)
.enabled_extension_names(&extension_ptrs)
.enabled_layer_names(&layer_ptrs)
.flags(vk::InstanceCreateFlags::from_raw(plan.create_flags));
// SAFETY: `create_info` points to stack-owned Vulkan create data that lives for the call.
let instance = unsafe { entry.create_instance(&create_info, None) }
.map_err(|error| VulkanInstanceError::CreateFailed { result: error })?;
Ok(VulkanInstanceProbe {
entry,
instance,
report: plan,
})
}
/// Renders a deterministic JSON Vulkan instance plan.
#[must_use]
pub fn render_instance_plan_json(plan: &VulkanInstancePlan) -> String {
#[derive(Serialize)]
struct InstancePlanJson<'a> {
schema: u32,
create_flags: u32,
validation_requested: bool,
enabled_extensions: &'a [String],
}
serialize_json_or_fallback(
&InstancePlanJson {
schema: plan.schema,
create_flags: plan.create_flags,
validation_requested: plan.validation_requested,
enabled_extensions: &plan.enabled_extensions,
},
"{\"schema\":0,\"create_flags\":0,\"validation_requested\":false,\"enabled_extensions\":[]}",
)
}
/// Opens the Vulkan loader and reports the supported instance API version.
///
/// # Errors
///
/// Returns [`VulkanLoaderError`] when no Vulkan loader library can be opened on
/// the host.
pub fn probe_vulkan_loader() -> Result<VulkanLoaderProbeReport, VulkanLoaderError> {
// SAFETY: Loading the entry only resolves loader symbols; no raw Vulkan handles escape.
let entry = unsafe { ash::Entry::load() }.map_err(|error| VulkanLoaderError::Unavailable {
message: error.to_string(),
})?;
// SAFETY: The resolved entry only queries the loader-supported instance API version.
let version = unsafe { entry.try_enumerate_instance_version() }
.map_err(|error| VulkanLoaderError::Unavailable {
message: error.to_string(),
})?
.unwrap_or(vk::API_VERSION_1_0);
Ok(VulkanLoaderProbeReport {
schema: 1,
loader_available: true,
instance_api_version: version,
})
}
/// Returns the static Vulkan entry name used by loader probes.
#[must_use]
pub fn vulkan_entry_symbol_name() -> &'static CStr {
c"vkGetInstanceProcAddr"
}
/// Renders a deterministic JSON Vulkan loader report.
#[must_use]
pub fn render_loader_probe_report_json(report: &VulkanLoaderProbeReport) -> String {
#[derive(Serialize)]
struct LoaderProbeReportJson {
schema: u32,
loader_available: bool,
instance_api: String,
}
serialize_json_or_fallback(
&LoaderProbeReportJson {
schema: report.schema,
loader_available: report.loader_available,
instance_api: format_api_version(report.instance_api_version),
},
"{\"schema\":0,\"loader_available\":false,\"instance_api\":\"0.0.0\"}",
)
}
fn available_instance_extensions(entry: &ash::Entry) -> Result<Vec<String>, VulkanInstanceError> {
let available_extensions =
// SAFETY: Enumerating instance extensions reads loader-owned immutable metadata.
unsafe { entry.enumerate_instance_extension_properties(None) }.map_err(|error| {
VulkanInstanceError::CreateFailed {
result: error,
}
})?;
available_extensions
.into_iter()
.map(|extension| {
// SAFETY: Vulkan extension names are fixed-size NUL-terminated strings from the loader.
Ok(unsafe { CStr::from_ptr(extension.extension_name.as_ptr()) }
.to_string_lossy()
.into_owned())
})
.collect()
}
pub(super) fn ensure_instance_extensions_available(
required_extensions: &[String],
available_extensions: &[String],
) -> Result<(), VulkanInstanceError> {
let available = available_extensions
.iter()
.map(String::as_str)
.collect::<BTreeSet<_>>();
for extension in required_extensions {
if !available.contains(extension.as_str()) {
return Err(VulkanInstanceError::MissingInstanceExtension {
extension: extension.clone(),
});
}
}
Ok(())
}
fn validation_layer_cstrings(
entry: &ash::Entry,
enable_validation: bool,
) -> Result<Vec<CString>, VulkanInstanceError> {
if !enable_validation {
return Ok(Vec::new());
}
let available_layers =
// SAFETY: Enumerating instance layers reads loader-owned immutable metadata.
unsafe { entry.enumerate_instance_layer_properties() }.map_err(|error| {
VulkanInstanceError::CreateFailed {
result: error,
}
})?;
let validation_available = available_layers.iter().any(|layer| {
// SAFETY: Vulkan layer names are fixed-size NUL-terminated strings from the loader.
unsafe { CStr::from_ptr(layer.layer_name.as_ptr()) }
.to_string_lossy()
.as_ref()
== VALIDATION_LAYER_NAME
});
if !validation_available {
return Err(VulkanInstanceError::MissingValidationLayer);
}
Ok(vec![CString::new(VALIDATION_LAYER_NAME).map_err(|_| {
VulkanInstanceError::InvalidApplicationName
})?])
}
pub(super) fn cstring_vec(values: &[String]) -> Result<Vec<CString>, VulkanInstanceError> {
values
.iter()
.map(|extension| {
CString::new(extension.as_str()).map_err(|_| {
VulkanInstanceError::InvalidExtensionName {
extension: extension.clone(),
}
})
})
.collect()
}
fn cstring_ptrs(values: &[CString]) -> Vec<*const c_char> {
values.iter().map(|value| value.as_ptr()).collect()
}
@@ -0,0 +1,751 @@
#![allow(unsafe_code)]
use ash::vk;
use fparkan_platform::DepthStencilSupport;
use super::{
VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanStaticMesh,
VulkanStaticTexture,
};
use crate::select_depth_stencil_attachment_format;
pub(super) struct VulkanAllocatedBuffer {
pub(super) buffer: vk::Buffer,
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) image_available: vk::Semaphore,
pub(super) render_finished: vk::Semaphore,
pub(super) fence: vk::Fence,
}
pub(super) fn create_command_pool(
device: &VulkanLogicalDeviceProbe,
) -> Result<vk::CommandPool, VulkanSmokeRendererError> {
let create_info = vk::CommandPoolCreateInfo::default()
.queue_family_index(device.report.graphics_queue_family)
.flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER);
// SAFETY: The queue-family index belongs to this live logical device.
unsafe { device.device().create_command_pool(&create_info, None) }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateCommandPool",
result: error,
}
})
}
pub(super) fn create_static_mesh_vertex_buffer(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
mesh: &VulkanStaticMesh,
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
let mut bytes = Vec::with_capacity(mesh.vertices.len() * 8 * std::mem::size_of::<f32>());
for vertex in &mesh.vertices {
for value in vertex
.position
.into_iter()
.chain(vertex.color)
.chain(vertex.uv)
{
bytes.extend_from_slice(&value.to_ne_bytes());
}
}
create_host_visible_buffer(
instance,
device,
&bytes,
vk::BufferUsageFlags::VERTEX_BUFFER,
"static mesh vertex buffer",
)
}
pub(super) fn create_static_mesh_index_buffer(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
mesh: &VulkanStaticMesh,
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
let mut bytes = Vec::with_capacity(mesh.indices.len() * std::mem::size_of::<u32>());
for &index in &mesh.indices {
bytes.extend_from_slice(&index.to_ne_bytes());
}
create_host_visible_buffer(
instance,
device,
&bytes,
vk::BufferUsageFlags::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(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
bytes: &[u8],
usage: vk::BufferUsageFlags,
context: &'static str,
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
let create_info = vk::BufferCreateInfo::default()
.size(bytes.len().try_into().unwrap_or(u64::MAX))
.usage(usage)
.sharing_mode(vk::SharingMode::EXCLUSIVE);
// SAFETY: The create info is stack-owned and references no external memory.
let buffer = unsafe { device.device().create_buffer(&create_info, None) }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation {
context,
result: error,
}
})?;
// SAFETY: The buffer belongs to this device and is queried immediately after creation.
let requirements = unsafe { device.device().get_buffer_memory_requirements(buffer) };
let Some(memory_type_index) = find_memory_type(
instance,
device.physical_device(),
requirements.memory_type_bits,
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
) else {
// SAFETY: The buffer was created above on this logical device and is destroyed on setup failure.
unsafe { device.device().destroy_buffer(buffer, None) };
return Err(VulkanSmokeRendererError::MissingMemoryType { context });
};
let allocate_info = vk::MemoryAllocateInfo::default()
.allocation_size(requirements.size)
.memory_type_index(memory_type_index);
let memory =
// SAFETY: The allocation request matches the queried memory requirements for this buffer.
unsafe { device.device().allocate_memory(&allocate_info, None) }.map_err(|error| {
// SAFETY: The buffer was created above on this logical device and is destroyed on setup failure.
unsafe { device.device().destroy_buffer(buffer, None) };
VulkanSmokeRendererError::VulkanOperation {
context,
result: error,
}
})?;
// SAFETY: The allocation satisfies the queried buffer memory requirements for this device.
unsafe { device.device().bind_buffer_memory(buffer, memory, 0) }.map_err(|error| {
// SAFETY: The buffer and allocation were created above on this logical device and are destroyed on setup failure.
unsafe {
device.device().destroy_buffer(buffer, None);
device.device().free_memory(memory, None);
}
VulkanSmokeRendererError::VulkanOperation {
context,
result: error,
}
})?;
// SAFETY: The mapping range is within the host-visible allocation bound to the buffer.
let mapped = unsafe {
device
.device()
.map_memory(memory, 0, requirements.size, vk::MemoryMapFlags::empty())
}
.map_err(|error| {
// SAFETY: The buffer and allocation were created above on this logical device and are destroyed on setup failure.
unsafe {
device.device().destroy_buffer(buffer, None);
device.device().free_memory(memory, None);
}
VulkanSmokeRendererError::VulkanOperation {
context,
result: error,
}
})?;
// SAFETY: The destination points to the mapped allocation and the source slice lives for the copy.
unsafe {
std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::<u8>(), bytes.len());
device.device().unmap_memory(memory);
}
Ok(VulkanAllocatedBuffer { buffer, memory })
}
fn find_memory_type(
instance: &VulkanInstanceProbe,
physical_device: vk::PhysicalDevice,
type_bits: u32,
required: vk::MemoryPropertyFlags,
) -> Option<u32> {
let properties =
// SAFETY: The physical device was selected from this live instance and queried by value.
unsafe {
instance
.instance
.get_physical_device_memory_properties(physical_device)
};
let count = usize::try_from(properties.memory_type_count).unwrap_or(0);
properties.memory_types[..count]
.iter()
.enumerate()
.find_map(|(index, memory_type)| {
let index_u32 = u32::try_from(index).ok()?;
let supported = (type_bits & (1_u32 << index_u32)) != 0;
(supported && memory_type.property_flags.contains(required)).then_some(index_u32)
})
}
pub(super) fn create_frame_sync(
device: &VulkanLogicalDeviceProbe,
) -> Result<Vec<VulkanFrameSync>, VulkanSmokeRendererError> {
let semaphore_info = vk::SemaphoreCreateInfo::default();
let fence_info = vk::FenceCreateInfo::default().flags(vk::FenceCreateFlags::SIGNALED);
let mut sync = Vec::with_capacity(2);
for _ in 0..2 {
// SAFETY: The sync objects belong to this live logical device and are destroyed at teardown.
let image_available = unsafe { device.device().create_semaphore(&semaphore_info, None) }
.map_err(|error| VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateSemaphore(image_available)",
result: error,
})?;
let render_finished = {
// SAFETY: The sync objects belong to this live logical device and are destroyed at teardown.
match unsafe { device.device().create_semaphore(&semaphore_info, None) } {
Ok(render_finished) => render_finished,
Err(error) => {
destroy_frame_sync_objects(device, &sync);
// SAFETY: The semaphore was created above on this logical device and is destroyed on setup failure.
unsafe { device.device().destroy_semaphore(image_available, None) };
return Err(VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateSemaphore(render_finished)",
result: error,
});
}
}
};
// SAFETY: The fence belongs to this live logical device and is destroyed at teardown.
let fence = match unsafe { device.device().create_fence(&fence_info, None) } {
Ok(fence) => fence,
Err(error) => {
destroy_frame_sync_objects(device, &sync);
// SAFETY: These semaphores were created above on this logical device and are destroyed on setup failure.
unsafe {
device.device().destroy_semaphore(image_available, None);
device.device().destroy_semaphore(render_finished, None);
}
return Err(VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateFence",
result: error,
});
}
};
sync.push(VulkanFrameSync {
image_available,
render_finished,
fence,
});
}
Ok(sync)
}
fn destroy_frame_sync_objects(device: &VulkanLogicalDeviceProbe, sync: &[VulkanFrameSync]) {
for frame_sync in sync {
// SAFETY: These sync objects belong to this live logical device and are destroyed once during teardown.
unsafe {
device
.device()
.destroy_semaphore(frame_sync.image_available, None);
device
.device()
.destroy_semaphore(frame_sync.render_finished, None);
device.device().destroy_fence(frame_sync.fence, None);
}
}
}
pub(super) fn destroy_allocated_buffer(
device: &VulkanLogicalDeviceProbe,
buffer: &VulkanAllocatedBuffer,
) {
// SAFETY: The buffer and allocation belong to this live logical device and are destroyed once during teardown.
unsafe {
device.device().destroy_buffer(buffer.buffer, None);
device.device().free_memory(buffer.memory, None);
}
}
/// 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 {
vk::ImageSubresourceRange::default()
.aspect_mask(vk::ImageAspectFlags::COLOR)
.base_mip_level(0)
.level_count(1)
.base_array_layer(0)
.layer_count(1)
}
@@ -0,0 +1,212 @@
#![allow(unsafe_code)]
use ash::vk;
use fparkan_platform::RenderRequest;
use std::ffi::CString;
use super::capabilities::{
select_live_device_candidate_for_request, unique_queue_families, VulkanRuntimeCapabilityError,
VulkanRuntimeCapabilityProbe,
};
use super::{VulkanInstanceProbe, VulkanSurfaceProbe};
/// Created Vulkan logical device probe.
pub struct VulkanLogicalDeviceProbe {
device: ash::Device,
physical_device: vk::PhysicalDevice,
/// Runtime capability report used for device selection.
pub runtime: VulkanRuntimeCapabilityProbe,
/// Deterministic logical device creation report.
pub report: VulkanLogicalDeviceReport,
}
impl Drop for VulkanLogicalDeviceProbe {
fn drop(&mut self) {
// SAFETY: The logical device was created by this probe and is destroyed once during drop.
unsafe { self.device.destroy_device(None) };
}
}
impl VulkanLogicalDeviceProbe {
/// Returns the graphics queue selected by the Stage 0 policy.
#[must_use]
pub fn graphics_queue(&self) -> vk::Queue {
// SAFETY: The queue-family index belongs to this live logical device.
unsafe {
self.device
.get_device_queue(self.report.graphics_queue_family, 0)
}
}
/// Returns the presentation queue selected by the Stage 0 policy.
#[must_use]
pub fn present_queue(&self) -> vk::Queue {
// SAFETY: The queue-family index belongs to this live logical device.
unsafe {
self.device
.get_device_queue(self.report.present_queue_family, 0)
}
}
/// Returns a shared reference to the live logical device.
#[must_use]
pub fn device(&self) -> &ash::Device {
&self.device
}
/// Returns the selected physical device handle.
#[must_use]
pub fn physical_device(&self) -> vk::PhysicalDevice {
self.physical_device
}
}
/// Logical device creation report.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanLogicalDeviceReport {
/// Report schema version.
pub schema: u32,
/// Selected physical device name.
pub device_name: String,
/// Graphics queue-family index used by the logical device.
pub graphics_queue_family: u32,
/// Present queue-family index used by the logical device.
pub present_queue_family: u32,
/// Enabled device extensions.
pub enabled_extensions: Vec<String>,
}
/// Vulkan logical device creation error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VulkanLogicalDeviceError {
/// Runtime capability probing failed.
Runtime(VulkanRuntimeCapabilityError),
/// Device extension name contained an interior NUL byte.
InvalidExtensionName {
/// Invalid extension name.
extension: String,
},
/// Logical device creation failed.
CreateFailed {
/// Selected device name.
device: String,
/// Vulkan result.
result: vk::Result,
},
}
impl std::fmt::Display for VulkanLogicalDeviceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Runtime(error) => write!(f, "{error}"),
Self::InvalidExtensionName { extension } => write!(
f,
"Vulkan device extension name contains an interior NUL byte: {extension:?}"
),
Self::CreateFailed { device, result } => {
write!(
f,
"Vulkan logical device creation failed for {device}: {result:?}"
)
}
}
}
}
impl std::error::Error for VulkanLogicalDeviceError {}
/// Creates a Vulkan logical device for the selected live surface-capable device.
///
/// # Errors
///
/// Returns [`VulkanLogicalDeviceError`] when runtime capability probing fails,
/// device extension names are invalid, or `vkCreateDevice` fails.
pub fn create_vulkan_logical_device_probe(
instance: &VulkanInstanceProbe,
surface: &VulkanSurfaceProbe,
drawable_extent: (u32, u32),
) -> Result<VulkanLogicalDeviceProbe, VulkanLogicalDeviceError> {
create_vulkan_logical_device_probe_for_request(
instance,
surface,
drawable_extent,
RenderRequest::conservative(),
)
}
/// Creates a Vulkan logical device for a specific Stage 0 render request.
///
/// # Errors
///
/// Returns [`VulkanLogicalDeviceError`] when runtime capability probing fails,
/// device extension names are invalid, or `vkCreateDevice` fails.
pub fn create_vulkan_logical_device_probe_for_request(
instance: &VulkanInstanceProbe,
surface: &VulkanSurfaceProbe,
drawable_extent: (u32, u32),
render_request: RenderRequest,
) -> Result<VulkanLogicalDeviceProbe, VulkanLogicalDeviceError> {
let selected = select_live_device_candidate_for_request(
instance,
surface,
drawable_extent,
render_request,
)
.map_err(VulkanLogicalDeviceError::Runtime)?;
let capability = &selected.runtime.capability;
let queue_priorities = [1.0_f32];
let queue_families = unique_queue_families(
capability.graphics_queue_family,
capability.present_queue_family,
);
let queue_infos = queue_families
.iter()
.map(|queue_family| {
vk::DeviceQueueCreateInfo::default()
.queue_family_index(*queue_family)
.queue_priorities(&queue_priorities)
})
.collect::<Vec<_>>();
let extension_names = device_extension_cstrings(&capability.enabled_extensions)
.map_err(|extension| VulkanLogicalDeviceError::InvalidExtensionName { extension })?;
let extension_ptrs = extension_names
.iter()
.map(|extension| extension.as_ptr())
.collect::<Vec<_>>();
let create_info = vk::DeviceCreateInfo::default()
.queue_create_infos(&queue_infos)
.enabled_extension_names(&extension_ptrs);
// SAFETY: `selected.physical_device` belongs to `instance`; create data lives for the call.
let device = unsafe {
instance
.instance
.create_device(selected.physical_device, &create_info, None)
}
.map_err(|error| VulkanLogicalDeviceError::CreateFailed {
device: capability.device_name.clone(),
result: error,
})?;
// SAFETY: Queue family indices came from validated live queue families requested above.
let _graphics_queue = unsafe { device.get_device_queue(capability.graphics_queue_family, 0) };
// SAFETY: Queue family indices came from validated live queue families requested above.
let _present_queue = unsafe { device.get_device_queue(capability.present_queue_family, 0) };
Ok(VulkanLogicalDeviceProbe {
device,
physical_device: selected.physical_device,
report: VulkanLogicalDeviceReport {
schema: 1,
device_name: capability.device_name.clone(),
graphics_queue_family: capability.graphics_queue_family,
present_queue_family: capability.present_queue_family,
enabled_extensions: capability.enabled_extensions.clone(),
},
runtime: selected.runtime,
})
}
fn device_extension_cstrings(values: &[String]) -> Result<Vec<CString>, String> {
values
.iter()
.map(|extension| CString::new(extension.as_str()).map_err(|_| extension.clone()))
.collect()
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,747 @@
use ash::vk;
use fparkan_platform::{NativeWindowHandles, RenderRequest};
use fparkan_render::{LegacyD3d7Projection, LegacyPipelineState, PipelineKey, RawCameraTransform};
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::Arc;
use super::{
VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanFrameSync, VulkanInstanceError,
VulkanInstanceProbe, VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanSurfaceError,
VulkanSurfaceProbe, VulkanSwapchainProbe, VulkanSwapchainProbeError, VulkanSwapchainResources,
VulkanValidationMessenger,
};
use crate::shader_manifest::VulkanShaderManifestError;
/// Creates a live native Vulkan renderer for the Stage 0 smoke loop.
#[derive(Clone, Debug)]
pub struct VulkanSmokeRendererCreateInfo {
/// Application name reported to the Vulkan loader.
pub application_name: String,
/// Native window/display handles borrowed from a live window.
pub native_handles: NativeWindowHandles,
/// Initial drawable extent.
pub drawable_extent: (u32, u32),
/// Stage 0 render request used for capability gating.
pub render_request: RenderRequest,
/// Whether validation layers must be enabled.
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.
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.
#[derive(Debug, Default)]
pub struct VulkanSmokeBootstrapProgress {
flags: AtomicU8,
}
impl VulkanSmokeBootstrapProgress {
/// Marks the Vulkan loader as available.
pub fn mark_loader_available(&self) {
self.set_flag(BOOTSTRAP_LOADER_AVAILABLE);
}
/// Marks the Vulkan instance as created.
pub fn mark_instance_created(&self) {
self.set_flag(BOOTSTRAP_INSTANCE_CREATED);
}
/// Marks the Vulkan surface as created.
pub fn mark_surface_created(&self) {
self.set_flag(BOOTSTRAP_SURFACE_CREATED);
}
/// Marks a suitable Vulkan device as selected and the logical device as created.
pub fn mark_logical_device_created(&self) {
self.set_flag(BOOTSTRAP_DEVICE_SELECTED | BOOTSTRAP_LOGICAL_DEVICE_CREATED);
}
/// Marks the Vulkan swapchain as created.
pub fn mark_swapchain_created(&self) {
self.set_flag(BOOTSTRAP_SWAPCHAIN_CREATED);
}
/// Returns a stable snapshot of the measured bootstrap state.
#[must_use]
pub fn snapshot(&self) -> VulkanSmokeBootstrapSnapshot {
let flags = self.flags.load(Ordering::SeqCst);
VulkanSmokeBootstrapSnapshot {
loader_available: flags & BOOTSTRAP_LOADER_AVAILABLE != 0,
instance_created: flags & BOOTSTRAP_INSTANCE_CREATED != 0,
surface_created: flags & BOOTSTRAP_SURFACE_CREATED != 0,
device_selected: flags & BOOTSTRAP_DEVICE_SELECTED != 0,
logical_device_created: flags & BOOTSTRAP_LOGICAL_DEVICE_CREATED != 0,
swapchain_created: flags & BOOTSTRAP_SWAPCHAIN_CREATED != 0,
}
}
fn set_flag(&self, flag: u8) {
self.flags.fetch_or(flag, Ordering::SeqCst);
}
}
/// Stable snapshot of measured bootstrap progress.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[allow(clippy::struct_excessive_bools)]
pub struct VulkanSmokeBootstrapSnapshot {
/// Whether the Vulkan loader was resolved.
pub loader_available: bool,
/// Whether the Vulkan instance was created.
pub instance_created: bool,
/// Whether the Vulkan surface was created.
pub surface_created: bool,
/// Whether a suitable Vulkan device was selected.
pub device_selected: bool,
/// Whether the logical device was created.
pub logical_device_created: bool,
/// Whether the swapchain was created.
pub swapchain_created: bool,
}
const BOOTSTRAP_LOADER_AVAILABLE: u8 = 1 << 0;
const BOOTSTRAP_INSTANCE_CREATED: u8 = 1 << 1;
const BOOTSTRAP_SURFACE_CREATED: u8 = 1 << 2;
const BOOTSTRAP_DEVICE_SELECTED: u8 = 1 << 3;
const BOOTSTRAP_LOGICAL_DEVICE_CREATED: u8 = 1 << 4;
const BOOTSTRAP_SWAPCHAIN_CREATED: u8 = 1 << 5;
/// Stable smoke renderer bootstrap report.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanSmokeRendererReport {
/// Checked-in shader manifest hash used by the renderer.
pub shader_manifest_hash: String,
/// Whether portability enumeration was enabled at instance creation.
pub portability_enumeration: bool,
/// Whether the logical device enabled `VK_KHR_portability_subset`.
pub portability_subset_enabled: bool,
/// Selected device name.
pub device_name: String,
/// Graphics queue-family index.
pub graphics_queue_family: u32,
/// Present queue-family index.
pub present_queue_family: u32,
/// Enabled logical-device extension count.
pub enabled_extension_count: u32,
/// Current swapchain extent.
pub swapchain_extent: (u32, u32),
/// Current swapchain image count.
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.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct VulkanValidationReport {
/// Validation warnings observed by the debug messenger.
pub warning_count: u32,
/// Validation errors observed by the debug messenger.
pub error_count: u32,
/// Stable sorted VUID list.
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.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanSmokeShutdownReport {
/// Stable renderer bootstrap and swapchain report.
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.
pub swapchain_recreate_count: u32,
/// Final validation snapshot captured before the debug messenger is destroyed.
pub validation: VulkanValidationReport,
}
/// Result of one rendered smoke frame.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum VulkanSmokeFrameOutcome {
/// A frame was submitted and presented.
Presented,
/// Rendering was skipped because the swapchain had to be recreated.
Recreated,
/// Rendering was skipped because the drawable extent is zero.
ZeroExtent,
}
/// Live smoke renderer error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VulkanSmokeRendererError {
/// Instance bootstrap failed.
Instance(VulkanInstanceError),
/// Surface bootstrap failed.
Surface(VulkanSurfaceError),
/// Logical-device bootstrap failed.
LogicalDevice(VulkanLogicalDeviceError),
/// Swapchain bootstrap failed.
Swapchain(VulkanSwapchainProbeError),
/// Shader manifest validation failed.
ShaderManifest(VulkanShaderManifestError),
/// Vulkan operation failed.
VulkanOperation {
/// Operation context.
context: &'static str,
/// Raw Vulkan result code.
result: vk::Result,
},
/// No suitable memory type exists for the required properties.
MissingMemoryType {
/// Operation context.
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.
InvariantViolation {
/// Missing state context.
context: &'static str,
},
}
impl std::fmt::Display for VulkanSmokeRendererError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Instance(error) => write!(f, "{error}"),
Self::Surface(error) => write!(f, "{error}"),
Self::LogicalDevice(error) => write!(f, "{error}"),
Self::Swapchain(error) => write!(f, "{error}"),
Self::ShaderManifest(error) => write!(f, "{error}"),
Self::VulkanOperation { context, result } => {
write!(f, "{context}: {result:?}")
}
Self::MissingMemoryType { context } => {
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 } => {
write!(f, "renderer invariant violated: {context}")
}
}
}
}
impl std::error::Error for VulkanSmokeRendererError {}
/// Live Stage 0 Vulkan triangle renderer used by the smoke app.
pub struct VulkanSmokeRenderer {
pub(super) instance: Option<VulkanInstanceProbe>,
pub(super) validation: Option<VulkanValidationMessenger>,
pub(super) surface: Option<VulkanSurfaceProbe>,
pub(super) device: Option<VulkanLogicalDeviceProbe>,
pub(super) swapchain: Option<VulkanSwapchainProbe>,
pub(super) command_pool: vk::CommandPool,
pub(super) swapchain_resources: Option<VulkanSwapchainResources>,
pub(super) vertex_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) images_in_flight: Vec<vk::Fence>,
pub(super) current_frame: usize,
/// Last swapchain image whose color attachment was copied after a
/// successful graphics submission. Reset whenever swapchain resources are
/// replaced so a teardown never reads a buffer from an old swapchain.
pub(super) last_readback_image_index: Option<usize>,
pub(super) depth_request: fparkan_platform::DepthStencilSupport,
pub(super) pending_extent: Option<(u32, u32)>,
pub(super) swapchain_recreate_count: u32,
pub(super) report: VulkanSmokeRendererReport,
}
@@ -0,0 +1,159 @@
#![allow(unsafe_code)]
use ash::{khr::surface, vk};
use fparkan_platform::NativeWindowHandles;
use serde::Serialize;
use std::ffi::CStr;
use std::os::raw::c_char;
use super::VulkanInstanceProbe;
use crate::policy::serialize_json_or_fallback;
/// Deterministic Vulkan surface creation plan.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanSurfacePlan {
/// Report schema version.
pub schema: u32,
/// Instance extensions required by the native display backend.
pub required_instance_extensions: Vec<String>,
}
/// Vulkan surface bootstrap error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VulkanSurfaceError {
/// No native raw window/display handles were available.
MissingNativeHandles,
/// Required platform surface extensions could not be enumerated.
RequiredExtensionsFailed {
/// Vulkan result.
result: vk::Result,
},
/// A required extension pointer was not valid UTF-8.
InvalidExtensionName,
/// Surface creation failed.
CreateFailed {
/// Vulkan result.
result: vk::Result,
},
}
impl std::fmt::Display for VulkanSurfaceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingNativeHandles => {
write!(
f,
"native window/display handles are required for Vulkan surface creation"
)
}
Self::RequiredExtensionsFailed { result } => write!(
f,
"failed to enumerate required Vulkan surface extensions: {result:?}"
),
Self::InvalidExtensionName => {
write!(f, "Vulkan surface extension name is not valid UTF-8")
}
Self::CreateFailed { result } => {
write!(f, "Vulkan surface creation failed: {result:?}")
}
}
}
}
impl std::error::Error for VulkanSurfaceError {}
/// Created Vulkan surface probe.
pub struct VulkanSurfaceProbe {
pub(super) loader: surface::Instance,
pub(super) surface: vk::SurfaceKHR,
/// Deterministic surface creation report.
pub report: VulkanSurfacePlan,
}
impl Drop for VulkanSurfaceProbe {
fn drop(&mut self) {
// SAFETY: The `SurfaceKHR` was created by this probe and is destroyed once during drop.
unsafe { self.loader.destroy_surface(self.surface, None) };
}
}
/// Builds a deterministic Vulkan surface plan from native window handles.
///
/// # Errors
///
/// Returns [`VulkanSurfaceError`] when no native handles exist or the platform
/// display backend has no Vulkan surface extension mapping.
pub fn plan_vulkan_surface(
handles: Option<NativeWindowHandles>,
) -> Result<VulkanSurfacePlan, VulkanSurfaceError> {
let handles = handles.ok_or(VulkanSurfaceError::MissingNativeHandles)?;
let required = ash_window::enumerate_required_extensions(handles.display)
.map_err(|error| VulkanSurfaceError::RequiredExtensionsFailed { result: error })?;
let mut required_instance_extensions = Vec::with_capacity(required.len());
for extension in required {
let name = extension_name(*extension)?;
required_instance_extensions.push(name);
}
required_instance_extensions.sort();
required_instance_extensions.dedup();
Ok(VulkanSurfacePlan {
schema: 1,
required_instance_extensions,
})
}
/// Creates a Vulkan surface probe from native window handles.
///
/// # Errors
///
/// Returns [`VulkanSurfaceError`] when handles are missing, required extensions
/// cannot be planned, or `vkCreate*SurfaceKHR` fails.
pub fn create_vulkan_surface_probe(
instance: &VulkanInstanceProbe,
handles: Option<NativeWindowHandles>,
) -> Result<VulkanSurfaceProbe, VulkanSurfaceError> {
let handles = handles.ok_or(VulkanSurfaceError::MissingNativeHandles)?;
let report = plan_vulkan_surface(Some(handles))?;
// SAFETY: The platform handles are only used to create a child surface owned by this probe.
let surface = unsafe {
ash_window::create_surface(
&instance.entry,
&instance.instance,
handles.display,
handles.window,
None,
)
}
.map_err(|error| VulkanSurfaceError::CreateFailed { result: error })?;
Ok(VulkanSurfaceProbe {
loader: surface::Instance::new(&instance.entry, &instance.instance),
surface,
report,
})
}
/// Renders a deterministic JSON Vulkan surface plan.
#[must_use]
pub fn render_surface_plan_json(plan: &VulkanSurfacePlan) -> String {
#[derive(Serialize)]
struct SurfacePlanJson<'a> {
schema: u32,
required_instance_extensions: &'a [String],
}
serialize_json_or_fallback(
&SurfacePlanJson {
schema: plan.schema,
required_instance_extensions: &plan.required_instance_extensions,
},
"{\"schema\":0,\"required_instance_extensions\":[]}",
)
}
pub(super) fn extension_name(extension: *const c_char) -> Result<String, VulkanSurfaceError> {
// SAFETY: `ash-window` returns extension pointers to static NUL-terminated Vulkan names.
let name = unsafe { CStr::from_ptr(extension) };
name.to_str()
.map(str::to_string)
.map_err(|_| VulkanSurfaceError::InvalidExtensionName)
}
@@ -0,0 +1,220 @@
#![allow(unsafe_code)]
use ash::{khr::swapchain, vk};
use super::{
capabilities::{
live_present_modes, live_surface_capabilities, live_surface_formats, unique_queue_families,
},
VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanRuntimeCapabilityError,
VulkanSurfaceProbe,
};
use crate::policy::{
plan_vulkan_swapchain, select_composite_alpha, VulkanSwapchainError, VulkanSwapchainPlan,
VulkanSwapchainRequest,
};
/// Created Vulkan swapchain probe.
pub struct VulkanSwapchainProbe {
loader: swapchain::Device,
swapchain: vk::SwapchainKHR,
/// Deterministic swapchain creation report.
pub report: VulkanSwapchainReport,
}
impl Drop for VulkanSwapchainProbe {
fn drop(&mut self) {
// SAFETY: The swapchain was created by this probe and is destroyed once during drop.
unsafe { self.loader.destroy_swapchain(self.swapchain, None) };
}
}
impl VulkanSwapchainProbe {
/// Returns the live swapchain handle.
#[must_use]
pub fn swapchain(&self) -> vk::SwapchainKHR {
self.swapchain
}
/// Returns the swapchain extension loader for this live swapchain.
#[must_use]
pub fn loader(&self) -> &swapchain::Device {
&self.loader
}
}
/// Runtime swapchain creation report.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanSwapchainReport {
/// Report schema version.
pub schema: u32,
/// Deterministic swapchain policy used for creation.
pub plan: VulkanSwapchainPlan,
/// Number of images returned by `vkGetSwapchainImagesKHR`.
pub image_count: u32,
}
/// Vulkan swapchain creation error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VulkanSwapchainProbeError {
/// Live runtime capability probing failed before swapchain creation.
Runtime(VulkanRuntimeCapabilityError),
/// Deterministic swapchain planning failed before create.
Plan(VulkanSwapchainError),
/// Surface capability query failed.
SurfaceCapabilitiesFailed {
/// Vulkan result.
result: vk::Result,
},
/// Swapchain creation failed.
CreateFailed {
/// Vulkan result.
result: vk::Result,
},
/// Swapchain image query failed.
ImagesFailed {
/// Vulkan result.
result: vk::Result,
},
}
impl std::fmt::Display for VulkanSwapchainProbeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Runtime(error) => write!(f, "{error}"),
Self::Plan(error) => write!(f, "{error}"),
Self::SurfaceCapabilitiesFailed { result } => {
write!(f, "Vulkan surface capabilities query failed: {result:?}")
}
Self::CreateFailed { result } => {
write!(f, "Vulkan swapchain creation failed: {result:?}")
}
Self::ImagesFailed { result } => {
write!(f, "Vulkan swapchain image query failed: {result:?}")
}
}
}
}
impl std::error::Error for VulkanSwapchainProbeError {}
/// Creates a Vulkan swapchain for the live logical device and surface.
///
/// # Errors
///
/// Returns [`VulkanSwapchainProbeError`] when live surface capability queries,
/// swapchain creation, or swapchain image enumeration fails.
pub fn create_vulkan_swapchain_probe(
instance: &VulkanInstanceProbe,
surface: &VulkanSurfaceProbe,
device: &VulkanLogicalDeviceProbe,
) -> Result<VulkanSwapchainProbe, VulkanSwapchainProbeError> {
create_vulkan_swapchain_probe_for_extent(
instance,
surface,
device,
device.runtime.swapchain.extent,
vk::SwapchainKHR::null(),
)
}
/// Creates a Vulkan swapchain for the live logical device and surface at a specific extent.
///
/// # Errors
///
/// Returns [`VulkanSwapchainProbeError`] when live surface capability queries,
/// swapchain creation, or swapchain image enumeration fails.
pub fn create_vulkan_swapchain_probe_for_extent(
instance: &VulkanInstanceProbe,
surface: &VulkanSurfaceProbe,
device: &VulkanLogicalDeviceProbe,
drawable_extent: (u32, u32),
old_swapchain: vk::SwapchainKHR,
) -> Result<VulkanSwapchainProbe, VulkanSwapchainProbeError> {
let raw_capabilities = {
// SAFETY: The physical device and surface are live query inputs and no handles are retained.
unsafe {
surface
.loader
.get_physical_device_surface_capabilities(device.physical_device(), surface.surface)
}
}
.map_err(|error| VulkanSwapchainProbeError::SurfaceCapabilitiesFailed { result: error })?;
let surface_formats = live_surface_formats(
surface,
device.physical_device(),
&device.report.device_name,
)
.map_err(VulkanSwapchainProbeError::Runtime)?;
let present_modes = live_present_modes(
surface,
device.physical_device(),
&device.report.device_name,
)
.map_err(VulkanSwapchainProbeError::Runtime)?;
let capabilities = live_surface_capabilities(
surface,
device.physical_device(),
&device.report.device_name,
)
.map_err(VulkanSwapchainProbeError::Runtime)?;
let plan = plan_vulkan_swapchain(&VulkanSwapchainRequest {
drawable_extent,
formats: surface_formats,
present_modes,
capabilities,
preferred_present_mode: vk::PresentModeKHR::MAILBOX.as_raw(),
})
.map_err(VulkanSwapchainProbeError::Plan)?;
let queue_family_indices = unique_queue_families(
device.runtime.capability.graphics_queue_family,
device.runtime.capability.present_queue_family,
);
let sharing_mode = if queue_family_indices.len() > 1 {
vk::SharingMode::CONCURRENT
} else {
vk::SharingMode::EXCLUSIVE
};
let create_info = vk::SwapchainCreateInfoKHR::default()
.surface(surface.surface)
.min_image_count(plan.image_count)
.image_format(vk::Format::from_raw(plan.format.format))
.image_color_space(vk::ColorSpaceKHR::from_raw(plan.format.color_space))
.image_extent(vk::Extent2D {
width: plan.extent.0,
height: plan.extent.1,
})
.image_array_layers(1)
.image_usage(vk::ImageUsageFlags::from_raw(plan.image_usage))
.image_sharing_mode(sharing_mode)
.queue_family_indices(&queue_family_indices)
.pre_transform(raw_capabilities.current_transform)
.composite_alpha(select_composite_alpha(
raw_capabilities.supported_composite_alpha,
))
.present_mode(vk::PresentModeKHR::from_raw(plan.present_mode))
.old_swapchain(old_swapchain)
.clipped(true);
let loader = swapchain::Device::new(&instance.instance, device.device());
// SAFETY: The create info references live instance/device/surface handles for this call.
let swapchain = unsafe { loader.create_swapchain(&create_info, None) }
.map_err(|error| VulkanSwapchainProbeError::CreateFailed { result: error })?;
// SAFETY: The swapchain was created above and the returned image handles are owned by it.
let images = match unsafe { loader.get_swapchain_images(swapchain) } {
Ok(images) => images,
Err(error) => {
// SAFETY: The swapchain was created above on this loader/device pair and is destroyed on setup failure.
unsafe { loader.destroy_swapchain(swapchain, None) };
return Err(VulkanSwapchainProbeError::ImagesFailed { result: error });
}
};
Ok(VulkanSwapchainProbe {
loader,
swapchain,
report: VulkanSwapchainReport {
schema: 1,
plan,
image_count: images.len().try_into().unwrap_or(u32::MAX),
},
})
}
@@ -0,0 +1,891 @@
#![allow(unsafe_code)]
use ash::vk;
use fparkan_platform::DepthStencilSupport;
use fparkan_render::{
LegacyBlendMode, LegacyCullMode, LegacyDepthMode, LegacyPipelineState, PipelineKey,
};
use std::collections::BTreeMap;
use super::{
color_subresource_range, create_depth_attachment, destroy_depth_attachment,
VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanDepthAttachment, VulkanInstanceProbe,
VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanSwapchainProbe,
TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS,
};
pub(super) struct VulkanSwapchainResources {
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) pipeline_layout: vk::PipelineLayout,
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) command_buffers: Vec<vk::CommandBuffer>,
}
struct PartialSwapchainResources {
image_views: Vec<vk::ImageView>,
readback_buffers: Vec<VulkanAllocatedBuffer>,
depth_attachment: Option<VulkanDepthAttachment>,
render_pass: Option<vk::RenderPass>,
pipeline_layout: Option<vk::PipelineLayout>,
descriptor_set_layout: Option<vk::DescriptorSetLayout>,
descriptor_pool: Option<vk::DescriptorPool>,
sampler: Option<vk::Sampler>,
pipelines: BTreeMap<PipelineKey, vk::Pipeline>,
framebuffers: Vec<vk::Framebuffer>,
command_buffers: Vec<vk::CommandBuffer>,
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
pub(super) fn create_swapchain_resources(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
swapchain: &VulkanSwapchainProbe,
command_pool: vk::CommandPool,
vertex_buffer: &VulkanAllocatedBuffer,
index_buffer: &VulkanAllocatedBuffer,
textures: &[VulkanAllocatedImage],
draw_ranges: &[super::VulkanStaticDrawRange],
depth_request: DepthStencilSupport,
reuse_command_pool: bool,
) -> Result<VulkanSwapchainResources, VulkanSmokeRendererError> {
// SAFETY: The swapchain is live and owned by this renderer for the duration of the query.
let images = unsafe {
swapchain
.loader()
.get_swapchain_images(swapchain.swapchain())
}
.map_err(|error| VulkanSmokeRendererError::VulkanOperation {
context: "vkGetSwapchainImagesKHR",
result: error,
})?;
let mut partial = PartialSwapchainResources {
image_views: create_swapchain_image_views(
device,
&images,
swapchain.report.plan.format.format,
)?,
readback_buffers: Vec::new(),
depth_attachment: None,
render_pass: None,
pipeline_layout: None,
descriptor_set_layout: None,
descriptor_pool: None,
sampler: None,
pipelines: BTreeMap::new(),
framebuffers: Vec::new(),
command_buffers: Vec::new(),
};
let (
depth_attachment,
render_pass,
pipeline_layout,
pipelines,
descriptor_set_layout,
descriptor_pool,
descriptor_sets,
sampler,
) = match create_swapchain_pipeline_bundle(
instance,
device,
swapchain.report.plan.format.format,
swapchain.report.plan.extent,
textures,
draw_ranges,
depth_request,
) {
Ok(bundle) => bundle,
Err(error) => {
destroy_partial_swapchain_resources(device, command_pool, partial);
return Err(error);
}
};
let depth_view = depth_attachment.image.view;
partial.depth_attachment = Some(depth_attachment);
partial.render_pass = Some(render_pass);
partial.pipeline_layout = Some(pipeline_layout);
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(
device,
render_pass,
&partial.image_views,
depth_view,
swapchain.report.plan.extent,
) {
Ok(framebuffers) => framebuffers,
Err(error) => {
destroy_partial_swapchain_resources(device, command_pool, partial);
return Err(error);
}
};
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)?;
let command_buffers = match allocate_command_buffers(
device,
command_pool,
u32::try_from(images.len()).unwrap_or(u32::MAX),
) {
Ok(command_buffers) => command_buffers,
Err(error) => {
destroy_partial_swapchain_resources(device, command_pool, partial);
return Err(error);
}
};
partial.command_buffers = command_buffers;
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 {
image_views: partial.image_views,
images,
readback_buffers: partial.readback_buffers,
depth_attachment,
render_pass,
pipeline_layout,
descriptor_set_layout,
descriptor_pool,
descriptor_sets,
sampler,
pipelines: partial.pipelines,
framebuffers: partial.framebuffers,
command_buffers: partial.command_buffers,
})
}
fn create_swapchain_image_views(
device: &VulkanLogicalDeviceProbe,
images: &[vk::Image],
format: i32,
) -> Result<Vec<vk::ImageView>, VulkanSmokeRendererError> {
let mut image_views = Vec::with_capacity(images.len());
for image in images.iter().copied() {
image_views.push(create_image_view(device, image, format)?);
}
Ok(image_views)
}
#[allow(clippy::type_complexity)]
fn create_swapchain_pipeline_bundle(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
format: i32,
extent: (u32, u32),
textures: &[VulkanAllocatedImage],
draw_ranges: &[super::VulkanStaticDrawRange],
depth_request: DepthStencilSupport,
) -> Result<
(
VulkanDepthAttachment,
vk::RenderPass,
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.
unsafe {
device
.device()
.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);
}
destroy_depth_attachment(device, &depth_attachment);
})?;
Ok((
depth_attachment,
render_pass,
pipeline_layout,
pipelines,
descriptor_set_layout,
descriptor_pool,
descriptor_sets,
sampler,
))
}
fn create_swapchain_framebuffers(
device: &VulkanLogicalDeviceProbe,
render_pass: vk::RenderPass,
image_views: &[vk::ImageView],
depth_view: vk::ImageView,
extent: (u32, u32),
) -> Result<Vec<vk::Framebuffer>, VulkanSmokeRendererError> {
let mut framebuffers = Vec::with_capacity(image_views.len());
for image_view in image_views.iter().copied() {
match create_framebuffer(device, render_pass, image_view, depth_view, extent) {
Ok(framebuffer) => framebuffers.push(framebuffer),
Err(error) => {
// SAFETY: These framebuffers were created above on this live logical device and are destroyed on setup failure.
unsafe {
for framebuffer in framebuffers.iter().copied() {
device.device().destroy_framebuffer(framebuffer, None);
}
}
return Err(error);
}
}
}
Ok(framebuffers)
}
fn reset_reusable_command_pool(
device: &VulkanLogicalDeviceProbe,
command_pool: vk::CommandPool,
reuse_command_pool: bool,
) -> Result<(), VulkanSmokeRendererError> {
if !reuse_command_pool {
return Ok(());
}
// SAFETY: All command buffers allocated from the live pool are freed before reallocating them.
unsafe {
device
.device()
.reset_command_pool(command_pool, vk::CommandPoolResetFlags::empty())
}
.map_err(|error| VulkanSmokeRendererError::VulkanOperation {
context: "vkResetCommandPool",
result: error,
})
}
fn create_image_view(
device: &VulkanLogicalDeviceProbe,
image: vk::Image,
format: i32,
) -> Result<vk::ImageView, VulkanSmokeRendererError> {
let create_info = vk::ImageViewCreateInfo::default()
.image(image)
.view_type(vk::ImageViewType::TYPE_2D)
.format(vk::Format::from_raw(format))
.subresource_range(color_subresource_range());
// SAFETY: The image comes from the live swapchain and the subresource range covers its color aspect.
unsafe { device.device().create_image_view(&create_info, None) }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateImageView",
result: error,
}
})
}
fn create_render_pass(
device: &VulkanLogicalDeviceProbe,
format: i32,
depth_format: vk::Format,
) -> Result<vk::RenderPass, VulkanSmokeRendererError> {
let color_attachment = vk::AttachmentDescription::default()
.format(vk::Format::from_raw(format))
.samples(vk::SampleCountFlags::TYPE_1)
.load_op(vk::AttachmentLoadOp::CLEAR)
.store_op(vk::AttachmentStoreOp::STORE)
.initial_layout(vk::ImageLayout::UNDEFINED)
.final_layout(vk::ImageLayout::PRESENT_SRC_KHR);
let color_attachment_ref = vk::AttachmentReference::default()
.attachment(0)
.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 subpass = vk::SubpassDescription::default()
.pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS)
.color_attachments(&color_attachments)
.depth_stencil_attachment(&depth_attachment_ref);
let dependency = vk::SubpassDependency::default()
.src_subpass(vk::SUBPASS_EXTERNAL)
.dst_subpass(0)
.src_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT)
.dst_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT)
.src_access_mask(vk::AccessFlags::empty())
.dst_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE);
let attachments = [color_attachment, depth_attachment];
let subpasses = [subpass];
let dependencies = [dependency];
let create_info = vk::RenderPassCreateInfo::default()
.attachments(&attachments)
.subpasses(&subpasses)
.dependencies(&dependencies);
// SAFETY: The render-pass create info only references stack-owned descriptors.
unsafe { device.device().create_render_pass(&create_info, None) }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateRenderPass",
result: error,
}
})
}
fn create_pipeline_layout(
device: &VulkanLogicalDeviceProbe,
descriptor_set_layout: vk::DescriptorSetLayout,
) -> Result<vk::PipelineLayout, VulkanSmokeRendererError> {
let set_layouts = [descriptor_set_layout];
let push_constant_ranges = [
vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::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| {
VulkanSmokeRendererError::VulkanOperation {
context: "vkCreatePipelineLayout",
result: error,
}
})
}
#[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)]
fn extent_component_to_f32(value: u32) -> 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)]
fn create_graphics_pipeline(
device: &VulkanLogicalDeviceProbe,
render_pass: vk::RenderPass,
pipeline_layout: vk::PipelineLayout,
extent: (u32, u32),
state: LegacyPipelineState,
) -> Result<vk::Pipeline, VulkanSmokeRendererError> {
let vertex_shader = create_shader_module(device, TRIANGLE_VERTEX_SHADER_WORDS)?;
let fragment_shader = match create_shader_module(device, TRIANGLE_FRAGMENT_SHADER_WORDS) {
Ok(module) => module,
Err(error) => {
// SAFETY: The shader module was created above on this live logical device and is destroyed on setup failure.
unsafe { device.device().destroy_shader_module(vertex_shader, None) };
return Err(error);
}
};
let entry_point = c"main";
let shader_stages = [
vk::PipelineShaderStageCreateInfo::default()
.module(vertex_shader)
.name(entry_point)
.stage(vk::ShaderStageFlags::VERTEX),
vk::PipelineShaderStageCreateInfo::default()
.module(fragment_shader)
.name(entry_point)
.stage(vk::ShaderStageFlags::FRAGMENT),
];
let vertex_binding = vk::VertexInputBindingDescription::default()
.binding(0)
.stride(u32::try_from(8 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX))
.input_rate(vk::VertexInputRate::VERTEX);
let vertex_attributes = [
vk::VertexInputAttributeDescription::default()
.binding(0)
.location(0)
.format(vk::Format::R32G32B32_SFLOAT)
.offset(0),
vk::VertexInputAttributeDescription::default()
.binding(0)
.location(1)
.format(vk::Format::R32G32B32_SFLOAT)
.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_input_state = vk::PipelineVertexInputStateCreateInfo::default()
.vertex_binding_descriptions(&vertex_bindings)
.vertex_attribute_descriptions(&vertex_attributes);
let input_assembly_state = vk::PipelineInputAssemblyStateCreateInfo::default()
.topology(vk::PrimitiveTopology::TRIANGLE_LIST)
.primitive_restart_enable(false);
let viewports = [vk::Viewport {
x: 0.0,
y: 0.0,
width: extent_component_to_f32(extent.0),
height: extent_component_to_f32(extent.1),
min_depth: 0.0,
max_depth: 1.0,
}];
let scissors = [vk::Rect2D {
offset: vk::Offset2D { x: 0, y: 0 },
extent: vk::Extent2D {
width: extent.0,
height: extent.1,
},
}];
let viewport_state = vk::PipelineViewportStateCreateInfo::default()
.viewports(&viewports)
.scissors(&scissors);
let rasterization_state = vk::PipelineRasterizationStateCreateInfo::default()
.depth_clamp_enable(false)
.rasterizer_discard_enable(false)
.polygon_mode(vk::PolygonMode::FILL)
.line_width(1.0)
.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)
.depth_bias_enable(false);
let multisample_state = vk::PipelineMultisampleStateCreateInfo::default()
.sample_shading_enable(false)
.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()
.color_write_mask(
vk::ColorComponentFlags::R
| vk::ColorComponentFlags::G
| vk::ColorComponentFlags::B
| vk::ColorComponentFlags::A,
)
.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_state = vk::PipelineColorBlendStateCreateInfo::default()
.logic_op_enable(false)
.attachments(&color_blend_attachments);
let create_info = vk::GraphicsPipelineCreateInfo::default()
.stages(&shader_stages)
.vertex_input_state(&vertex_input_state)
.input_assembly_state(&input_assembly_state)
.viewport_state(&viewport_state)
.rasterization_state(&rasterization_state)
.multisample_state(&multisample_state)
.depth_stencil_state(&depth_stencil_state)
.color_blend_state(&color_blend_state)
.layout(pipeline_layout)
.render_pass(render_pass)
.subpass(0);
let create_infos = [create_info];
// SAFETY: Pipeline creation references only stack-owned descriptions and live render-pass/layout handles.
let pipeline_result = unsafe {
device
.device()
.create_graphics_pipelines(vk::PipelineCache::null(), &create_infos, None)
};
// SAFETY: The shader modules were created above on this live logical device and are no longer needed after pipeline creation.
unsafe {
device.device().destroy_shader_module(vertex_shader, None);
device.device().destroy_shader_module(fragment_shader, None);
}
let pipelines =
pipeline_result.map_err(|(_, error)| VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateGraphicsPipelines",
result: error,
})?;
Ok(pipelines[0])
}
fn create_shader_module(
device: &VulkanLogicalDeviceProbe,
words: &[u32],
) -> Result<vk::ShaderModule, VulkanSmokeRendererError> {
let create_info = vk::ShaderModuleCreateInfo::default().code(words);
// SAFETY: The SPIR-V slice points to static checked-in words and lives for the duration of the call.
unsafe { device.device().create_shader_module(&create_info, None) }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateShaderModule",
result: error,
}
})
}
fn create_framebuffer(
device: &VulkanLogicalDeviceProbe,
render_pass: vk::RenderPass,
image_view: vk::ImageView,
depth_view: vk::ImageView,
extent: (u32, u32),
) -> Result<vk::Framebuffer, VulkanSmokeRendererError> {
let attachments = [image_view, depth_view];
let create_info = vk::FramebufferCreateInfo::default()
.render_pass(render_pass)
.attachments(&attachments)
.width(extent.0)
.height(extent.1)
.layers(1);
// SAFETY: The framebuffer references a live image view and render pass owned by this logical device.
unsafe { device.device().create_framebuffer(&create_info, None) }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateFramebuffer",
result: error,
}
})
}
fn allocate_command_buffers(
device: &VulkanLogicalDeviceProbe,
command_pool: vk::CommandPool,
count: u32,
) -> Result<Vec<vk::CommandBuffer>, VulkanSmokeRendererError> {
let allocate_info = vk::CommandBufferAllocateInfo::default()
.command_pool(command_pool)
.level(vk::CommandBufferLevel::PRIMARY)
.command_buffer_count(count);
// SAFETY: The command pool belongs to this live logical device and the allocation info is stack-owned.
unsafe { device.device().allocate_command_buffers(&allocate_info) }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation {
context: "vkAllocateCommandBuffers",
result: error,
}
})
}
pub(super) fn destroy_swapchain_resources(
device: &VulkanLogicalDeviceProbe,
command_pool: vk::CommandPool,
resources: VulkanSwapchainResources,
) {
// SAFETY: All handles belong to this live logical device and are destroyed during renderer teardown/recreation.
unsafe {
if !resources.command_buffers.is_empty() {
device
.device()
.free_command_buffers(command_pool, &resources.command_buffers);
}
for framebuffer in resources.framebuffers {
device.device().destroy_framebuffer(framebuffer, None);
}
for pipeline in resources.pipelines.into_values() {
device.device().destroy_pipeline(pipeline, None);
}
device
.device()
.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()
.destroy_render_pass(resources.render_pass, None);
destroy_depth_attachment(device, &resources.depth_attachment);
for image_view in resources.image_views {
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);
}
}
}
fn destroy_partial_swapchain_resources(
device: &VulkanLogicalDeviceProbe,
command_pool: vk::CommandPool,
partial: PartialSwapchainResources,
) {
// SAFETY: All handles in the partial bundle belong to this live logical device and are destroyed on setup failure.
unsafe {
if !partial.command_buffers.is_empty() {
device
.device()
.free_command_buffers(command_pool, &partial.command_buffers);
}
for framebuffer in partial.framebuffers {
device.device().destroy_framebuffer(framebuffer, None);
}
for pipeline in partial.pipelines.into_values() {
device.device().destroy_pipeline(pipeline, None);
}
if let Some(pipeline_layout) = partial.pipeline_layout {
device
.device()
.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 {
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 {
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);
}
}
}
@@ -0,0 +1,832 @@
use super::*;
use crate::policy::{KHR_PORTABILITY_SUBSET_EXTENSION, KHR_SWAPCHAIN_EXTENSION};
use crate::shader_manifest::{
SHADER_COMPILER_BINARY_SHA256, SHADER_COMPILER_NAME, SHADER_COMPILER_VERSION,
SHADER_MANIFEST_SCHEMA, SHADER_TARGET_ENV, SPIRV_MAGIC, SPIRV_VALIDATOR_BINARY_SHA256,
SPIRV_VALIDATOR_NAME, SPIRV_VALIDATOR_VERSION, SPIRV_VERSION_1_0,
TRIANGLE_VERTEX_COMPILE_COMMAND, TRIANGLE_VERTEX_SOURCE_PATH, TRIANGLE_VERTEX_SOURCE_SHA256,
TRIANGLE_VERTEX_SPIRV_PATH, TRIANGLE_VERTEX_VALIDATE_COMMAND,
};
use crate::*;
use fparkan_platform::{DepthStencilSupport, RenderRequest};
use fparkan_render::{
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, LegacyPipelineState, RenderCommand,
RenderPhase,
};
use fparkan_render::{RenderBackend, RenderError};
#[test]
fn planning_backend_tracks_render_request_and_simulated_present() -> Result<(), RenderError> {
let mut backend = VulkanPlanningBackend::new();
let request = RenderRequest {
presentation: fparkan_platform::PresentationMode::Immediate,
..RenderRequest::conservative()
};
backend.set_render_request(request);
assert_eq!(backend.render_request(), request);
assert_eq!(backend.report().request.current_request, request);
assert_eq!(backend.report().request.request_updates, 1);
let commands = fparkan_render::RenderCommandList {
commands: vec![
RenderCommand::BeginFrame,
RenderCommand::Draw(DrawCommand {
id: DrawId(11),
phase: RenderPhase::Opaque,
object_id: None,
mesh: GpuMeshId(1),
material: GpuMaterialId(2),
pipeline_key: LegacyPipelineState::default().into(),
transform: [1.0; 16],
range: IndexRange { start: 0, count: 3 },
stable_order: 7,
}),
RenderCommand::EndFrame,
],
};
backend.execute(&commands)?;
assert_eq!(backend.state(), VulkanPlanningBackendState::Configured);
assert_eq!(backend.report().execution.planned_frames, 1);
assert_eq!(backend.report().execution.submission_plans, 1);
assert_eq!(backend.report().execution.simulated_presents, 1);
assert!(backend.report().execution.last_capture_size > 0);
assert_eq!(
backend.report().last_frame_submission,
Some(VulkanFrameSubmissionPlan {
schema: 1,
frames_in_flight: 2,
command_buffers: 2,
semaphores_per_frame: 2,
fences_per_frame: 1,
draw_count: 1,
indexed_vertex_count: 3,
})
);
Ok(())
}
#[test]
fn frame_submission_plan_json_is_stable() -> Result<(), RenderError> {
let commands = fparkan_render::RenderCommandList {
commands: vec![
RenderCommand::BeginFrame,
RenderCommand::Draw(DrawCommand {
id: DrawId(11),
phase: RenderPhase::Opaque,
object_id: None,
mesh: GpuMeshId(1),
material: GpuMaterialId(2),
pipeline_key: LegacyPipelineState::default().into(),
transform: [1.0; 16],
range: IndexRange { start: 0, count: 3 },
stable_order: 7,
}),
RenderCommand::EndFrame,
],
};
let swapchain = VulkanSwapchainPlan {
schema: 1,
extent: (1, 1),
format: VulkanSurfaceFormat {
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
},
present_mode: vk::PresentModeKHR::FIFO.as_raw(),
image_count: 3,
image_usage: vk::ImageUsageFlags::COLOR_ATTACHMENT.as_raw(),
};
let plan = plan_vulkan_frame_submission(&swapchain, &commands)?;
assert_eq!(plan.frames_in_flight, 2);
assert_eq!(plan.command_buffers, 3);
assert_eq!(plan.draw_count, 1);
assert_eq!(plan.indexed_vertex_count, 3);
assert_eq!(
render_frame_submission_plan_json(&plan),
"{\"schema\":1,\"frames_in_flight\":2,\"command_buffers\":3,\"semaphores_per_frame\":2,\"fences_per_frame\":1,\"draw_count\":1,\"indexed_vertex_count\":3}"
);
Ok(())
}
#[test]
fn device_scoring_is_deterministic_and_prefers_discrete_unified_queue() {
let devices = vec![
device("SwiftShader", VulkanDeviceType::Cpu, 0, true, false),
device("Discrete", VulkanDeviceType::DiscreteGpu, 1, true, false),
device(
"Integrated",
VulkanDeviceType::IntegratedGpu,
2,
true,
false,
),
];
let report = select_physical_device(&devices).expect("selected device");
assert_eq!(report.device_name, "Discrete");
assert_eq!(report.graphics_queue_family, 1);
assert_eq!(report.present_queue_family, 1);
assert!(!report.portability_subset);
assert_eq!(report.enabled_extensions, vec![KHR_SWAPCHAIN_EXTENSION]);
}
#[test]
fn device_selection_skips_rejected_candidates_before_accepting_valid_gpu() {
let mut rejected = device("Rejected", VulkanDeviceType::DiscreteGpu, 0, true, false);
rejected.queue_families[0].present = false;
let accepted = device("Accepted", VulkanDeviceType::IntegratedGpu, 2, true, false);
let report = select_physical_device(&[rejected, accepted]).expect("selected fallback device");
assert_eq!(report.device_name, "Accepted");
assert_eq!(report.graphics_queue_family, 2);
assert_eq!(report.present_queue_family, 2);
assert_eq!(
report.rejected_devices,
vec![VulkanRejectedDeviceReport {
device_name: "Rejected".to_string(),
reason_code: "no_present_queue",
reason: "Vulkan device Rejected has no present queue".to_string(),
}]
);
}
#[test]
fn queue_family_selection_prefers_lowest_index_unified_family() {
let mut candidate = device(
"Unified later in list",
VulkanDeviceType::DiscreteGpu,
7,
true,
false,
);
candidate.queue_families = vec![
VulkanQueueFamily {
index: 9,
graphics: true,
present: true,
},
VulkanQueueFamily {
index: 3,
graphics: true,
present: true,
},
VulkanQueueFamily {
index: 1,
graphics: true,
present: false,
},
];
let report = select_physical_device(&[candidate]).expect("selected unified queue");
assert_eq!(report.graphics_queue_family, 3);
assert_eq!(report.present_queue_family, 3);
}
#[test]
fn portability_subset_is_reported_and_enabled_when_exposed() {
let report = select_physical_device(&[device(
"MoltenVK",
VulkanDeviceType::IntegratedGpu,
0,
true,
true,
)])
.expect("selected device");
assert!(report.portability_subset);
assert_eq!(
report.enabled_extensions,
vec![
KHR_SWAPCHAIN_EXTENSION.to_string(),
KHR_PORTABILITY_SUBSET_EXTENSION.to_string()
]
);
}
#[test]
fn missing_loader_candidates_are_reported() {
assert_eq!(
select_physical_device(&[]),
Err(VulkanCapabilityError::NoPhysicalDevice)
);
}
#[test]
fn rejects_low_api_version() {
let mut candidate = device("Old GPU", VulkanDeviceType::DiscreteGpu, 0, true, false);
candidate.api_version = vk::API_VERSION_1_0;
assert!(matches!(
select_physical_device(&[candidate]),
Err(VulkanCapabilityError::ApiVersionTooLow { .. })
));
}
#[test]
fn rejects_missing_graphics_present_swapchain_and_format() {
let mut no_graphics = device("No graphics", VulkanDeviceType::DiscreteGpu, 0, true, false);
no_graphics.queue_families[0].graphics = false;
assert!(matches!(
select_physical_device(&[no_graphics]),
Err(VulkanCapabilityError::NoGraphicsQueue { .. })
));
let mut no_present = device("No present", VulkanDeviceType::DiscreteGpu, 0, true, false);
no_present.queue_families[0].present = false;
assert!(matches!(
select_physical_device(&[no_present]),
Err(VulkanCapabilityError::NoPresentQueue { .. })
));
let no_swapchain = device(
"No swapchain",
VulkanDeviceType::DiscreteGpu,
0,
false,
false,
);
assert!(matches!(
select_physical_device(&[no_swapchain]),
Err(VulkanCapabilityError::MissingSwapchainExtension { .. })
));
let mut no_format = device("No format", VulkanDeviceType::DiscreteGpu, 0, true, false);
no_format.surface_formats.clear();
assert!(matches!(
select_physical_device(&[no_format]),
Err(VulkanCapabilityError::MissingSurfaceFormat { .. })
));
let mut no_present_mode = device(
"No present mode",
VulkanDeviceType::DiscreteGpu,
0,
true,
false,
);
no_present_mode.present_modes.clear();
assert!(matches!(
select_physical_device(&[no_present_mode]),
Err(VulkanCapabilityError::MissingPresentMode { .. })
));
let mut no_color_attachment = device(
"No color attachment",
VulkanDeviceType::DiscreteGpu,
0,
true,
false,
);
no_color_attachment
.surface_capabilities
.supported_usage_flags = vk::ImageUsageFlags::TRANSFER_DST.as_raw();
assert!(matches!(
select_physical_device(&[no_color_attachment]),
Err(VulkanCapabilityError::MissingColorAttachmentUsage { .. })
));
}
#[test]
fn capability_gate_rejects_devices_without_requested_depth_stencil_support() {
let mut no_depth = device("No depth", VulkanDeviceType::DiscreteGpu, 0, true, false);
no_depth.supported_depth_stencil_formats = vec![vk::Format::D32_SFLOAT.as_raw()];
assert!(matches!(
select_physical_device(&[no_depth]),
Err(VulkanCapabilityError::MissingDepthStencilFormat { .. })
));
}
#[test]
fn capability_gate_respects_request_specific_depth_profiles() {
let mut no_stencil = device("No stencil", VulkanDeviceType::DiscreteGpu, 0, true, false);
no_stencil.supported_depth_stencil_formats = vec![vk::Format::D32_SFLOAT.as_raw()];
let relaxed_request = RenderRequest {
depth: DepthStencilSupport {
depth_bits: 32,
stencil_bits: 0,
},
..RenderRequest::conservative()
};
let report = select_physical_device_for_request(&[no_stencil], relaxed_request)
.expect("selected device for depth-only request");
assert_eq!(report.device_name, "No stencil");
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]
fn capability_report_preserves_informational_sampled_formats_and_limits() {
let report = select_physical_device(&[device(
"Telemetry GPU",
VulkanDeviceType::DiscreteGpu,
0,
true,
false,
)])
.expect("selected device");
assert_eq!(
report.informational_capabilities.sampled_color_formats,
vec![vk::Format::B8G8R8A8_SRGB.as_raw()]
);
assert_eq!(
report.informational_capabilities.sampled_depth_formats,
vec![vk::Format::D32_SFLOAT.as_raw()]
);
assert_eq!(
report.informational_capabilities.limits,
VulkanDeviceLimits {
max_image_dimension_2d: 4096,
max_sampler_allocation_count: 4096,
max_per_stage_descriptor_samplers: 16,
max_bound_descriptor_sets: 4,
}
);
}
#[test]
fn capability_report_json_is_stable() {
let mut rejected = device("Rejected", VulkanDeviceType::IntegratedGpu, 0, true, false);
rejected.present_modes.clear();
let report = select_physical_device(&[
rejected,
device("GPU \"A\"", VulkanDeviceType::DiscreteGpu, 3, true, false),
])
.expect("selected device");
assert_eq!(
render_capability_report_json(&report),
"{\"schema\":1,\"vulkan_api\":\"1.1.0\",\"device_name\":\"GPU \\\"A\\\"\",\"score\":1101,\"graphics_queue_family\":3,\"present_queue_family\":3,\"portability_subset\":false,\"enabled_extensions\":[\"VK_KHR_swapchain\"],\"informational_capabilities\":{\"sampled_color_formats\":[50],\"sampled_depth_formats\":[126],\"limits\":{\"max_image_dimension_2d\":4096,\"max_sampler_allocation_count\":4096,\"max_per_stage_descriptor_samplers\":16,\"max_bound_descriptor_sets\":4}},\"rejected_devices\":[{\"device_name\":\"Rejected\",\"reason_code\":\"missing_present_mode\",\"reason\":\"Vulkan device Rejected has no supported present mode\"}]}"
);
}
#[test]
fn loader_probe_report_json_is_stable() {
assert_eq!(
vulkan_entry_symbol_name().to_bytes(),
b"vkGetInstanceProcAddr"
);
assert_eq!(
render_loader_probe_report_json(&VulkanLoaderProbeReport {
schema: 1,
loader_available: true,
instance_api_version: vk::API_VERSION_1_2,
}),
"{\"schema\":1,\"loader_available\":true,\"instance_api\":\"1.2.0\"}"
);
}
#[test]
fn loader_error_display_is_actionable() {
assert_eq!(
VulkanLoaderError::Unavailable {
message: "dlopen failed".to_string(),
}
.to_string(),
"Vulkan loader is unavailable: dlopen failed"
);
}
#[test]
fn instance_plan_is_sorted_deduplicated_and_portability_aware() {
let plan = plan_vulkan_instance(&VulkanInstanceConfig {
application_name: "FParkan".to_string(),
required_extensions: vec![
"VK_KHR_surface".to_string(),
KHR_PORTABILITY_ENUMERATION_EXTENSION.to_string(),
"VK_KHR_surface".to_string(),
],
enable_portability_enumeration: true,
enable_validation: true,
});
assert_eq!(
render_instance_plan_json(&plan),
"{\"schema\":1,\"create_flags\":1,\"validation_requested\":true,\"enabled_extensions\":[\"VK_EXT_debug_utils\",\"VK_KHR_portability_enumeration\",\"VK_KHR_surface\"]}"
);
}
#[test]
fn instance_plan_adds_portability_extension_when_requested() {
let plan = plan_vulkan_instance(&VulkanInstanceConfig {
application_name: "FParkan".to_string(),
required_extensions: vec!["VK_KHR_surface".to_string()],
enable_portability_enumeration: true,
enable_validation: false,
});
assert_eq!(
plan.enabled_extensions,
vec![
KHR_PORTABILITY_ENUMERATION_EXTENSION.to_string(),
"VK_KHR_surface".to_string()
]
);
assert_eq!(plan.create_flags, 1);
}
#[test]
fn invalid_instance_extension_name_is_reported_before_loader_use() {
assert_eq!(
cstring_vec(&["bad\0extension".to_string()]),
Err(VulkanInstanceError::InvalidExtensionName {
extension: "bad\0extension".to_string()
})
);
}
#[test]
fn missing_instance_extension_is_reported_before_create_instance() {
assert_eq!(
ensure_instance_extensions_available(
&[
"VK_EXT_debug_utils".to_string(),
"VK_KHR_surface".to_string(),
],
&["VK_KHR_surface".to_string()],
),
Err(VulkanInstanceError::MissingInstanceExtension {
extension: "VK_EXT_debug_utils".to_string(),
})
);
}
#[test]
fn surface_plan_requires_native_handles() {
assert_eq!(
plan_vulkan_surface(None),
Err(VulkanSurfaceError::MissingNativeHandles)
);
assert_eq!(
VulkanSurfaceError::MissingNativeHandles.to_string(),
"native window/display handles are required for Vulkan surface creation"
);
}
#[test]
fn surface_plan_json_is_stable() {
assert_eq!(
render_surface_plan_json(&VulkanSurfacePlan {
schema: 1,
required_instance_extensions: vec![
"VK_KHR_surface".to_string(),
"VK_EXT_metal_surface".to_string(),
],
}),
"{\"schema\":1,\"required_instance_extensions\":[\"VK_KHR_surface\",\"VK_EXT_metal_surface\"]}"
);
}
#[test]
fn static_surface_extension_name_is_decoded() {
let name = extension_name(ash::khr::surface::NAME.as_ptr()).expect("extension name");
assert_eq!(name, "VK_KHR_surface");
}
#[test]
fn swapchain_plan_prefers_srgb_mailbox_and_clamps_extent() {
let plan = plan_vulkan_swapchain(&swapchain_request()).expect("swapchain plan");
assert_eq!(
plan.format,
VulkanSurfaceFormat {
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
}
);
assert_eq!(plan.present_mode, vk::PresentModeKHR::MAILBOX.as_raw());
assert_eq!(plan.extent, (1024, 720));
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]
fn swapchain_plan_uses_fifo_and_current_extent_fallbacks() {
let mut request = swapchain_request();
request.preferred_present_mode = vk::PresentModeKHR::IMMEDIATE.as_raw();
request.present_modes = vec![vk::PresentModeKHR::FIFO.as_raw()];
request.capabilities.current_extent = Some((800, 600));
let plan = plan_vulkan_swapchain(&request).expect("swapchain plan");
assert_eq!(plan.present_mode, vk::PresentModeKHR::FIFO.as_raw());
assert_eq!(plan.extent, (800, 600));
}
#[test]
fn swapchain_plan_accepts_undefined_surface_format_by_picking_stage0_default() {
let mut request = swapchain_request();
request.formats = vec![VulkanSurfaceFormat {
format: vk::Format::UNDEFINED.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
}];
let plan = plan_vulkan_swapchain(&request).expect("swapchain plan");
assert_eq!(
plan.format,
VulkanSurfaceFormat {
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
}
);
}
#[test]
fn swapchain_plan_rejects_missing_surface_data_and_empty_extent() {
let mut request = swapchain_request();
request.formats.clear();
assert_eq!(
plan_vulkan_swapchain(&request),
Err(VulkanSwapchainError::MissingSurfaceFormat)
);
let mut request = swapchain_request();
request.present_modes.clear();
assert_eq!(
plan_vulkan_swapchain(&request),
Err(VulkanSwapchainError::MissingPresentMode)
);
let mut request = swapchain_request();
request.capabilities.current_extent = Some((0, 600));
assert_eq!(
plan_vulkan_swapchain(&request),
Err(VulkanSwapchainError::EmptyExtent)
);
}
#[test]
fn swapchain_plan_json_and_recreation_reports_are_stable() {
let plan = plan_vulkan_swapchain(&swapchain_request()).expect("swapchain plan");
assert_eq!(
render_swapchain_plan_json(&plan),
"{\"schema\":1,\"extent\":[1024,720],\"format\":50,\"color_space\":0,\"present_mode\":1,\"image_count\":3,\"image_usage\":16}"
);
let report = swapchain_recreation_report(
VulkanSwapchainRecreationReason::OutOfDate,
(1024, 720),
(1280, 720),
);
assert_eq!(
render_swapchain_recreation_report_json(&report),
"{\"schema\":1,\"reason\":\"out_of_date\",\"previous_extent\":[1024,720],\"next_extent\":[1280,720]}"
);
}
#[test]
fn triangle_shader_manifest_hashes_are_stable() {
let report = validate_shader_manifest(&triangle_shader_manifest()).expect("shader manifest");
assert_eq!(report.schema, SHADER_MANIFEST_SCHEMA);
assert_eq!(report.target_env, SHADER_TARGET_ENV);
assert_eq!(
report.compiler,
VulkanShaderToolManifest {
name: SHADER_COMPILER_NAME,
version: SHADER_COMPILER_VERSION,
binary_sha256: SHADER_COMPILER_BINARY_SHA256,
}
);
assert_eq!(
report.validator,
VulkanShaderToolManifest {
name: SPIRV_VALIDATOR_NAME,
version: SPIRV_VALIDATOR_VERSION,
binary_sha256: SPIRV_VALIDATOR_BINARY_SHA256,
}
);
assert_eq!(report.modules.len(), 2);
assert_eq!(report.modules[0].name, "triangle.vert");
assert_eq!(report.modules[0].stage, VulkanShaderStage::Vertex);
assert_eq!(report.modules[0].source_path, TRIANGLE_VERTEX_SOURCE_PATH);
assert_eq!(
report.modules[0].source_sha256,
TRIANGLE_VERTEX_SOURCE_SHA256
);
assert_eq!(report.modules[0].spirv_path, TRIANGLE_VERTEX_SPIRV_PATH);
assert_eq!(report.modules[0].word_count, 358);
assert_eq!(
report.modules[0].sha256,
"4e2051ac43b933a57e5557e596bcc95952b4efbfcb45315180f89581e094398d"
);
assert_eq!(report.modules[0].descriptor_sets, 0);
assert_eq!(report.modules[0].push_constant_bytes, 64);
assert_eq!(
report.modules[0].compile_command,
TRIANGLE_VERTEX_COMPILE_COMMAND
);
assert_eq!(
report.modules[0].validate_command,
TRIANGLE_VERTEX_VALIDATE_COMMAND
);
assert!(!report.modules[0].interface_hash.is_empty());
assert_eq!(
report.modules[1].sha256,
"8ab7bf835e166892b04b10fa1100258daf355d2f693ad311d014dbee22de5c7b"
);
assert_eq!(
report.manifest_hash,
"cf471972978ddb5c8919711ad64c93d0d64e18b1f590a89ce014f1e2a743cfb5"
);
}
#[test]
fn shader_manifest_report_json_is_stable() {
let report = validate_shader_manifest(&triangle_shader_manifest()).expect("shader manifest");
let json = render_shader_manifest_report_json(&report);
assert!(json.contains(SHADER_COMPILER_NAME));
assert!(json.contains(SPIRV_VALIDATOR_NAME));
assert!(json.contains(TRIANGLE_VERTEX_SOURCE_PATH));
assert!(json.contains(TRIANGLE_VERTEX_COMPILE_COMMAND));
}
#[test]
fn checked_in_shader_manifest_matches_generated_report() {
let report = validate_shader_manifest(&triangle_shader_manifest()).expect("shader manifest");
assert_eq!(
render_shader_manifest_report_json(&report),
include_str!("../../shaders/manifest.json").trim()
);
}
#[test]
fn shader_manifest_rejects_invalid_spirv_containers() {
let mut module = triangle_shader_manifest().remove(0);
module.words = &[0xFFFF_FFFF, SPIRV_VERSION_1_0, 0, 1, 0];
assert_eq!(
validate_shader_manifest(&[module]),
Err(VulkanShaderManifestError::InvalidMagic {
name: "triangle.vert",
found: 0xFFFF_FFFF,
})
);
let mut module = triangle_shader_manifest().remove(0);
module.words = &[SPIRV_MAGIC, 0, 0, 1, 0];
assert_eq!(
validate_shader_manifest(&[module]),
Err(VulkanShaderManifestError::UnsupportedVersion {
name: "triangle.vert",
found: 0,
})
);
let mut module = triangle_shader_manifest().remove(0);
module.words = &[SPIRV_MAGIC, SPIRV_VERSION_1_0, 0, 0, 0];
assert_eq!(
validate_shader_manifest(&[module]),
Err(VulkanShaderManifestError::InvalidBound {
name: "triangle.vert",
})
);
}
fn device(
name: &str,
device_type: VulkanDeviceType,
queue_index: u32,
swapchain: bool,
portability_subset: bool,
) -> VulkanPhysicalDeviceRecord {
let mut extensions = Vec::new();
if swapchain {
extensions.push(KHR_SWAPCHAIN_EXTENSION.to_string());
}
if portability_subset {
extensions.push(KHR_PORTABILITY_SUBSET_EXTENSION.to_string());
}
VulkanPhysicalDeviceRecord {
name: name.to_string(),
api_version: MIN_VULKAN_API_VERSION,
device_type,
extensions,
queue_families: vec![VulkanQueueFamily {
index: queue_index,
graphics: true,
present: true,
}],
surface_formats: vec![VulkanSurfaceFormat {
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
}],
present_modes: vec![
vk::PresentModeKHR::FIFO.as_raw(),
vk::PresentModeKHR::MAILBOX.as_raw(),
],
surface_capabilities: default_surface_capabilities(),
supported_depth_stencil_formats: vec![
vk::Format::D24_UNORM_S8_UINT.as_raw(),
vk::Format::D32_SFLOAT_S8_UINT.as_raw(),
vk::Format::D32_SFLOAT.as_raw(),
],
sampled_image_formats: vec![
vk::Format::B8G8R8A8_SRGB.as_raw(),
vk::Format::D32_SFLOAT.as_raw(),
],
limits: VulkanDeviceLimits {
max_image_dimension_2d: 4096,
max_sampler_allocation_count: 4096,
max_per_stage_descriptor_samplers: 16,
max_bound_descriptor_sets: 4,
},
}
}
fn swapchain_request() -> VulkanSwapchainRequest {
VulkanSwapchainRequest {
drawable_extent: (1280, 720),
formats: vec![
VulkanSurfaceFormat {
format: vk::Format::R8G8B8A8_UNORM.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
},
VulkanSurfaceFormat {
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
},
],
present_modes: vec![
vk::PresentModeKHR::FIFO.as_raw(),
vk::PresentModeKHR::MAILBOX.as_raw(),
],
capabilities: default_surface_capabilities(),
preferred_present_mode: vk::PresentModeKHR::MAILBOX.as_raw(),
}
}
fn default_surface_capabilities() -> VulkanSwapchainSurfaceCapabilities {
VulkanSwapchainSurfaceCapabilities {
current_extent: None,
min_extent: (320, 240),
max_extent: (1024, 768),
min_image_count: 2,
max_image_count: 3,
supported_usage_flags: vk::ImageUsageFlags::COLOR_ATTACHMENT.as_raw(),
}
}
@@ -0,0 +1,125 @@
#![allow(unsafe_code)]
use ash::vk;
use std::collections::BTreeSet;
use std::ffi::CStr;
use std::sync::{
atomic::{AtomicU32, Ordering},
Mutex,
};
use super::{VulkanInstanceProbe, VulkanSmokeRendererError, VulkanValidationReport};
struct VulkanValidationShared {
warning_count: AtomicU32,
error_count: AtomicU32,
vuids: Mutex<BTreeSet<String>>,
}
impl Default for VulkanValidationShared {
fn default() -> Self {
Self {
warning_count: AtomicU32::new(0),
error_count: AtomicU32::new(0),
vuids: Mutex::new(BTreeSet::new()),
}
}
}
pub(super) struct VulkanValidationMessenger {
loader: ash::ext::debug_utils::Instance,
messenger: vk::DebugUtilsMessengerEXT,
shared: Box<VulkanValidationShared>,
}
impl VulkanValidationMessenger {
pub(super) fn report(&self) -> VulkanValidationReport {
let vuids = self
.shared
.vuids
.lock()
.map(|values| values.iter().cloned().collect::<Vec<_>>())
.unwrap_or_default();
VulkanValidationReport {
warning_count: self.shared.warning_count.load(Ordering::Relaxed),
error_count: self.shared.error_count.load(Ordering::Relaxed),
vuids,
}
}
}
impl Drop for VulkanValidationMessenger {
fn drop(&mut self) {
// SAFETY: The messenger belongs to this instance-level loader and is destroyed once.
unsafe {
self.loader
.destroy_debug_utils_messenger(self.messenger, None);
};
}
}
unsafe extern "system" fn vulkan_validation_callback(
message_severity: vk::DebugUtilsMessageSeverityFlagsEXT,
_message_types: vk::DebugUtilsMessageTypeFlagsEXT,
callback_data: *const vk::DebugUtilsMessengerCallbackDataEXT<'_>,
user_data: *mut std::ffi::c_void,
) -> vk::Bool32 {
// SAFETY: The debug messenger stores a stable pointer to `VulkanValidationShared` for the messenger lifetime.
let Some(shared) = (unsafe { (user_data as *const VulkanValidationShared).as_ref() }) else {
return vk::FALSE;
};
if message_severity.contains(vk::DebugUtilsMessageSeverityFlagsEXT::ERROR) {
shared.error_count.fetch_add(1, Ordering::Relaxed);
} else if message_severity.contains(vk::DebugUtilsMessageSeverityFlagsEXT::WARNING) {
shared.warning_count.fetch_add(1, Ordering::Relaxed);
}
// SAFETY: Vulkan invokes the callback with either a null pointer or a valid callback-data payload.
let Some(callback_data) = (unsafe { callback_data.as_ref() }) else {
return vk::FALSE;
};
if let Some(vuid) = (!callback_data.p_message_id_name.is_null()).then(|| {
// SAFETY: `p_message_id_name` is a Vulkan-owned NUL-terminated string for the callback duration.
unsafe { CStr::from_ptr(callback_data.p_message_id_name) }
.to_string_lossy()
.into_owned()
}) {
if vuid.starts_with("VUID-") {
if let Ok(mut vuids) = shared.vuids.lock() {
vuids.insert(vuid);
}
}
}
vk::FALSE
}
pub(super) fn create_validation_messenger(
instance: &VulkanInstanceProbe,
) -> Result<VulkanValidationMessenger, VulkanSmokeRendererError> {
let shared = Box::new(VulkanValidationShared::default());
let loader = ash::ext::debug_utils::Instance::new(&instance.entry, &instance.instance);
let create_info = vk::DebugUtilsMessengerCreateInfoEXT::default()
.message_severity(
vk::DebugUtilsMessageSeverityFlagsEXT::WARNING
| vk::DebugUtilsMessageSeverityFlagsEXT::ERROR,
)
.message_type(
vk::DebugUtilsMessageTypeFlagsEXT::GENERAL
| vk::DebugUtilsMessageTypeFlagsEXT::VALIDATION
| vk::DebugUtilsMessageTypeFlagsEXT::PERFORMANCE,
)
.pfn_user_callback(Some(vulkan_validation_callback))
.user_data((&raw const *shared).cast_mut().cast());
let messenger =
// SAFETY: The create info points at a stable boxed user-data allocation for the messenger lifetime.
unsafe { loader.create_debug_utils_messenger(&create_info, None) }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateDebugUtilsMessengerEXT",
result: error,
}
})?;
Ok(VulkanValidationMessenger {
loader,
messenger,
shared,
})
}
+12
View File
@@ -0,0 +1,12 @@
#![deny(unsafe_code)]
//! Vulkan adapter public surface.
mod ffi;
mod planning_backend;
mod policy;
mod shader_manifest;
pub use ffi::*;
pub use planning_backend::*;
pub use policy::*;
pub use shader_manifest::*;
@@ -0,0 +1,168 @@
use ash::vk;
use fparkan_platform::RenderRequest;
use fparkan_render::{
canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError,
};
use crate::{
plan_vulkan_frame_submission, VulkanFrameSubmissionPlan, VulkanSurfaceFormat,
VulkanSwapchainPlan,
};
/// Vulkan backend migration readiness.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum VulkanPlanningBackendState {
/// Planning facade is configured and able to accept command lists.
#[default]
Configured,
/// Adapter is tracking a recoverable runtime surface/depth pipeline fault.
Degraded,
/// Adapter has encountered a non-recoverable error.
Error,
}
/// Diagnostics for planning-facade request tracking.
#[derive(Clone, Debug, PartialEq)]
pub struct VulkanPlanningRequestReport {
/// Last render request observed by the planning facade.
pub current_request: RenderRequest,
/// Number of meaningful request updates applied to the facade.
pub request_updates: u64,
}
impl Default for VulkanPlanningRequestReport {
fn default() -> Self {
Self {
current_request: RenderRequest::conservative(),
request_updates: 0,
}
}
}
/// Diagnostics for planning-facade execution telemetry.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct VulkanPlanningExecutionReport {
/// Total frames planned by the facade.
pub planned_frames: u64,
/// Total frame-submission plans emitted by the facade.
pub submission_plans: u64,
/// Last command-capture byte size.
pub last_capture_size: usize,
/// Number of simulated present calls issued by the planning facade.
pub simulated_presents: u64,
}
/// Diagnostics for Vulkan planning backend setup and frame progression.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct VulkanPlanningBackendReport {
/// Request-tracking telemetry.
pub request: VulkanPlanningRequestReport,
/// Execution-planning telemetry.
pub execution: VulkanPlanningExecutionReport,
/// Last deterministic frame submission plan.
pub last_frame_submission: Option<VulkanFrameSubmissionPlan>,
}
/// Vulkan planning backend facade used by the game entrypoint.
#[derive(Debug)]
pub struct VulkanPlanningBackend {
state: VulkanPlanningBackendState,
report: VulkanPlanningBackendReport,
swapchain_plan: VulkanSwapchainPlan,
}
impl Default for VulkanPlanningBackend {
fn default() -> Self {
Self::new()
}
}
impl VulkanPlanningBackend {
/// Creates a new Vulkan planning backend facade.
#[must_use]
pub fn new() -> Self {
Self {
state: VulkanPlanningBackendState::Configured,
report: VulkanPlanningBackendReport::default(),
swapchain_plan: default_stage0_swapchain_plan(),
}
}
/// Replaces active surface/profile request.
pub fn set_render_request(&mut self, request: RenderRequest) {
if self.report.request.current_request != request {
self.report.request.current_request = request;
self.report.request.request_updates =
self.report.request.request_updates.saturating_add(1);
}
}
/// Returns active render request policy.
#[must_use]
pub const fn render_request(&self) -> RenderRequest {
self.report.request.current_request
}
/// Replaces active swapchain plan used for frame submission planning.
pub fn set_swapchain_plan(&mut self, plan: VulkanSwapchainPlan) {
self.swapchain_plan = plan;
}
/// Returns active swapchain plan.
#[must_use]
pub const fn swapchain_plan(&self) -> &VulkanSwapchainPlan {
&self.swapchain_plan
}
/// Returns adapter state.
#[must_use]
pub const fn state(&self) -> VulkanPlanningBackendState {
self.state
}
/// Returns backend report.
#[must_use]
pub fn report(&self) -> &VulkanPlanningBackendReport {
&self.report
}
fn simulate_present(&mut self) {
self.report.execution.simulated_presents =
self.report.execution.simulated_presents.saturating_add(1);
}
}
impl RenderBackend for VulkanPlanningBackend {
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
if !matches!(
self.state,
VulkanPlanningBackendState::Configured | VulkanPlanningBackendState::Degraded
) {
return Err(RenderError::InvalidRange);
}
let capture = canonical_capture(commands)?;
let frame_plan = plan_vulkan_frame_submission(&self.swapchain_plan, commands)?;
self.report.execution.planned_frames =
self.report.execution.planned_frames.saturating_add(1);
self.report.execution.submission_plans =
self.report.execution.submission_plans.saturating_add(1);
self.report.execution.last_capture_size = capture.len();
self.report.last_frame_submission = Some(frame_plan);
self.simulate_present();
Ok(FrameOutput)
}
}
fn default_stage0_swapchain_plan() -> VulkanSwapchainPlan {
VulkanSwapchainPlan {
schema: 1,
extent: (1, 1),
format: VulkanSurfaceFormat {
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
},
present_mode: vk::PresentModeKHR::FIFO.as_raw(),
image_count: 2,
image_usage: vk::ImageUsageFlags::COLOR_ATTACHMENT.as_raw(),
}
}
@@ -0,0 +1,915 @@
use ash::vk;
use fparkan_platform::{DepthStencilSupport, RenderRequest};
use fparkan_render::{validate_command_list, RenderCommand, RenderCommandList, RenderError};
use serde::Serialize;
const MIN_VULKAN_API_VERSION: u32 = vk::API_VERSION_1_1;
pub(crate) const KHR_SWAPCHAIN_EXTENSION: &str = "VK_KHR_swapchain";
pub(crate) const KHR_PORTABILITY_SUBSET_EXTENSION: &str = "VK_KHR_portability_subset";
/// Synthetic physical-device type used by deterministic capability scoring.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum VulkanDeviceType {
/// Discrete GPU.
DiscreteGpu,
/// Integrated GPU.
IntegratedGpu,
/// CPU or software Vulkan implementation.
Cpu,
/// Other or unknown implementation.
Other,
}
impl VulkanDeviceType {
const fn score_bonus(self) -> i32 {
match self {
Self::DiscreteGpu => 1_000,
Self::IntegratedGpu => 700,
Self::Cpu => 100,
Self::Other => 10,
}
}
}
/// Queue-family capabilities needed by the Stage 0 renderer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VulkanQueueFamily {
/// Stable queue-family index.
pub index: u32,
/// Whether the family supports graphics commands.
pub graphics: bool,
/// Whether the family supports presentation for the target surface.
pub present: bool,
}
/// Surface format capability needed by the Stage 0 swapchain policy.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VulkanSurfaceFormat {
/// Vulkan format numeric value.
pub format: i32,
/// Vulkan color-space numeric value.
pub color_space: i32,
}
/// Surface capabilities needed by the Stage 0 swapchain policy.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VulkanSwapchainSurfaceCapabilities {
/// Current surface extent, when dictated by the platform.
pub current_extent: Option<(u32, u32)>,
/// Minimum supported swapchain extent.
pub min_extent: (u32, u32),
/// Maximum supported swapchain extent.
pub max_extent: (u32, u32),
/// Minimum supported image count.
pub min_image_count: u32,
/// Maximum supported image count, or 0 when unbounded.
pub max_image_count: u32,
/// Supported swapchain image-usage flags as raw Vulkan bits.
pub supported_usage_flags: u32,
}
/// Deterministic swapchain planning input.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanSwapchainRequest {
/// Requested drawable extent.
pub drawable_extent: (u32, u32),
/// Available surface formats.
pub formats: Vec<VulkanSurfaceFormat>,
/// Available present modes as raw Vulkan values.
pub present_modes: Vec<i32>,
/// Surface capabilities.
pub capabilities: VulkanSwapchainSurfaceCapabilities,
/// Preferred present mode.
pub preferred_present_mode: i32,
}
/// Deterministic swapchain plan.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanSwapchainPlan {
/// Report schema version.
pub schema: u32,
/// Selected swapchain extent.
pub extent: (u32, u32),
/// Selected surface format.
pub format: VulkanSurfaceFormat,
/// Selected present mode raw Vulkan value.
pub present_mode: i32,
/// Selected image count.
pub image_count: u32,
/// Selected image-usage flags as raw Vulkan bits.
pub image_usage: u32,
}
/// Swapchain planning error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VulkanSwapchainError {
/// No surface format was available.
MissingSurfaceFormat,
/// No present mode was available.
MissingPresentMode,
/// Requested or current extent is empty.
EmptyExtent,
}
impl std::fmt::Display for VulkanSwapchainError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingSurfaceFormat => write!(f, "Vulkan swapchain has no surface format"),
Self::MissingPresentMode => write!(f, "Vulkan swapchain has no present mode"),
Self::EmptyExtent => write!(f, "Vulkan swapchain extent must be non-zero"),
}
}
}
impl std::error::Error for VulkanSwapchainError {}
/// Swapchain recreation reason.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum VulkanSwapchainRecreationReason {
/// Drawable extent changed.
Resize,
/// Vulkan reported `VK_ERROR_OUT_OF_DATE_KHR`.
OutOfDate,
/// Vulkan reported `VK_SUBOPTIMAL_KHR`.
Suboptimal,
}
/// Deterministic swapchain recreation report.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanSwapchainRecreationReport {
/// Report schema version.
pub schema: u32,
/// Recreation reason.
pub reason: VulkanSwapchainRecreationReason,
/// Previous extent.
pub previous_extent: (u32, u32),
/// Next extent.
pub next_extent: (u32, u32),
}
/// Deterministic frame submission plan for command buffers and sync objects.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct VulkanFrameSubmissionPlan {
/// Report schema version.
pub schema: u32,
/// Frames allowed in flight.
pub frames_in_flight: u32,
/// Swapchain-backed primary command buffers.
pub command_buffers: u32,
/// Binary semaphores allocated per frame.
pub semaphores_per_frame: u32,
/// Fences allocated per frame.
pub fences_per_frame: u32,
/// Draw commands encoded into the frame.
pub draw_count: u32,
/// Total indexed vertices submitted by draw commands.
pub indexed_vertex_count: u32,
}
/// Synthetic physical-device capabilities used by negative tests and reports.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanPhysicalDeviceRecord {
/// Human-readable device name.
pub name: String,
/// Reported Vulkan API version.
pub api_version: u32,
/// Device class.
pub device_type: VulkanDeviceType,
/// Supported device-extension names.
pub extensions: Vec<String>,
/// Queue-family capabilities.
pub queue_families: Vec<VulkanQueueFamily>,
/// Surface formats accepted by the target surface.
pub surface_formats: Vec<VulkanSurfaceFormat>,
/// Present modes accepted by the target surface.
pub present_modes: Vec<i32>,
/// Surface capabilities accepted by the target surface.
pub surface_capabilities: VulkanSwapchainSurfaceCapabilities,
/// Depth/stencil attachment formats supported by the device.
pub supported_depth_stencil_formats: Vec<i32>,
/// Formats that can be used as sampled images.
pub sampled_image_formats: Vec<i32>,
/// Informational device limits relevant to the future Stage 0 baseline.
pub limits: VulkanDeviceLimits,
}
impl VulkanPhysicalDeviceRecord {
/// Returns whether the device supports an extension name.
#[must_use]
pub fn supports_extension(&self, extension: &str) -> bool {
self.extensions
.iter()
.any(|candidate| candidate == extension)
}
}
/// Informational device limits relevant to future Stage 0 capability growth.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
pub struct VulkanDeviceLimits {
/// Maximum 2D image dimension supported by the device.
pub max_image_dimension_2d: u32,
/// Maximum number of live samplers supported by the device.
pub max_sampler_allocation_count: u32,
/// Maximum number of sampler descriptors per stage.
pub max_per_stage_descriptor_samplers: u32,
/// Maximum number of bound descriptor sets.
pub max_bound_descriptor_sets: u32,
}
/// Informational capabilities preserved in deterministic Stage 0 reports.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct VulkanInformationalCapabilities {
/// Color formats that support sampled-image usage.
pub sampled_color_formats: Vec<i32>,
/// Depth/stencil formats that support sampled-image usage.
pub sampled_depth_formats: Vec<i32>,
/// Future-baseline device limits.
pub limits: VulkanDeviceLimits,
}
/// Selected device and queue capability report.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanCapabilityReport {
/// Report schema version.
pub schema: u32,
/// Selected device name.
pub device_name: String,
/// Selected Vulkan API version.
pub vulkan_api_version: u32,
/// Deterministic score used for device selection.
pub score: i32,
/// Graphics queue family index.
pub graphics_queue_family: u32,
/// Present queue family index.
pub present_queue_family: u32,
/// Whether portability subset is enabled for the selected device.
pub portability_subset: bool,
/// Enabled device extensions.
pub enabled_extensions: Vec<String>,
/// Informational capabilities retained for future baseline planning.
pub informational_capabilities: VulkanInformationalCapabilities,
/// Devices rejected by deterministic Stage 0 capability validation.
pub rejected_devices: Vec<VulkanRejectedDeviceReport>,
}
/// Deterministic rejection reason for an unsuitable physical device.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct VulkanRejectedDeviceReport {
/// Human-readable device name.
pub device_name: String,
/// Stable machine-readable rejection code.
pub reason_code: &'static str,
/// Actionable rejection summary.
pub reason: String,
}
/// Vulkan capability selection error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VulkanCapabilityError {
/// No physical devices were available.
NoPhysicalDevice,
/// Device API version is lower than the Stage 0 minimum.
ApiVersionTooLow {
/// Required Vulkan API version.
required: u32,
/// Reported Vulkan API version.
found: u32,
},
/// Required graphics queue is unavailable.
NoGraphicsQueue {
/// Device name that failed validation.
device: String,
},
/// Required present queue is unavailable.
NoPresentQueue {
/// Device name that failed validation.
device: String,
},
/// Swapchain device extension is unavailable.
MissingSwapchainExtension {
/// Device name that failed validation.
device: String,
},
/// No compatible surface format exists.
MissingSurfaceFormat {
/// Device name that failed validation.
device: String,
},
/// No present mode is available for the target surface.
MissingPresentMode {
/// Device name that failed validation.
device: String,
},
/// Swapchain images cannot be used as color attachments.
MissingColorAttachmentUsage {
/// Device name that failed validation.
device: String,
},
/// No compatible depth/stencil attachment format exists for the render request.
MissingDepthStencilFormat {
/// Device name that failed validation.
device: String,
/// Requested depth/stencil profile.
requested: DepthStencilSupport,
},
}
impl std::fmt::Display for VulkanCapabilityError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoPhysicalDevice => write!(f, "no Vulkan physical device available"),
Self::ApiVersionTooLow { required, found } => write!(
f,
"Vulkan API version too low: required {}, found {}",
format_api_version(*required),
format_api_version(*found)
),
Self::NoGraphicsQueue { device } => {
write!(f, "Vulkan device {device} has no graphics queue")
}
Self::NoPresentQueue { device } => {
write!(f, "Vulkan device {device} has no present queue")
}
Self::MissingSwapchainExtension { device } => {
write!(f, "Vulkan device {device} lacks {KHR_SWAPCHAIN_EXTENSION}")
}
Self::MissingSurfaceFormat { device } => {
write!(f, "Vulkan device {device} has no compatible surface format")
}
Self::MissingPresentMode { device } => {
write!(f, "Vulkan device {device} has no supported present mode")
}
Self::MissingColorAttachmentUsage { device } => write!(
f,
"Vulkan device {device} surface does not support COLOR_ATTACHMENT usage"
),
Self::MissingDepthStencilFormat { device, requested } => write!(
f,
"Vulkan device {device} lacks a depth/stencil attachment format for {}-bit depth and {}-bit stencil",
requested.depth_bits,
requested.stencil_bits
),
}
}
}
impl std::error::Error for VulkanCapabilityError {}
/// Selects a Vulkan physical device using deterministic Stage 0 policy.
///
/// # Errors
///
/// Returns [`VulkanCapabilityError`] when no candidate satisfies the minimum
/// API version, queue, swapchain-extension and surface-format requirements.
pub fn select_physical_device(
devices: &[VulkanPhysicalDeviceRecord],
) -> Result<VulkanCapabilityReport, VulkanCapabilityError> {
select_physical_device_for_request(devices, RenderRequest::conservative())
}
/// Selects a Vulkan physical device for a specific Stage 0 render request.
///
/// # Errors
///
/// Returns [`VulkanCapabilityError`] when no candidate satisfies the minimum
/// API version, queue, swapchain-extension, surface-format or depth/stencil
/// requirements for the requested profile.
pub fn select_physical_device_for_request(
devices: &[VulkanPhysicalDeviceRecord],
render_request: RenderRequest,
) -> Result<VulkanCapabilityReport, VulkanCapabilityError> {
if devices.is_empty() {
return Err(VulkanCapabilityError::NoPhysicalDevice);
}
let mut best = None;
let mut rejected_devices = Vec::new();
let mut last_error = None;
for device in devices {
let report = match validate_device_for_request(device, render_request) {
Ok(report) => report,
Err(err) => {
rejected_devices.push(rejected_device_report(device, &err));
last_error = Some(err);
continue;
}
};
match &best {
Some(existing) if compare_reports(&report, existing) != std::cmp::Ordering::Greater => {
}
_ => best = Some(report),
}
}
let mut best =
best.ok_or_else(|| last_error.unwrap_or(VulkanCapabilityError::NoPhysicalDevice))?;
best.rejected_devices = rejected_devices;
Ok(best)
}
/// Builds a deterministic swapchain plan from surface capabilities.
///
/// # Errors
///
/// Returns [`VulkanSwapchainError`] when formats, present modes or extent are
/// unusable.
pub fn plan_vulkan_swapchain(
request: &VulkanSwapchainRequest,
) -> Result<VulkanSwapchainPlan, VulkanSwapchainError> {
let format = select_surface_format(&request.formats)?;
let present_mode = select_present_mode(&request.present_modes, request.preferred_present_mode)?;
let extent = select_swapchain_extent(request)?;
if extent.0 == 0 || extent.1 == 0 {
return Err(VulkanSwapchainError::EmptyExtent);
}
Ok(VulkanSwapchainPlan {
schema: 1,
extent,
format,
present_mode,
image_count: select_image_count(request.capabilities),
image_usage: select_swapchain_image_usage(request.capabilities),
})
}
/// Builds a deterministic swapchain recreation report.
#[must_use]
pub const fn swapchain_recreation_report(
reason: VulkanSwapchainRecreationReason,
previous_extent: (u32, u32),
next_extent: (u32, u32),
) -> VulkanSwapchainRecreationReport {
VulkanSwapchainRecreationReport {
schema: 1,
reason,
previous_extent,
next_extent,
}
}
/// Builds a deterministic frame submission plan for a validated command list.
///
/// Stage 0 keeps this as a pure planning boundary so command-pool, command-buffer
/// and synchronization policy can be tested without requiring a native surface.
///
/// # Errors
///
/// Returns [`RenderError`] when the command list has invalid frame framing,
/// ordering, draw ranges, mesh bounds, or non-finite transforms.
pub fn plan_vulkan_frame_submission(
swapchain: &VulkanSwapchainPlan,
commands: &RenderCommandList,
) -> Result<VulkanFrameSubmissionPlan, RenderError> {
validate_command_list(commands)?;
let mut draw_count = 0_u32;
let mut indexed_vertex_count = 0_u32;
for command in &commands.commands {
if let RenderCommand::Draw(draw) = command {
draw_count = draw_count.saturating_add(1);
indexed_vertex_count = indexed_vertex_count.saturating_add(draw.range.count);
}
}
Ok(VulkanFrameSubmissionPlan {
schema: 1,
frames_in_flight: swapchain.image_count.clamp(1, 2),
command_buffers: swapchain.image_count,
semaphores_per_frame: 2,
fences_per_frame: 1,
draw_count,
indexed_vertex_count,
})
}
/// Renders a deterministic JSON capability report.
#[must_use]
pub fn render_capability_report_json(report: &VulkanCapabilityReport) -> String {
#[derive(Serialize)]
struct CapabilityReportJson<'a> {
schema: u32,
vulkan_api: String,
device_name: &'a str,
score: i32,
graphics_queue_family: u32,
present_queue_family: u32,
portability_subset: bool,
enabled_extensions: &'a [String],
informational_capabilities: &'a VulkanInformationalCapabilities,
rejected_devices: &'a [VulkanRejectedDeviceReport],
}
serialize_json_or_fallback(
&CapabilityReportJson {
schema: report.schema,
vulkan_api: format_api_version(report.vulkan_api_version),
device_name: &report.device_name,
score: report.score,
graphics_queue_family: report.graphics_queue_family,
present_queue_family: report.present_queue_family,
portability_subset: report.portability_subset,
enabled_extensions: &report.enabled_extensions,
informational_capabilities: &report.informational_capabilities,
rejected_devices: &report.rejected_devices,
},
"{\"schema\":0,\"vulkan_api\":\"0.0.0\",\"device_name\":\"unknown\",\"score\":0,\"graphics_queue_family\":0,\"present_queue_family\":0,\"portability_subset\":false,\"enabled_extensions\":[],\"informational_capabilities\":{\"sampled_color_formats\":[],\"sampled_depth_formats\":[],\"limits\":{\"max_image_dimension_2d\":0,\"max_sampler_allocation_count\":0,\"max_per_stage_descriptor_samplers\":0,\"max_bound_descriptor_sets\":0}},\"rejected_devices\":[]}",
)
}
/// Renders a deterministic JSON swapchain plan.
#[must_use]
pub fn render_swapchain_plan_json(plan: &VulkanSwapchainPlan) -> String {
#[derive(Serialize)]
struct SwapchainPlanJson {
schema: u32,
extent: [u32; 2],
format: i32,
color_space: i32,
present_mode: i32,
image_count: u32,
image_usage: u32,
}
serialize_json_or_fallback(
&SwapchainPlanJson {
schema: plan.schema,
extent: [plan.extent.0, plan.extent.1],
format: plan.format.format,
color_space: plan.format.color_space,
present_mode: plan.present_mode,
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,\"image_usage\":0}",
)
}
/// Renders a deterministic JSON swapchain recreation report.
#[must_use]
pub fn render_swapchain_recreation_report_json(report: &VulkanSwapchainRecreationReport) -> String {
#[derive(Serialize)]
struct SwapchainRecreationReportJson<'a> {
schema: u32,
reason: &'a str,
previous_extent: [u32; 2],
next_extent: [u32; 2],
}
serialize_json_or_fallback(
&SwapchainRecreationReportJson {
schema: report.schema,
reason: match report.reason {
VulkanSwapchainRecreationReason::Resize => "resize",
VulkanSwapchainRecreationReason::OutOfDate => "out_of_date",
VulkanSwapchainRecreationReason::Suboptimal => "suboptimal",
},
previous_extent: [report.previous_extent.0, report.previous_extent.1],
next_extent: [report.next_extent.0, report.next_extent.1],
},
"{\"schema\":0,\"reason\":\"unknown\",\"previous_extent\":[0,0],\"next_extent\":[0,0]}",
)
}
/// Renders a deterministic JSON frame submission plan.
#[must_use]
pub fn render_frame_submission_plan_json(plan: &VulkanFrameSubmissionPlan) -> String {
serialize_json_or_fallback(
plan,
"{\"schema\":0,\"frames_in_flight\":0,\"command_buffers\":0,\"semaphores_per_frame\":0,\"fences_per_frame\":0,\"draw_count\":0,\"indexed_vertex_count\":0}",
)
}
pub(crate) fn select_composite_alpha(
supported: vk::CompositeAlphaFlagsKHR,
) -> vk::CompositeAlphaFlagsKHR {
if supported.contains(vk::CompositeAlphaFlagsKHR::OPAQUE) {
vk::CompositeAlphaFlagsKHR::OPAQUE
} else if supported.contains(vk::CompositeAlphaFlagsKHR::PRE_MULTIPLIED) {
vk::CompositeAlphaFlagsKHR::PRE_MULTIPLIED
} else if supported.contains(vk::CompositeAlphaFlagsKHR::POST_MULTIPLIED) {
vk::CompositeAlphaFlagsKHR::POST_MULTIPLIED
} else {
vk::CompositeAlphaFlagsKHR::INHERIT
}
}
pub(crate) fn serialize_json_or_fallback<T: Serialize>(value: &T, fallback: &str) -> String {
match serde_json::to_string(value) {
Ok(json) => json,
Err(_) => fallback.to_string(),
}
}
pub(crate) fn format_api_version(version: u32) -> String {
format!(
"{}.{}.{}",
vk::api_version_major(version),
vk::api_version_minor(version),
vk::api_version_patch(version)
)
}
fn select_surface_format(
formats: &[VulkanSurfaceFormat],
) -> Result<VulkanSurfaceFormat, VulkanSwapchainError> {
if let Some(format) = undefined_surface_format_override(formats) {
return Ok(format);
}
formats
.iter()
.copied()
.find(|format| {
format.format == vk::Format::B8G8R8A8_SRGB.as_raw()
&& format.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw()
})
.or_else(|| formats.first().copied())
.ok_or(VulkanSwapchainError::MissingSurfaceFormat)
}
fn undefined_surface_format_override(
formats: &[VulkanSurfaceFormat],
) -> Option<VulkanSurfaceFormat> {
match formats {
[format] if format.format == vk::Format::UNDEFINED.as_raw() => Some(VulkanSurfaceFormat {
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
color_space: format.color_space,
}),
_ => None,
}
}
fn select_present_mode(present_modes: &[i32], preferred: i32) -> Result<i32, VulkanSwapchainError> {
if present_modes.contains(&preferred) {
Ok(preferred)
} else if present_modes.contains(&vk::PresentModeKHR::FIFO.as_raw()) {
Ok(vk::PresentModeKHR::FIFO.as_raw())
} else {
present_modes
.first()
.copied()
.ok_or(VulkanSwapchainError::MissingPresentMode)
}
}
fn select_swapchain_extent(
request: &VulkanSwapchainRequest,
) -> Result<(u32, u32), VulkanSwapchainError> {
if let Some(extent) = request.capabilities.current_extent {
return if extent.0 == 0 || extent.1 == 0 {
Err(VulkanSwapchainError::EmptyExtent)
} else {
Ok(extent)
};
}
let width = request.drawable_extent.0.clamp(
request.capabilities.min_extent.0,
request.capabilities.max_extent.0,
);
let height = request.drawable_extent.1.clamp(
request.capabilities.min_extent.1,
request.capabilities.max_extent.1,
);
Ok((width, height))
}
fn select_image_count(capabilities: VulkanSwapchainSurfaceCapabilities) -> u32 {
let requested = capabilities.min_image_count.saturating_add(1).max(2);
if capabilities.max_image_count == 0 {
requested
} else {
requested.min(capabilities.max_image_count)
}
}
pub(crate) fn validate_device_for_request(
device: &VulkanPhysicalDeviceRecord,
render_request: RenderRequest,
) -> Result<VulkanCapabilityReport, VulkanCapabilityError> {
if device.api_version < MIN_VULKAN_API_VERSION {
return Err(VulkanCapabilityError::ApiVersionTooLow {
required: MIN_VULKAN_API_VERSION,
found: device.api_version,
});
}
if !device.supports_extension(KHR_SWAPCHAIN_EXTENSION) {
return Err(VulkanCapabilityError::MissingSwapchainExtension {
device: device.name.clone(),
});
}
if !supports_surface_formats(device) {
return Err(VulkanCapabilityError::MissingSurfaceFormat {
device: device.name.clone(),
});
}
if device.present_modes.is_empty() {
return Err(VulkanCapabilityError::MissingPresentMode {
device: device.name.clone(),
});
}
if !supports_color_attachment_usage(device.surface_capabilities) {
return Err(VulkanCapabilityError::MissingColorAttachmentUsage {
device: device.name.clone(),
});
}
if !supports_depth_stencil_request(device, render_request.depth) {
return Err(VulkanCapabilityError::MissingDepthStencilFormat {
device: device.name.clone(),
requested: render_request.depth,
});
}
let (graphics_queue_family, present_queue_family) = select_queue_families(device)?;
let portability_subset = device.supports_extension(KHR_PORTABILITY_SUBSET_EXTENSION);
let mut enabled_extensions = vec![KHR_SWAPCHAIN_EXTENSION.to_string()];
if portability_subset {
enabled_extensions.push(KHR_PORTABILITY_SUBSET_EXTENSION.to_string());
}
Ok(VulkanCapabilityReport {
schema: 1,
device_name: device.name.clone(),
vulkan_api_version: device.api_version,
score: score_device(device, graphics_queue_family, present_queue_family),
graphics_queue_family,
present_queue_family,
portability_subset,
enabled_extensions,
informational_capabilities: informational_capabilities(device),
rejected_devices: Vec::new(),
})
}
fn rejected_device_report(
device: &VulkanPhysicalDeviceRecord,
error: &VulkanCapabilityError,
) -> VulkanRejectedDeviceReport {
VulkanRejectedDeviceReport {
device_name: device.name.clone(),
reason_code: capability_error_code(error),
reason: error.to_string(),
}
}
const fn capability_error_code(error: &VulkanCapabilityError) -> &'static str {
match error {
VulkanCapabilityError::NoPhysicalDevice => "no_physical_device",
VulkanCapabilityError::ApiVersionTooLow { .. } => "api_version_too_low",
VulkanCapabilityError::NoGraphicsQueue { .. } => "no_graphics_queue",
VulkanCapabilityError::NoPresentQueue { .. } => "no_present_queue",
VulkanCapabilityError::MissingSwapchainExtension { .. } => "missing_swapchain_extension",
VulkanCapabilityError::MissingSurfaceFormat { .. } => "missing_surface_format",
VulkanCapabilityError::MissingPresentMode { .. } => "missing_present_mode",
VulkanCapabilityError::MissingColorAttachmentUsage { .. } => {
"missing_color_attachment_usage"
}
VulkanCapabilityError::MissingDepthStencilFormat { .. } => "missing_depth_stencil_format",
}
}
fn select_queue_families(
device: &VulkanPhysicalDeviceRecord,
) -> Result<(u32, u32), VulkanCapabilityError> {
if let Some(unified) = device
.queue_families
.iter()
.filter(|family| family.graphics && family.present)
.min_by_key(|family| family.index)
{
return Ok((unified.index, unified.index));
}
let graphics_queue_family = device
.queue_families
.iter()
.filter(|family| family.graphics)
.min_by_key(|family| family.index)
.ok_or_else(|| VulkanCapabilityError::NoGraphicsQueue {
device: device.name.clone(),
})?
.index;
let present_queue_family = device
.queue_families
.iter()
.filter(|family| family.present)
.min_by_key(|family| family.index)
.ok_or_else(|| VulkanCapabilityError::NoPresentQueue {
device: device.name.clone(),
})?
.index;
Ok((graphics_queue_family, present_queue_family))
}
fn supports_surface_formats(device: &VulkanPhysicalDeviceRecord) -> bool {
!device.surface_formats.is_empty()
}
fn supports_color_attachment_usage(capabilities: VulkanSwapchainSurfaceCapabilities) -> bool {
capabilities.supported_usage_flags & vk::ImageUsageFlags::COLOR_ATTACHMENT.as_raw() != 0
}
fn supports_depth_stencil_request(
device: &VulkanPhysicalDeviceRecord,
depth: DepthStencilSupport,
) -> bool {
if depth.depth_bits == 0 && depth.stencil_bits == 0 {
return true;
}
select_depth_stencil_attachment_format(&device.supported_depth_stencil_formats, depth).is_some()
}
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(
device: &VulkanPhysicalDeviceRecord,
) -> VulkanInformationalCapabilities {
let (sampled_depth_formats, sampled_color_formats): (Vec<_>, Vec<_>) = device
.sampled_image_formats
.iter()
.copied()
.partition(|format| is_depth_stencil_format(*format));
VulkanInformationalCapabilities {
sampled_color_formats,
sampled_depth_formats,
limits: device.limits,
}
}
fn required_depth_stencil_formats(depth: DepthStencilSupport) -> &'static [vk::Format] {
match (depth.depth_bits, depth.stencil_bits) {
(16, 0) => &[vk::Format::D16_UNORM, vk::Format::D32_SFLOAT],
(24, 0) => &[vk::Format::X8_D24_UNORM_PACK32, vk::Format::D32_SFLOAT],
(32, 0) => &[vk::Format::D32_SFLOAT],
(16, 8) => &[vk::Format::D16_UNORM_S8_UINT, vk::Format::D24_UNORM_S8_UINT],
(24, 8) => &[
vk::Format::D24_UNORM_S8_UINT,
vk::Format::D32_SFLOAT_S8_UINT,
],
(32, 8) => &[vk::Format::D32_SFLOAT_S8_UINT],
_ => &[],
}
}
fn is_depth_stencil_format(format: i32) -> bool {
matches!(
vk::Format::from_raw(format),
vk::Format::D16_UNORM
| vk::Format::X8_D24_UNORM_PACK32
| vk::Format::D32_SFLOAT
| vk::Format::S8_UINT
| vk::Format::D16_UNORM_S8_UINT
| vk::Format::D24_UNORM_S8_UINT
| vk::Format::D32_SFLOAT_S8_UINT
)
}
fn score_device(
device: &VulkanPhysicalDeviceRecord,
graphics_queue_family: u32,
present_queue_family: u32,
) -> i32 {
let unified_queue_bonus = if graphics_queue_family == present_queue_family {
100
} else {
0
};
let portability_penalty = if device.supports_extension(KHR_PORTABILITY_SUBSET_EXTENSION) {
-50
} else {
0
};
device.device_type.score_bonus()
+ unified_queue_bonus
+ portability_penalty
+ i32::try_from(device.surface_formats.len()).unwrap_or(i32::MAX)
}
pub(crate) fn compare_reports(
left: &VulkanCapabilityReport,
right: &VulkanCapabilityReport,
) -> std::cmp::Ordering {
left.score
.cmp(&right.score)
.then_with(|| right.device_name.cmp(&left.device_name))
}
@@ -0,0 +1,399 @@
use fparkan_binary::{sha256, sha256_hex};
use serde::Serialize;
pub(crate) use crate::ffi::{
SPIRV_MAGIC, SPIRV_VERSION_1_0, TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS,
};
use crate::policy::serialize_json_or_fallback;
pub(crate) const SHADER_MANIFEST_SCHEMA: u32 = 2;
pub(crate) const SHADER_TARGET_ENV: &str = "vulkan1.1";
pub(crate) const SHADER_COMPILER_NAME: &str = "glslangValidator";
pub(crate) const SHADER_COMPILER_VERSION: &str = "11:16.3.0";
pub(crate) const SHADER_COMPILER_BINARY_SHA256: &str =
"9bcd69d830b350aaa6e2254915ff74e46070e217b67f38daad27c1fc1f22910f";
pub(crate) const SPIRV_VALIDATOR_NAME: &str = "spirv-val";
pub(crate) const SPIRV_VALIDATOR_VERSION: &str =
"SPIRV-Tools v2026.2 unknown hash, 2026-04-29T17:02:58+00:00";
pub(crate) const SPIRV_VALIDATOR_BINARY_SHA256: &str =
"f6d5b96ff19f073f3af0c0bcfa0c18702d288d3ec598efc242d01cd104d8354f";
pub(crate) const TRIANGLE_VERTEX_SOURCE_PATH: &str =
"adapters/fparkan-render-vulkan/shaders/triangle.vert";
pub(crate) const TRIANGLE_VERTEX_SOURCE_SHA256: &str =
"fe3721202477220d2d9677b6572d7f3baecb374e94f04eea9d7286ed5e98b6c9";
pub(crate) const TRIANGLE_VERTEX_SPIRV_PATH: &str =
"adapters/fparkan-render-vulkan/shaders/triangle.vert.spv";
pub(crate) const TRIANGLE_VERTEX_COMPILE_COMMAND: &str =
"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";
pub(crate) const TRIANGLE_VERTEX_VALIDATE_COMMAND: &str =
"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.vert.spv";
const TRIANGLE_FRAGMENT_SOURCE_PATH: &str = "adapters/fparkan-render-vulkan/shaders/triangle.frag";
const TRIANGLE_FRAGMENT_SOURCE_SHA256: &str =
"fb3ed435af48e4fb4a817f160b467f7a561c7c547c87b6f0c1e89f5f58af7329";
const TRIANGLE_FRAGMENT_SPIRV_PATH: &str =
"adapters/fparkan-render-vulkan/shaders/triangle.frag.spv";
const TRIANGLE_FRAGMENT_COMPILE_COMMAND: &str =
"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";
const TRIANGLE_FRAGMENT_VALIDATE_COMMAND: &str =
"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.frag.spv";
fn shader_compiler_name() -> &'static str {
option_env!("FPARKAN_BUILD_SHADER_COMPILER_NAME").unwrap_or(SHADER_COMPILER_NAME)
}
fn shader_compiler_version() -> &'static str {
option_env!("FPARKAN_BUILD_SHADER_COMPILER_VERSION").unwrap_or(SHADER_COMPILER_VERSION)
}
fn shader_compiler_binary_sha256() -> &'static str {
option_env!("FPARKAN_BUILD_SHADER_COMPILER_SHA256").unwrap_or(SHADER_COMPILER_BINARY_SHA256)
}
fn spirv_validator_name() -> &'static str {
option_env!("FPARKAN_BUILD_SPIRV_VALIDATOR_NAME").unwrap_or(SPIRV_VALIDATOR_NAME)
}
fn spirv_validator_version() -> &'static str {
option_env!("FPARKAN_BUILD_SPIRV_VALIDATOR_VERSION").unwrap_or(SPIRV_VALIDATOR_VERSION)
}
fn spirv_validator_binary_sha256() -> &'static str {
option_env!("FPARKAN_BUILD_SPIRV_VALIDATOR_SHA256").unwrap_or(SPIRV_VALIDATOR_BINARY_SHA256)
}
/// Shader tool metadata pinned in the Stage 0 manifest.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct VulkanShaderToolManifest {
/// Tool executable name.
pub name: &'static str,
/// Tool version string.
pub version: &'static str,
/// Tool binary SHA-256.
pub binary_sha256: &'static str,
}
/// Vulkan shader stage.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum VulkanShaderStage {
/// Vertex stage.
Vertex,
/// Fragment stage.
Fragment,
}
/// Offline SPIR-V shader manifest entry.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanShaderModuleManifest {
/// Logical shader name.
pub name: &'static str,
/// Shader stage.
pub stage: VulkanShaderStage,
/// SPIR-V entry point.
pub entry_point: &'static str,
/// Descriptor set count.
pub descriptor_sets: u32,
/// Push constant byte count.
pub push_constant_bytes: u32,
/// Checked-in GLSL source path.
pub source_path: &'static str,
/// Checked-in GLSL source SHA-256.
pub source_sha256: &'static str,
/// Checked-in SPIR-V module path.
pub spirv_path: &'static str,
/// Exact offline compile command used for the checked-in SPIR-V artifact.
pub compile_command: &'static str,
/// Exact offline validation command used for the checked-in SPIR-V artifact.
pub validate_command: &'static str,
/// SPIR-V words.
pub words: &'static [u32],
}
/// Shader manifest validation report.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanShaderManifestReport {
/// Report schema version.
pub schema: u32,
/// Explicit Vulkan target environment for the checked-in SPIR-V.
pub target_env: &'static str,
/// Pinned compiler metadata.
pub compiler: VulkanShaderToolManifest,
/// Pinned validator metadata.
pub validator: VulkanShaderToolManifest,
/// Shader module reports.
pub modules: Vec<VulkanShaderModuleReport>,
/// Hash of the normalized shader manifest.
pub manifest_hash: String,
}
/// Shader module validation report.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct VulkanShaderModuleReport {
/// Logical shader name.
pub name: &'static str,
/// Shader stage.
pub stage: VulkanShaderStage,
/// SPIR-V entry point.
pub entry_point: &'static str,
/// Checked-in GLSL source path.
pub source_path: &'static str,
/// Checked-in GLSL source SHA-256.
pub source_sha256: &'static str,
/// Checked-in SPIR-V module path.
pub spirv_path: &'static str,
/// SPIR-V word count.
pub word_count: usize,
/// SPIR-V byte hash.
pub sha256: String,
/// Descriptor set count.
pub descriptor_sets: u32,
/// Push constant byte count.
pub push_constant_bytes: u32,
/// Exact offline compile command used for the checked-in SPIR-V artifact.
pub compile_command: &'static str,
/// Exact offline validation command used for the checked-in SPIR-V artifact.
pub validate_command: &'static str,
/// Stable hash of the reflected interface contract for this module.
pub interface_hash: String,
}
/// Shader manifest validation error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VulkanShaderManifestError {
/// SPIR-V module is too short to contain a header.
TooShort {
/// Shader name.
name: &'static str,
},
/// SPIR-V module has an invalid magic word.
InvalidMagic {
/// Shader name.
name: &'static str,
/// Found magic word.
found: u32,
},
/// SPIR-V module version is below 1.0.
UnsupportedVersion {
/// Shader name.
name: &'static str,
/// Found version word.
found: u32,
},
/// SPIR-V module declares an invalid bound.
InvalidBound {
/// Shader name.
name: &'static str,
},
}
impl std::fmt::Display for VulkanShaderManifestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TooShort { name } => write!(f, "shader {name} SPIR-V module is too short"),
Self::InvalidMagic { name, found } => {
write!(f, "shader {name} has invalid SPIR-V magic 0x{found:08x}")
}
Self::UnsupportedVersion { name, found } => write!(
f,
"shader {name} has unsupported SPIR-V version 0x{found:08x}"
),
Self::InvalidBound { name } => write!(f, "shader {name} has invalid SPIR-V bound"),
}
}
}
impl std::error::Error for VulkanShaderManifestError {}
/// Returns the built-in Stage 0 indexed-triangle shader manifest.
#[must_use]
pub fn triangle_shader_manifest() -> Vec<VulkanShaderModuleManifest> {
vec![
VulkanShaderModuleManifest {
name: "triangle.vert",
stage: VulkanShaderStage::Vertex,
entry_point: "main",
descriptor_sets: 0,
push_constant_bytes: 64,
source_path: TRIANGLE_VERTEX_SOURCE_PATH,
source_sha256: TRIANGLE_VERTEX_SOURCE_SHA256,
spirv_path: TRIANGLE_VERTEX_SPIRV_PATH,
compile_command: TRIANGLE_VERTEX_COMPILE_COMMAND,
validate_command: TRIANGLE_VERTEX_VALIDATE_COMMAND,
words: TRIANGLE_VERTEX_SHADER_WORDS,
},
VulkanShaderModuleManifest {
name: "triangle.frag",
stage: VulkanShaderStage::Fragment,
entry_point: "main",
descriptor_sets: 1,
push_constant_bytes: 68,
source_path: TRIANGLE_FRAGMENT_SOURCE_PATH,
source_sha256: TRIANGLE_FRAGMENT_SOURCE_SHA256,
spirv_path: TRIANGLE_FRAGMENT_SPIRV_PATH,
compile_command: TRIANGLE_FRAGMENT_COMPILE_COMMAND,
validate_command: TRIANGLE_FRAGMENT_VALIDATE_COMMAND,
words: TRIANGLE_FRAGMENT_SHADER_WORDS,
},
]
}
/// Validates shader SPIR-V containers and renders a deterministic report.
///
/// # Errors
///
/// Returns [`VulkanShaderManifestError`] when a module fails Stage 0 SPIR-V
/// container validation.
pub fn validate_shader_manifest(
modules: &[VulkanShaderModuleManifest],
) -> Result<VulkanShaderManifestReport, VulkanShaderManifestError> {
let mut reports = Vec::with_capacity(modules.len());
for module in modules {
validate_spirv_container(module)?;
let bytes = spirv_words_to_bytes(module.words);
reports.push(VulkanShaderModuleReport {
name: module.name,
stage: module.stage,
entry_point: module.entry_point,
source_path: module.source_path,
source_sha256: module.source_sha256,
spirv_path: module.spirv_path,
word_count: module.words.len(),
sha256: sha256_hex(&sha256(&bytes)),
descriptor_sets: module.descriptor_sets,
push_constant_bytes: module.push_constant_bytes,
compile_command: module.compile_command,
validate_command: module.validate_command,
interface_hash: shader_interface_hash(module),
});
}
let normalized = render_shader_manifest_without_hash_json(&reports);
Ok(VulkanShaderManifestReport {
schema: SHADER_MANIFEST_SCHEMA,
target_env: SHADER_TARGET_ENV,
compiler: VulkanShaderToolManifest {
name: shader_compiler_name(),
version: shader_compiler_version(),
binary_sha256: shader_compiler_binary_sha256(),
},
validator: VulkanShaderToolManifest {
name: spirv_validator_name(),
version: spirv_validator_version(),
binary_sha256: spirv_validator_binary_sha256(),
},
modules: reports,
manifest_hash: sha256_hex(&sha256(normalized.as_bytes())),
})
}
/// Renders a deterministic JSON shader manifest report.
#[must_use]
pub fn render_shader_manifest_report_json(report: &VulkanShaderManifestReport) -> String {
#[derive(Serialize)]
struct ShaderManifestReportJson<'a> {
schema: u32,
target_env: &'a str,
compiler: &'a VulkanShaderToolManifest,
validator: &'a VulkanShaderToolManifest,
modules: &'a [VulkanShaderModuleReport],
manifest_hash: &'a str,
}
serialize_json_or_fallback(
&ShaderManifestReportJson {
schema: report.schema,
target_env: report.target_env,
compiler: &report.compiler,
validator: &report.validator,
modules: &report.modules,
manifest_hash: &report.manifest_hash,
},
"{\"schema\":0,\"target_env\":\"unknown\",\"compiler\":{\"name\":\"unknown\",\"version\":\"unknown\",\"binary_sha256\":\"unknown\"},\"validator\":{\"name\":\"unknown\",\"version\":\"unknown\",\"binary_sha256\":\"unknown\"},\"modules\":[],\"manifest_hash\":\"unknown\"}",
)
}
fn shader_interface_hash(module: &VulkanShaderModuleManifest) -> String {
#[derive(Serialize)]
struct ShaderInterfaceHashJson<'a> {
stage: VulkanShaderStage,
entry_point: &'a str,
descriptor_sets: u32,
push_constant_bytes: u32,
}
let normalized = serialize_json_or_fallback(
&ShaderInterfaceHashJson {
stage: module.stage,
entry_point: module.entry_point,
descriptor_sets: module.descriptor_sets,
push_constant_bytes: module.push_constant_bytes,
},
"{\"stage\":\"vertex\",\"entry_point\":\"main\",\"descriptor_sets\":0,\"push_constant_bytes\":0}",
);
sha256_hex(&sha256(normalized.as_bytes()))
}
fn validate_spirv_container(
module: &VulkanShaderModuleManifest,
) -> Result<(), VulkanShaderManifestError> {
if module.words.len() < 5 {
return Err(VulkanShaderManifestError::TooShort { name: module.name });
}
if module.words[0] != SPIRV_MAGIC {
return Err(VulkanShaderManifestError::InvalidMagic {
name: module.name,
found: module.words[0],
});
}
if module.words[1] < SPIRV_VERSION_1_0 {
return Err(VulkanShaderManifestError::UnsupportedVersion {
name: module.name,
found: module.words[1],
});
}
if module.words[3] == 0 {
return Err(VulkanShaderManifestError::InvalidBound { name: module.name });
}
Ok(())
}
fn spirv_words_to_bytes(words: &[u32]) -> Vec<u8> {
let mut out = Vec::with_capacity(words.len() * 4);
for word in words {
out.extend_from_slice(&word.to_le_bytes());
}
out
}
fn render_shader_manifest_without_hash_json(modules: &[VulkanShaderModuleReport]) -> String {
#[derive(Serialize)]
struct ShaderManifestWithoutHashJson<'a> {
schema: u32,
target_env: &'a str,
compiler: VulkanShaderToolManifest,
validator: VulkanShaderToolManifest,
modules: &'a [VulkanShaderModuleReport],
}
let json = serialize_json_or_fallback(
&ShaderManifestWithoutHashJson {
schema: SHADER_MANIFEST_SCHEMA,
target_env: SHADER_TARGET_ENV,
compiler: VulkanShaderToolManifest {
name: SHADER_COMPILER_NAME,
version: SHADER_COMPILER_VERSION,
binary_sha256: SHADER_COMPILER_BINARY_SHA256,
},
validator: VulkanShaderToolManifest {
name: SPIRV_VALIDATOR_NAME,
version: SPIRV_VALIDATOR_VERSION,
binary_sha256: SPIRV_VALIDATOR_BINARY_SHA256,
},
modules,
},
"{\"schema\":0,\"target_env\":\"unknown\",\"compiler\":{\"name\":\"unknown\",\"version\":\"unknown\",\"binary_sha256\":\"unknown\"},\"validator\":{\"name\":\"unknown\",\"version\":\"unknown\",\"binary_sha256\":\"unknown\"},\"modules\":[]}",
);
match json.strip_suffix('}') {
Some(stripped) => stripped.to_string(),
None => json,
}
}
+22 -1
View File
@@ -2,4 +2,25 @@
Status: provisional
Workspace-owned code forbids `unsafe`. SDL/OpenGL adapters must use maintained external crates behind a safe project API. The current repository still contains legacy demo code scheduled for replacement; new adapter crates are placeholders until a fully audited safe facade is selected and proven on all target profiles.
Workspace-owned code forbids `unsafe`. SDL/OpenGL adapters must use maintained
external crates behind a safe project API; local Objective-C/CGL/SDL/OpenGL FFI
inside FParkan is not an acceptable implementation strategy.
The current adapter crates are safe boundary stubs. They compile the intended
ports and deterministic command contracts, but they do not create SDL windows,
GL contexts, GPU resources, shaders, draw calls, swapchains, or presents. They
must not be treated as backend readiness evidence.
To close the macOS backend requirement, choose and vendor/lock a maintained
safe facade stack, then implement:
- SDL event source, window creation, GL context lifecycle, drawable size and
present;
- GL shader compile/link, buffer/texture upload, render state, draw calls and
diagnostics;
- game/viewer composition roots using those adapters;
- hidden-window/offscreen macOS smoke tests and licensed local model/terrain
frame captures.
Until those are implemented, Desktop GL evidence may document external probes
only; it does not satisfy the permanent adapter requirement.
+11 -7
View File
@@ -6,13 +6,17 @@ license.workspace = true
repository.workspace = true
[dependencies]
fparkan-corpus = { path = "../../crates/fparkan-corpus" }
fparkan-nres = { path = "../../crates/fparkan-nres" }
fparkan-prototype = { path = "../../crates/fparkan-prototype" }
fparkan-resource = { path = "../../crates/fparkan-resource" }
fparkan-rsli = { path = "../../crates/fparkan-rsli" }
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
fparkan-assets = { path = "../../crates/fparkan-assets", version = "0.1.0" }
fparkan-corpus = { path = "../../crates/fparkan-corpus", version = "0.1.0" }
fparkan-prototype = { path = "../../crates/fparkan-prototype", version = "0.1.0" }
fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
fparkan-path = { path = "../../crates/fparkan-path", version = "0.1.0" }
fparkan-resource = { path = "../../crates/fparkan-resource", version = "0.1.0" }
fparkan-runtime = { path = "../../crates/fparkan-runtime", version = "0.1.0" }
fparkan-script = { path = "../../crates/fparkan-script", version = "0.1.0" }
fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+14 -4
View File
@@ -6,10 +6,20 @@ license.workspace = true
repository.workspace = true
[dependencies]
fparkan-render = { path = "../../crates/fparkan-render" }
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
fparkan-world = { path = "../../crates/fparkan-world" }
fparkan-assets = { path = "../../crates/fparkan-assets", version = "0.1.0" }
fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
fparkan-path = { path = "../../crates/fparkan-path", version = "0.1.0" }
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" }
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit", version = "0.1.0" }
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan", version = "0.1.0" }
fparkan-runtime = { path = "../../crates/fparkan-runtime", version = "0.1.0" }
fparkan-terrain = { path = "../../crates/fparkan-terrain", version = "0.1.0" }
fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" }
fparkan-world = { path = "../../crates/fparkan-world", version = "0.1.0" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
[lints]
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
[dependencies]
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
fparkan-world = { path = "../../crates/fparkan-world" }
fparkan-runtime = { path = "../../crates/fparkan-runtime", version = "0.1.0" }
fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" }
fparkan-world = { path = "../../crates/fparkan-world", version = "0.1.0" }
[lints]
workspace = true
+158 -5
View File
@@ -1,12 +1,32 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
#![allow(clippy::print_stderr, clippy::print_stdout)]
//! `FParkan` headless runtime entrypoint.
use fparkan_runtime::{
create, load_mission, step_headless, EngineConfig, EngineMode, EngineServices, MissionRequest,
advance_reference_movement, create, load_mission, step_headless, EngineConfig, EngineMode,
EngineServices, MissionRequest,
};
use fparkan_vfs::DirectoryVfs;
use fparkan_world::InputSnapshot;
use fparkan_world::{InputSnapshot, OriginalObjectId};
use std::path::PathBuf;
use std::sync::Arc;
@@ -36,7 +56,7 @@ fn run() -> Result<(), String> {
let loaded = load_mission(&mut engine, MissionRequest { key: mission })
.map_err(|err| format!("{err}"))?;
println!(
"mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} graph_failures={}",
"mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} scripts={} script_events={} script_varset_declarations={} script_init_states={} script_varset_states={} graph_failures={}",
loaded.object_count,
loaded.areal_count,
loaded.surface_count,
@@ -46,9 +66,27 @@ fn run() -> Result<(), String> {
loaded.graph_material_resolved_count,
loaded.graph_texture_resolved_count,
loaded.graph_lightmap_resolved_count,
loaded.script_bundle_count,
loaded.script_event_count,
loaded.script_varset_declaration_count,
loaded.script_init_state_count,
loaded.script_varset_state_count,
loaded.graph_failure_count
);
}
if let Some(movement) = args.reference_movement {
let reached = advance_reference_movement(
&mut engine,
OriginalObjectId(movement.original_id),
movement.target_xy,
movement.max_step,
)
.map_err(|err| format!("{err}"))?;
println!(
"reference_movement original_id={} target_xy=[{},{}] max_step={} reached={reached}",
movement.original_id, movement.target_xy[0], movement.target_xy[1], movement.max_step,
);
}
let mut last = None;
for _ in 0..args.ticks {
last = Some(step_headless(&mut engine, InputSnapshot).map_err(|err| format!("{err}"))?);
@@ -62,10 +100,19 @@ fn run() -> Result<(), String> {
Ok(())
}
#[derive(Debug)]
struct Args {
root: Option<PathBuf>,
mission: Option<String>,
ticks: u64,
reference_movement: Option<ReferenceMovement>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct ReferenceMovement {
original_id: u32,
target_xy: [f32; 2],
max_step: f32,
}
impl Args {
@@ -74,6 +121,7 @@ impl Args {
root: None,
mission: None,
ticks: 1,
reference_movement: None,
};
let mut iter = args.iter();
while let Some(arg) = iter.next() {
@@ -99,16 +147,121 @@ impl Args {
.parse()
.map_err(|_| "--ticks must be an integer".to_string())?;
}
"--move-object" => {
if parsed.reference_movement.is_some() {
return Err("--move-object may be specified once".to_string());
}
let original_id = iter
.next()
.ok_or_else(|| "--move-object requires an original object id".to_string())?
.parse()
.map_err(|_| {
"--move-object object id must be an unsigned integer".to_string()
})?;
let x = parse_finite_argument(
iter.next(),
"--move-object requires a finite X target",
)?;
let y = parse_finite_argument(
iter.next(),
"--move-object requires a finite Y target",
)?;
let max_step = parse_finite_argument(
iter.next(),
"--move-object requires a finite positive maximum step",
)?;
if max_step <= 0.0 {
return Err("--move-object maximum step must be positive".to_string());
}
parsed.reference_movement = Some(ReferenceMovement {
original_id,
target_xy: [x, y],
max_step,
});
}
_ => return Err(usage()),
}
}
if parsed.mission.is_some() && parsed.root.is_none() {
return Err("--mission requires --root".to_string());
}
if parsed.reference_movement.is_some() && parsed.mission.is_none() {
return Err("--move-object requires --mission".to_string());
}
Ok(parsed)
}
}
fn usage() -> String {
"usage: fparkan-headless [--root <path> --mission <path>] [--ticks <n>]".to_string()
fn parse_finite_argument(value: Option<&String>, error: &str) -> Result<f32, String> {
let value: f32 = value
.ok_or_else(|| error.to_string())?
.parse()
.map_err(|_| error.to_string())?;
value
.is_finite()
.then_some(value)
.ok_or_else(|| error.to_string())
}
fn usage() -> String {
"usage: fparkan-headless [--root <path> --mission <path>] [--move-object <original-id> <x> <y> <max-step>] [--ticks <n>]".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn args(values: &[&str]) -> Vec<String> {
values.iter().map(ToString::to_string).collect()
}
#[test]
fn move_object_parses_a_single_finite_reference_command() {
let parsed = Args::parse(&args(&[
"--root",
"C:/game",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
"--move-object",
"7",
"12.5",
"-3",
"0.25",
"--ticks",
"2",
]))
.expect("args");
assert_eq!(parsed.ticks, 2);
assert_eq!(
parsed.reference_movement,
Some(ReferenceMovement {
original_id: 7,
target_xy: [12.5, -3.0],
max_step: 0.25,
})
);
}
#[test]
fn move_object_requires_a_loaded_mission_and_valid_step() {
assert_eq!(
Args::parse(&args(&["--move-object", "7", "1", "2", "1"])).expect_err("mission"),
"--move-object requires --mission"
);
assert_eq!(
Args::parse(&args(&[
"--root",
"C:/game",
"--mission",
"M/data.tma",
"--move-object",
"7",
"1",
"2",
"0",
]))
.expect_err("step"),
"--move-object maximum step must be positive"
);
}
}
+2 -8
View File
@@ -6,14 +6,8 @@ license.workspace = true
repository.workspace = true
[dependencies]
fparkan-msh = { path = "../../crates/fparkan-msh" }
fparkan-nres = { path = "../../crates/fparkan-nres" }
fparkan-resource = { path = "../../crates/fparkan-resource" }
fparkan-render = { path = "../../crates/fparkan-render" }
fparkan-rsli = { path = "../../crates/fparkan-rsli" }
fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format" }
fparkan-texm = { path = "../../crates/fparkan-texm" }
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" }
[lints]
workspace = true
+101 -104
View File
@@ -1,20 +1,36 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
#![allow(clippy::print_stderr, clippy::print_stdout)]
//! `FParkan` asset viewer composition root.
//! `FParkan` asset inspection composition root.
use fparkan_msh::{decode_msh, validate_msh};
use fparkan_nres::{decode as decode_nres, ReadProfile as NresReadProfile};
use fparkan_inspection::{
inspect_land_file, inspect_model_from_root, inspect_texture_from_root, ArchiveInspection,
LandFileKind, MapInspection, NresEntrySummary,
};
use fparkan_render::{
build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderPhase,
RenderProfile, RenderSnapshot, RenderSnapshotDraw,
};
use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
use fparkan_terrain_format::{decode_land_map, decode_land_msh};
use fparkan_texm::decode_texm;
use fparkan_vfs::DirectoryVfs;
use std::fmt::Write;
use std::path::PathBuf;
use std::sync::Arc;
fn main() {
let args = std::env::args().skip(1).collect::<Vec<_>>();
@@ -44,35 +60,27 @@ fn run(args: &[String]) -> Result<String, String> {
fn inspect_archive(args: &[String]) -> Result<String, String> {
let file = parse_file(args)?;
let limit = parse_limit(args)?;
let bytes = std::fs::read(&file).map_err(|err| format!("{}: {err}", file.display()))?;
if bytes.starts_with(b"NRes") {
let document = decode_nres(
Arc::from(bytes.into_boxed_slice()),
NresReadProfile::Compatible,
)
.map_err(|err| err.to_string())?;
let sample = render_nres_entries(&document, limit);
return Ok(format!(
"{{\"kind\":\"NRes\",\"path\":{},\"entries\":{},\"lookup_order_valid\":{},\"sample\":[{}]}}",
let inspection = fparkan_inspection::inspect_archive_file(&file, limit)?;
match inspection {
ArchiveInspection::Nres {
entries,
lookup_order_valid,
sample,
} => Ok(format!(
"{{\"report_kind\":\"archive-inspection\",\"kind\":\"NRes\",\"path\":{},\"entries\":{},\"lookup_order_valid\":{},\"sample\":[{}]}}",
json_string(&file.display().to_string()),
document.entries().len(),
document.lookup_order_valid(),
sample
));
}
if bytes.get(0..4) == Some(b"NL\0\x01") {
let document = fparkan_rsli::decode(
Arc::from(bytes.into_boxed_slice()),
fparkan_rsli::ReadProfile::Compatible,
)
.map_err(|err| err.to_string())?;
return Ok(format!(
"{{\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}",
entries,
lookup_order_valid,
render_nres_entries(&sample)
)),
ArchiveInspection::Rsli { entries } => Ok(format!(
"{{\"report_kind\":\"archive-inspection\",\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}",
json_string(&file.display().to_string()),
document.entries().len()
));
entries
)),
ArchiveInspection::Unsupported => Err(format!("{}: unsupported archive magic", file.display())),
}
Err(format!("{}: unsupported archive magic", file.display()))
}
fn inspect_model(args: &[String]) -> Result<String, String> {
@@ -81,21 +89,18 @@ fn inspect_model(args: &[String]) -> Result<String, String> {
}
let query = parse_resource_query(args)?;
let bytes = read_resource(&query)?;
let nested = decode_nres(bytes, NresReadProfile::Compatible).map_err(|err| err.to_string())?;
let document = decode_msh(&nested).map_err(|err| err.to_string())?;
let model = validate_msh(&document).map_err(|err| err.to_string())?;
let inspection = inspect_model_from_root(&query.root, &query.archive, &query.name)?;
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.name),
document.streams().len(),
model.node_count,
model.slots.len(),
model.positions.len(),
model.indices.len(),
model.batches.len()
inspection.streams,
inspection.nodes,
inspection.slots,
inspection.positions,
inspection.indices,
inspection.batches
))
}
@@ -117,6 +122,7 @@ impl ViewerModelService {
mesh: GpuMeshId(1),
material_slots: vec![GpuMaterialId(7)],
material_index: 0,
pipeline_state: fparkan_render::LegacyPipelineState::default(),
transform: identity_transform(),
range: IndexRange { start: 0, count: 3 },
stable_order: 0,
@@ -131,7 +137,7 @@ impl ViewerModelService {
.count();
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)
))
}
@@ -139,54 +145,58 @@ impl ViewerModelService {
fn inspect_texture(args: &[String]) -> Result<String, String> {
let query = parse_resource_query(args)?;
let document = decode_texm(read_resource(&query)?).map_err(|err| err.to_string())?;
let inspection = inspect_texture_from_root(&query.root, &query.archive, &query.name)?;
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.name),
document.width(),
document.height(),
json_string(&format!("{:?}", document.format())),
document.mip_count(),
document.page_rects().len()
inspection.width,
inspection.height,
json_string(&inspection.format),
inspection.mips,
inspection.pages
))
}
fn inspect_map(args: &[String]) -> Result<String, String> {
let file = parse_file(args)?;
let kind = parse_option(args, &["--kind"]).ok_or_else(|| "missing --kind".to_string())?;
let bytes = std::fs::read(&file).map_err(|err| format!("{}: {err}", file.display()))?;
let nres = decode_nres(
Arc::from(bytes.into_boxed_slice()),
NresReadProfile::Compatible,
)
.map_err(|err| err.to_string())?;
let inspection = inspect_land_file(
&file,
match kind.as_str() {
"land-msh" => LandFileKind::LandMsh,
"land-map" => LandFileKind::LandMap,
_ => return Err(format!("unknown map kind: {kind}")),
},
)?;
match kind.as_str() {
"land-msh" => {
let land = decode_land_msh(&nres).map_err(|err| err.to_string())?;
Ok(format!(
"{{\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}",
json_string(&file.display().to_string()),
land.streams.len(),
land.positions.len(),
land.faces.len(),
land.slots.slots_raw.len()
))
}
"land-map" => {
let land = decode_land_map(&nres).map_err(|err| err.to_string())?;
Ok(format!(
"{{\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}",
json_string(&file.display().to_string()),
land.areals.len(),
land.areal_count,
land.grid.cells_x,
land.grid.cells_y
))
}
_ => Err(format!("unknown map kind: {kind}")),
Ok(render_map_inspection_json(
&file.display().to_string(),
&kind,
&inspection,
))
}
fn render_map_inspection_json(path: &str, kind: &str, inspection: &MapInspection) -> String {
match kind {
"land-msh" => format!(
"{{\"report_kind\":\"map-inspection\",\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}",
json_string(path),
inspection.streams,
inspection.positions,
inspection.faces,
inspection.slots
),
"land-map" => format!(
"{{\"report_kind\":\"map-inspection\",\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}",
json_string(path),
inspection.areals,
inspection.declared_areals,
inspection.grid_width,
inspection.grid_height
),
_ => unreachable!("invalid land kind: {kind}"),
}
}
@@ -205,19 +215,6 @@ fn parse_resource_query(args: &[String]) -> Result<ResourceQuery, String> {
})
}
fn read_resource(query: &ResourceQuery) -> Result<Arc<[u8]>, String> {
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(&query.root)));
let archive = repository
.open_archive(&archive_path(query.archive.as_bytes()).map_err(|err| err.to_string())?)
.map_err(|err| err.to_string())?;
let entry = repository
.find(archive, &resource_name(query.name.as_bytes()))
.map_err(|err| err.to_string())?
.ok_or_else(|| format!("resource not found: {}/{}", query.archive, query.name))?;
let bytes = repository.read(entry).map_err(|err| err.to_string())?;
Ok(Arc::from(bytes.into_owned()))
}
fn parse_file(args: &[String]) -> Result<PathBuf, String> {
parse_path_option(args, &["--file"], "--file")
}
@@ -233,19 +230,19 @@ fn parse_limit(args: &[String]) -> Result<usize, String> {
.map(|value| value.unwrap_or(0))
}
fn render_nres_entries(document: &fparkan_nres::NresDocument, limit: usize) -> String {
fn render_nres_entries(entries: &[NresEntrySummary]) -> String {
let mut out = String::new();
for (index, entry) in document.entries().iter().take(limit).enumerate() {
for (index, entry) in entries.iter().enumerate() {
if index > 0 {
out.push(',');
}
let name = String::from_utf8_lossy(entry.name_bytes());
let name = &entry.name;
let _ = write!(
out,
"{{\"name\":{},\"type\":{},\"size\":{}}}",
json_string(&name),
entry.meta().type_id,
entry.meta().data_size
json_string(name),
entry.type_id,
entry.data_size
);
}
out
@@ -278,7 +275,7 @@ fn json_string(value: &str) -> String {
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => {
let _ = write!(out, "\\u{:04x}", u32::from(c));
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
@@ -346,7 +343,7 @@ mod tests {
fn model_fixture_uses_viewer_service_and_render_commands() -> Result<(), String> {
assert_eq!(
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(())
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "fparkan-vulkan-smoke"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
build = "build.rs"
[dependencies]
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit", version = "0.1.0" }
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan", version = "0.1.0" }
fparkan-inspection = { path = "../../crates/fparkan-inspection", 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]
workspace = true
+134
View File
@@ -0,0 +1,134 @@
//! Build-time provenance for native smoke artifacts.
use std::env;
use std::path::{Path, PathBuf};
use std::process::Command;
fn main() {
println!("cargo:rerun-if-env-changed=TARGET");
println!("cargo:rerun-if-env-changed=RUSTC");
println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN");
println!("cargo:rerun-if-env-changed=GITHUB_SHA");
println!("cargo:rerun-if-env-changed=SOURCE_VERSION");
println!("cargo:rerun-if-env-changed=BUILD_VCS_NUMBER");
if let Ok(target) = env::var("TARGET") {
println!("cargo:rustc-env=FPARKAN_BUILD_TARGET_TRIPLE={target}");
}
if let Some(toolchain) = rustc_release() {
println!("cargo:rustc-env=FPARKAN_BUILD_RUST_TOOLCHAIN={toolchain}");
}
if let Some(workspace_root) = workspace_root() {
if let Some(git_dir) = git_dir(&workspace_root) {
emit_git_rerun_hints(&git_dir);
}
if let Some(commit_sha) = env_commit_sha().or_else(|| git_head_commit_sha(&workspace_root))
{
println!("cargo:rustc-env=FPARKAN_BUILD_COMMIT_SHA={commit_sha}");
}
if let Some(git_dirty) = git_dirty(&workspace_root) {
println!("cargo:rustc-env=FPARKAN_BUILD_GIT_DIRTY={git_dirty}");
}
}
}
fn workspace_root() -> Option<PathBuf> {
env::var_os("CARGO_MANIFEST_DIR")
.map(PathBuf::from)
.map(|manifest_dir| manifest_dir.join("../.."))
}
fn env_commit_sha() -> Option<String> {
["GITHUB_SHA", "SOURCE_VERSION", "BUILD_VCS_NUMBER"]
.into_iter()
.filter_map(|name| env::var(name).ok())
.find(|value| is_commit_sha(value))
}
fn git_head_commit_sha(workspace_root: &Path) -> Option<String> {
let output = Command::new("git")
.args(["-C"])
.arg(workspace_root)
.args(["rev-parse", "HEAD"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8(output.stdout).ok()?;
let value = value.trim().to_string();
is_commit_sha(&value).then_some(value)
}
fn git_dirty(workspace_root: &Path) -> Option<bool> {
let output = Command::new("git")
.args(["-C"])
.arg(workspace_root)
.args(["status", "--short"])
.output()
.ok()?;
output
.status
.success()
.then(|| !String::from_utf8_lossy(&output.stdout).trim().is_empty())
}
fn git_dir(workspace_root: &Path) -> Option<PathBuf> {
let output = Command::new("git")
.args(["-C"])
.arg(workspace_root)
.args(["rev-parse", "--git-dir"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8(output.stdout).ok()?;
let value = value.trim();
(!value.is_empty()).then(|| workspace_root.join(value))
}
fn emit_git_rerun_hints(git_dir: &Path) {
let head = git_dir.join("HEAD");
println!("cargo:rerun-if-changed={}", head.display());
println!(
"cargo:rerun-if-changed={}",
git_dir.join("packed-refs").display()
);
println!("cargo:rerun-if-changed={}", git_dir.join("index").display());
let Some(reference) = std::fs::read_to_string(&head).ok().and_then(|value| {
value
.strip_prefix("ref: ")
.map(str::trim)
.map(ToOwned::to_owned)
}) else {
return;
};
println!(
"cargo:rerun-if-changed={}",
git_dir.join(reference).display()
);
}
fn is_commit_sha(value: &str) -> bool {
value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit())
}
fn rustc_release() -> Option<String> {
let rustc = env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string());
let output = Command::new(rustc).arg("-Vv").output().ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout)
.ok()?
.lines()
.find_map(|line| {
line.strip_prefix("release: ")
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
})
}
File diff suppressed because it is too large Load Diff
+64 -7
View File
@@ -1,15 +1,36 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
#![allow(clippy::cast_precision_loss)]
//! Deterministic animation sampling contracts.
//!
//! The current sampler is a portable reference path. Compatibility profiles
//! that require runtime-captured x87 parity remain explicit evidence gaps.
use std::fmt;
/// Numeric profile.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum NumericProfile {
/// Portable reference.
/// Portable reference sampler implemented by this crate.
PortableReference,
/// X87-compatible compatibility profile for captured parity vectors.
/// Reserved profile for future runtime-captured x87 parity vectors.
X87Compatibility,
}
@@ -424,8 +445,11 @@ impl PoseTrack {
&self.keys
}
/// Samples the pose track with linear translation and normalized quaternion
/// interpolation.
/// Samples the pose track with the portable reference path.
///
/// `NumericProfile::X87Compatibility` is accepted so callers can keep the
/// compatibility contract explicit, but it does not yet select an
/// independently captured x87 runtime path.
///
/// # Errors
///
@@ -757,16 +781,29 @@ fn write_f32_bits(out: &mut Vec<u8>, value: f32) {
}
fn compose_pose(parent: Pose, child: Pose) -> Result<Pose, AnimationError> {
let translated = rotate_point(parent.rotation, child.translation);
Ok(Pose {
translation: [
parent.translation[0] + child.translation[0],
parent.translation[1] + child.translation[1],
parent.translation[2] + child.translation[2],
parent.translation[0] + translated[0],
parent.translation[1] + translated[1],
parent.translation[2] + translated[2],
],
rotation: normalize_quat(mul_quat(parent.rotation, child.rotation))?,
})
}
fn rotate_point(rotation: [f32; 4], point: [f32; 3]) -> [f32; 3] {
let [x, y, z, w] = rotation;
let tx = 2.0 * (y * point[2] - z * point[1]);
let ty = 2.0 * (z * point[0] - x * point[2]);
let tz = 2.0 * (x * point[1] - y * point[0]);
[
point[0] + w * tx + (y * tz - z * ty),
point[1] + w * ty + (z * tx - x * tz),
point[2] + w * tz + (x * ty - y * tx),
]
}
fn mul_quat(left: [f32; 4], right: [f32; 4]) -> [f32; 4] {
let [lx, ly, lz, lw] = left;
let [rx, ry, rz, rw] = right;
@@ -1150,6 +1187,26 @@ mod tests {
);
}
#[test]
fn hierarchy_rotates_child_translation_by_parent_orientation() {
let half = std::f32::consts::FRAC_1_SQRT_2;
let local = [
Pose {
translation: [1.0, 0.0, 0.0],
rotation: [0.0, 0.0, half, half],
},
Pose {
translation: [2.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
];
let buffer = evaluate_hierarchy(&[ParentIndex(None), ParentIndex(Some(0))], &local)
.expect("hierarchy");
assert!((buffer.poses[1].translation[0] - 1.0).abs() < 0.0001);
assert!((buffer.poses[1].translation[1] - 2.0).abs() < 0.0001);
assert!(buffer.poses[1].translation[2].abs() < 0.0001);
}
#[test]
fn generated_valid_quaternions_remain_finite() {
for index in 1..64_u16 {
+11 -8
View File
@@ -6,16 +6,19 @@ license.workspace = true
repository.workspace = true
[dependencies]
fparkan-material = { path = "../fparkan-material" }
fparkan-msh = { path = "../fparkan-msh" }
fparkan-nres = { path = "../fparkan-nres" }
fparkan-path = { path = "../fparkan-path" }
fparkan-prototype = { path = "../fparkan-prototype" }
fparkan-resource = { path = "../fparkan-resource" }
fparkan-texm = { path = "../fparkan-texm" }
fparkan-material = { path = "../fparkan-material", version = "0.1.0" }
fparkan-msh = { path = "../fparkan-msh", version = "0.1.0" }
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
fparkan-mission-format = { path = "../fparkan-mission-format", version = "0.1.0" }
fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" }
fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
fparkan-texm = { path = "../fparkan-texm", version = "0.1.0" }
fparkan-terrain = { path = "../fparkan-terrain", version = "0.1.0" }
fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" }
[dev-dependencies]
fparkan-vfs = { path = "../fparkan-vfs" }
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+211
View File
@@ -1,8 +1,30 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Bounded little-endian binary cursor and checked layout helpers.
use std::fmt;
/// SHA-256 digest bytes.
pub type Sha256Digest = [u8; 32];
/// Parser limits shared by binary formats.
#[derive(Clone, Copy, Debug)]
pub struct Limits {
@@ -262,6 +284,183 @@ pub fn read_lp_bytes(cursor: &mut Cursor<'_>, max: u32) -> Result<Vec<u8>, Decod
Ok(cursor.read_exact(len)?.to_vec())
}
/// Computes a SHA-256 content digest without external dependencies.
#[must_use]
pub fn sha256(bytes: &[u8]) -> Sha256Digest {
const K: [u32; 64] = [
0x428a_2f98,
0x7137_4491,
0xb5c0_fbcf,
0xe9b5_dba5,
0x3956_c25b,
0x59f1_11f1,
0x923f_82a4,
0xab1c_5ed5,
0xd807_aa98,
0x1283_5b01,
0x2431_85be,
0x550c_7dc3,
0x72be_5d74,
0x80de_b1fe,
0x9bdc_06a7,
0xc19b_f174,
0xe49b_69c1,
0xefbe_4786,
0x0fc1_9dc6,
0x240c_a1cc,
0x2de9_2c6f,
0x4a74_84aa,
0x5cb0_a9dc,
0x76f9_88da,
0x983e_5152,
0xa831_c66d,
0xb003_27c8,
0xbf59_7fc7,
0xc6e0_0bf3,
0xd5a7_9147,
0x06ca_6351,
0x1429_2967,
0x27b7_0a85,
0x2e1b_2138,
0x4d2c_6dfc,
0x5338_0d13,
0x650a_7354,
0x766a_0abb,
0x81c2_c92e,
0x9272_2c85,
0xa2bf_e8a1,
0xa81a_664b,
0xc24b_8b70,
0xc76c_51a3,
0xd192_e819,
0xd699_0624,
0xf40e_3585,
0x106a_a070,
0x19a4_c116,
0x1e37_6c08,
0x2748_774c,
0x34b0_bcb5,
0x391c_0cb3,
0x4ed8_aa4a,
0x5b9c_ca4f,
0x682e_6ff3,
0x748f_82ee,
0x78a5_636f,
0x84c8_7814,
0x8cc7_0208,
0x90be_fffa,
0xa450_6ceb,
0xbef9_a3f7,
0xc671_78f2,
];
let mut h = [
0x6a09_e667,
0xbb67_ae85,
0x3c6e_f372,
0xa54f_f53a,
0x510e_527f,
0x9b05_688c,
0x1f83_d9ab,
0x5be0_cd19,
];
let bit_len = (bytes.len() as u64).wrapping_mul(8);
let mut chunks = bytes.chunks_exact(64);
for chunk in &mut chunks {
compress_sha256_chunk(&mut h, chunk, &K);
}
let tail = chunks.remainder();
let mut block = [0u8; 128];
block[..tail.len()].copy_from_slice(tail);
block[tail.len()] = 0x80;
let padded_len = if tail.len() < 56 { 64 } else { 128 };
block[padded_len - 8..padded_len].copy_from_slice(&bit_len.to_be_bytes());
for chunk in block[..padded_len].chunks_exact(64) {
compress_sha256_chunk(&mut h, chunk, &K);
}
let mut out = [0u8; 32];
for (idx, word) in h.iter().enumerate() {
out[idx * 4..idx * 4 + 4].copy_from_slice(&word.to_be_bytes());
}
out
}
/// Renders a SHA-256 digest as lowercase hexadecimal.
#[must_use]
pub fn sha256_hex(digest: &Sha256Digest) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(64);
for byte in digest {
out.push(char::from(HEX[usize::from(byte >> 4)]));
out.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
out
}
#[allow(clippy::many_single_char_names)]
fn compress_sha256_chunk(h: &mut [u32; 8], chunk: &[u8], k: &[u32; 64]) {
let mut w = [0u32; 64];
for (idx, word) in w.iter_mut().take(16).enumerate() {
let base = idx * 4;
*word = u32::from_be_bytes([
chunk[base],
chunk[base + 1],
chunk[base + 2],
chunk[base + 3],
]);
}
for idx in 16..64 {
let s0 = w[idx - 15].rotate_right(7) ^ w[idx - 15].rotate_right(18) ^ (w[idx - 15] >> 3);
let s1 = w[idx - 2].rotate_right(17) ^ w[idx - 2].rotate_right(19) ^ (w[idx - 2] >> 10);
w[idx] = w[idx - 16]
.wrapping_add(s0)
.wrapping_add(w[idx - 7])
.wrapping_add(s1);
}
let mut a = h[0];
let mut b = h[1];
let mut c = h[2];
let mut d = h[3];
let mut e = h[4];
let mut f = h[5];
let mut g = h[6];
let mut hh = h[7];
for idx in 0..64 {
let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
let ch = (e & f) ^ ((!e) & g);
let temp1 = hh
.wrapping_add(s1)
.wrapping_add(ch)
.wrapping_add(k[idx])
.wrapping_add(w[idx]);
let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
let maj = (a & b) ^ (a & c) ^ (b & c);
let temp2 = s0.wrapping_add(maj);
hh = g;
g = f;
f = e;
e = d.wrapping_add(temp1);
d = c;
c = b;
b = a;
a = temp1.wrapping_add(temp2);
}
h[0] = h[0].wrapping_add(a);
h[1] = h[1].wrapping_add(b);
h[2] = h[2].wrapping_add(c);
h[3] = h[3].wrapping_add(d);
h[4] = h[4].wrapping_add(e);
h[5] = h[5].wrapping_add(f);
h[6] = h[6].wrapping_add(g);
h[7] = h[7].wrapping_add(hh);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -305,4 +504,16 @@ mod tests {
);
assert_eq!(cursor.offset(), 4);
}
#[test]
fn sha256_matches_known_vectors() {
assert_eq!(
sha256_hex(&sha256(b"")),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
sha256_hex(&sha256(b"abc")),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
}
+11 -1
View File
@@ -6,7 +6,17 @@ license.workspace = true
repository.workspace = true
[dependencies]
fparkan-path = { path = "../fparkan-path" }
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
fparkan-fx = { path = "../fparkan-fx", version = "0.1.0" }
fparkan-material = { path = "../fparkan-material", version = "0.1.0" }
fparkan-msh = { path = "../fparkan-msh", version = "0.1.0" }
fparkan-mission-format = { path = "../fparkan-mission-format", version = "0.1.0" }
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" }
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
fparkan-rsli = { path = "../fparkan-rsli", version = "0.1.0" }
fparkan-texm = { path = "../fparkan-texm", version = "0.1.0" }
fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+2
View File
@@ -6,6 +6,8 @@ license.workspace = true
repository.workspace = true
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[lints]
workspace = true
+50 -102
View File
@@ -1,8 +1,30 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Structured diagnostics shared by `FParkan` crates.
use serde::Serialize;
/// Diagnostic severity.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
/// Informational note.
Info,
@@ -15,7 +37,8 @@ pub enum Severity {
}
/// Evidence level for a contract or interpretation.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum EvidenceStatus {
/// Described by project documentation.
Documented,
@@ -30,7 +53,8 @@ pub enum EvidenceStatus {
}
/// Operation phase where a diagnostic was produced.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Phase {
/// Discovery.
Discover,
@@ -55,7 +79,7 @@ pub enum Phase {
}
/// Byte span in an input source.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
pub struct SourceSpan {
/// Start offset.
pub offset: u64,
@@ -64,26 +88,31 @@ pub struct SourceSpan {
}
/// Stable diagnostic code.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
pub struct DiagnosticCode(pub &'static str);
/// Context attached to a diagnostic.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
pub struct DiagnosticContext {
/// Phase.
#[serde(skip_serializing_if = "Option::is_none")]
pub phase: Option<Phase>,
/// Redacted or logical path.
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
/// Archive entry name.
#[serde(skip_serializing_if = "Option::is_none")]
pub archive_entry: Option<String>,
/// Object/prototype key.
#[serde(skip_serializing_if = "Option::is_none")]
pub object_key: Option<String>,
/// Input span.
#[serde(skip_serializing_if = "Option::is_none")]
pub span: Option<SourceSpan>,
}
/// Structured diagnostic with cause chain.
#[derive(Clone, Debug, Eq, PartialEq)]
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct Diagnostic {
/// Stable code.
pub code: DiagnosticCode,
@@ -145,104 +174,13 @@ pub fn render_human(diagnostic: &Diagnostic) -> String {
out
}
/// Renders deterministic JSON without requiring a serialization dependency.
/// Renders deterministic JSON using the typed diagnostic schema.
#[must_use]
pub fn render_json(diagnostic: &Diagnostic) -> String {
fn esc(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
for ch in value.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(ch),
}
}
out
match serde_json::to_string(diagnostic) {
Ok(json) => json,
Err(err) => format!("{{\"error\":\"diagnostic serialization failed: {err}\"}}"),
}
let mut out = String::new();
out.push('{');
out.push_str("\"code\":\"");
out.push_str(&esc(diagnostic.code.0));
out.push_str("\",\"severity\":\"");
out.push_str(match diagnostic.severity {
Severity::Info => "info",
Severity::Warning => "warning",
Severity::Error => "error",
Severity::Fatal => "fatal",
});
out.push_str("\",\"message\":\"");
out.push_str(&esc(&diagnostic.message));
out.push_str("\",\"context\":{");
if let Some(phase) = diagnostic.context.phase {
out.push_str("\"phase\":\"");
out.push_str(match phase {
Phase::Discover => "discover",
Phase::Read => "read",
Phase::Parse => "parse",
Phase::Validate => "validate",
Phase::Resolve => "resolve",
Phase::Prepare => "prepare",
Phase::Construct => "construct",
Phase::Register => "register",
Phase::Simulate => "simulate",
Phase::Render => "render",
});
out.push('"');
}
if let Some(path) = &diagnostic.context.path {
if diagnostic.context.phase.is_some() {
out.push(',');
}
out.push_str("\"path\":\"");
out.push_str(&esc(path));
out.push('"');
}
if let Some(entry) = &diagnostic.context.archive_entry {
if diagnostic.context.phase.is_some() || diagnostic.context.path.is_some() {
out.push(',');
}
out.push_str("\"archive_entry\":\"");
out.push_str(&esc(entry));
out.push('"');
}
if let Some(key) = &diagnostic.context.object_key {
if diagnostic.context.phase.is_some()
|| diagnostic.context.path.is_some()
|| diagnostic.context.archive_entry.is_some()
{
out.push(',');
}
out.push_str("\"object_key\":\"");
out.push_str(&esc(key));
out.push('"');
}
if let Some(span) = diagnostic.context.span {
if diagnostic.context.phase.is_some()
|| diagnostic.context.path.is_some()
|| diagnostic.context.archive_entry.is_some()
|| diagnostic.context.object_key.is_some()
{
out.push(',');
}
out.push_str("\"span\":{\"offset\":");
out.push_str(&span.offset.to_string());
out.push_str(",\"length\":");
out.push_str(&span.length.to_string());
out.push('}');
}
out.push_str("},\"causes\":[");
for (idx, cause) in diagnostic.causes.iter().enumerate() {
if idx > 0 {
out.push(',');
}
out.push_str(&render_json(cause));
}
out.push_str("]}");
out
}
#[cfg(test)]
@@ -298,4 +236,14 @@ mod tests {
assert!(json.contains("\"code\":\"CAUSE\""));
assert!(json.contains("\"span\":{\"offset\":16,\"length\":8}"));
}
#[test]
fn json_escapes_all_control_characters() {
let value = diagnostic(DiagnosticCode("S1-H01"), "quote\"\u{0000}tab\tline\r\n");
let json = render_json(&value);
assert!(json.contains("\\u0000"));
assert!(json.contains("\\t"));
assert!(!json.contains('\t'));
assert!(!json.contains('\r'));
}
}
+2 -2
View File
@@ -6,10 +6,10 @@ license.workspace = true
repository.workspace = true
[dependencies]
fparkan-binary = { path = "../fparkan-binary" }
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
[dev-dependencies]
fparkan-nres = { path = "../fparkan-nres" }
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
[lints]
workspace = true
+50 -19
View File
@@ -1,5 +1,29 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! FXID effect contracts.
//!
//! FXID decoding and command framing are implemented as compatibility
//! contracts. The create/update/emit lifecycle below is a deterministic
//! reference stub until opcode timing, gates, RNG, and command-body semantics
//! are backed by runtime-captured evidence.
use fparkan_binary::{Cursor, DecodeError};
use std::sync::Arc;
@@ -121,7 +145,7 @@ impl Default for Transform {
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct GameTime(pub u64);
/// FX runtime state.
/// FX reference-stub runtime state.
#[derive(Clone, Debug)]
pub struct FxState {
/// Instance id.
@@ -322,12 +346,12 @@ pub fn decode_fxid(bytes: Arc<[u8]>) -> Result<FxDocument, FxError> {
})
}
/// Creates an FX instance.
/// Creates a deterministic FX reference-stub instance.
///
/// # Errors
///
/// Currently returns [`FxError`] only for future resource/lifecycle validation
/// hooks; creation is deterministic for a decoded document.
/// hooks. This function does not claim original runtime parity.
pub fn create_instance(
document: Arc<FxDocument>,
seed: FxSeed,
@@ -344,21 +368,21 @@ pub fn create_instance(
})
}
/// Updates FX simulation time without emitting side effects.
/// Updates reference-stub FX simulation time without emitting side effects.
///
/// # Errors
///
/// Reserved for future runtime validation.
/// Reserved for future runtime-captured compatibility validation.
pub fn update(state: &mut FxState, time: GameTime) -> Result<(), FxError> {
state.time = time;
Ok(())
}
/// Emits active commands without advancing state.
/// Emits reference-stub active commands without advancing state.
///
/// # Errors
///
/// Reserved for future resource/runtime validation.
/// Reserved for future resource/runtime-captured compatibility validation.
pub fn emit(state: &FxState, out: &mut Vec<FxEmission>) -> Result<(), FxError> {
if state.lifecycle != FxLifecycle::Running {
return Ok(());
@@ -838,11 +862,10 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_fxid_exact_eof_and_distribution() {
for (corpus, expected_count) in [("IS", 923_usize), ("IS2", 1065_usize)] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut count = 0usize;
let mut opcodes = BTreeMap::<FxOpcode, usize>::new();
let mut time_modes = BTreeMap::<u32, usize>::new();
@@ -886,14 +909,13 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_fxid_emission_captures_are_approved() {
for (corpus, expected_count, expected_emitting, expected_hash) in [
("IS", 923_usize, 467_usize, 10_553_431_922_547_057_702_u64),
("IS2", 1065_usize, 532_usize, 9_217_284_592_334_143_531_u64),
] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut count = 0usize;
let mut emitting = 0usize;
let mut hash = FNV_OFFSET;
@@ -985,12 +1007,21 @@ mod tests {
dst[..len].copy_from_slice(&src[..len]);
}
fn corpus_root(name: &str) -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join(name);
root.is_dir().then_some(root)
fn corpus_root(name: &str) -> PathBuf {
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => panic!("unknown licensed corpus part: {name}"),
};
let root = std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
root
}
fn files_under(root: &Path) -> Vec<PathBuf> {
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "fparkan-inspection"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-diagnostics = { path = "../fparkan-diagnostics", version = "0.1.0" }
fparkan-msh = { path = "../fparkan-msh", version = "0.1.0" }
fparkan-material = { path = "../fparkan-material", version = "0.1.0" }
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
fparkan-rsli = { path = "../fparkan-rsli", version = "0.1.0" }
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]
workspace = true
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -7,12 +7,12 @@ repository.workspace = true
[dependencies]
encoding_rs = "0.8"
fparkan-path = { path = "../fparkan-path" }
fparkan-resource = { path = "../fparkan-resource" }
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
[dev-dependencies]
fparkan-nres = { path = "../fparkan-nres" }
fparkan-vfs = { path = "../fparkan-vfs" }
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
[lints]
workspace = true
+76 -18
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! WEAR/MAT0 material contracts.
use encoding_rs::WINDOWS_1251;
@@ -417,15 +436,8 @@ pub fn resolve_material(
{
return Ok(resolved);
}
if let Some(first) = table.entries.first() {
if let Some(resolved) = load_material_entry(
repository,
archive,
&first.material,
MaterialFallback::FirstEntry,
)? {
return Ok(resolved);
}
if let Some(resolved) = load_first_material_entry(repository, archive)? {
return Ok(resolved);
}
Err(MaterialError::MissingMaterial(
String::from_utf8_lossy(&entry.material.0).into_owned(),
@@ -610,6 +622,26 @@ fn load_material_entry(
}))
}
fn load_first_material_entry(
repository: &dyn ResourceRepository,
archive: fparkan_resource::ArchiveId,
) -> Result<Option<ResolvedMaterial>, MaterialError> {
let Some(handle) = repository.first_entry(archive)? else {
return Ok(None);
};
let info = repository.entry_info(handle)?;
if info.key.type_id != Some(MAT0_KIND) {
return Ok(None);
}
let bytes = repository.read(handle)?.into_owned();
let document = decode_mat0(&bytes, info.attr2)?;
Ok(Some(ResolvedMaterial {
name: info.key.name,
fallback: MaterialFallback::FirstEntry,
document,
}))
}
fn parse_lightmaps(lines: &[&str]) -> Result<Vec<LightmapEntry>, MaterialError> {
if lines.is_empty() || lines.iter().all(|line| line.trim().is_empty()) {
return Ok(Vec::new());
@@ -926,6 +958,24 @@ mod tests {
assert_eq!(resolved.fallback, MaterialFallback::FirstEntry);
}
#[test]
fn resolve_material_first_entry_uses_material_archive_not_wear_row_zero() {
let repo = material_repo(&[
material_entry(b"MAT_ARCHIVE_FIRST", &mat0_with_texture(b"TEX_ARCHIVE")),
material_entry(b"MAT_WEAR_FIRST", &mat0_with_texture(b"TEX_WEAR")),
]);
let table = decode_wear(b"2\n0 MAT_WEAR_FIRST\n1 MISSING\n").expect("wear");
let resolved = resolve_material(&repo, &table, 1).expect("resolved");
assert_eq!(resolved.name.0, b"MAT_ARCHIVE_FIRST");
assert_eq!(resolved.fallback, MaterialFallback::FirstEntry);
assert_eq!(
resolved.document.primary_texture().expect("texture").0,
b"TEX_ARCHIVE"
);
}
#[test]
fn resolve_material_empty_texture_means_untextured() {
let repo = material_repo(&[material_entry(b"MAT_EMPTY", &mat0_with_texture(b""))]);
@@ -1092,14 +1142,13 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_mat0_and_wear_parse() {
for (corpus, expected_mat0, expected_archive_wear, expected_standalone_wear) in [
("IS", 905_usize, 439_usize, 95_usize),
("IS2", 1127_usize, 515_usize, 95_usize),
] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut mat0_count = 0usize;
let mut archive_wear_count = 0usize;
let mut standalone_wear_count = 0usize;
@@ -1153,12 +1202,21 @@ mod tests {
}
}
fn corpus_root(name: &str) -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join(name);
root.is_dir().then_some(root)
fn corpus_root(name: &str) -> PathBuf {
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => panic!("unknown licensed corpus part: {name}"),
};
let root = std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
root
}
fn files_under(root: &Path) -> Vec<PathBuf> {
+1 -1
View File
@@ -7,7 +7,7 @@ repository.workspace = true
[dependencies]
encoding_rs = "0.8"
fparkan-binary = { path = "../fparkan-binary" }
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
[lints]
workspace = true
+36 -9
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Count-driven mission format primitives.
use encoding_rs::WINDOWS_1251;
@@ -979,6 +998,7 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_tma_validate() {
for (
corpus,
@@ -991,9 +1011,7 @@ mod tests {
("IS", 29_usize, 34_usize, 101_usize, 864_usize, 28_usize),
("IS2", 31_usize, 61_usize, 91_usize, 885_usize, 41_usize),
] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut files = 0usize;
let mut paths = 0usize;
let mut clans = 0usize;
@@ -1142,12 +1160,21 @@ mod tests {
}
}
fn corpus_root(name: &str) -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join(name);
root.is_dir().then_some(root)
fn corpus_root(name: &str) -> PathBuf {
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => panic!("unknown licensed corpus part: {name}"),
};
let root = std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
root
}
fn files_under(root: &Path) -> Vec<PathBuf> {
+2 -4
View File
@@ -7,10 +7,8 @@ repository.workspace = true
[dependencies]
encoding_rs = "0.8"
fparkan-nres = { path = "../fparkan-nres" }
[dev-dependencies]
fparkan-animation = { path = "../fparkan-animation" }
fparkan-animation = { path = "../fparkan-animation", version = "0.1.0" }
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
[lints]
workspace = true
+396 -13
View File
@@ -1,7 +1,30 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Stage-3 MSH asset contract.
use encoding_rs::WINDOWS_1251;
use fparkan_animation::{
evaluate_hierarchy, AnimKey24, AnimationTime, NodePoseBuffer, ParentIndex, Pose, TimedPoseKey,
TimedPoseTrack,
};
use fparkan_nres::{EntryMeta, NresDocument, NresError};
/// Node table stream.
@@ -91,6 +114,22 @@ pub struct ModelAsset {
pub batches: Vec<Batch>,
/// Optional decoded node names.
pub node_names: Option<Vec<Option<String>>>,
/// Optional decoded node-animation streams.
pub animation: Option<ModelAnimation>,
}
/// Decoded MSH node-animation streams.
///
/// This preserves the exact type-8/type-19 boundary used by `Node38` without
/// assigning runtime-state ownership to a static asset.
#[derive(Clone, Debug, PartialEq)]
pub struct ModelAnimation {
/// Type-8 animation keys in source order.
pub keys: Vec<AnimKey24>,
/// Type-19 frame-to-key mapping words in source order.
pub frame_map: Vec<u16>,
/// Declared frame count from type-19 `attr2`.
pub frame_count: u32,
}
/// Node id.
@@ -108,6 +147,17 @@ pub struct Node {
pub raw: Vec<u8>,
}
/// Raw fields of the standard 38-byte node layout that precede slot selection.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Node38Metadata {
/// Unassigned source link value at byte offset two.
pub parent_or_link_raw: u16,
/// Offset into the type-19 frame map, or `0xFFFF` for no map.
pub anim_map_start: u16,
/// Fallback type-8 key index.
pub fallback_key: u16,
}
/// Slot descriptor.
#[derive(Clone, Debug, PartialEq)]
pub struct Slot {
@@ -368,6 +418,151 @@ pub fn selected_slot(model: &ModelAsset, node: NodeId, lod: Lod, group: Group) -
(slot < model.slots.len()).then_some(SlotId(u32::from(raw)))
}
/// Returns the undecorated metadata of a standard 38-byte node.
#[must_use]
pub fn node38_metadata(model: &ModelAsset, node: NodeId) -> Option<Node38Metadata> {
if model.node_stride != 38 {
return None;
}
let node_index = usize::try_from(node.0).ok()?;
if node_index >= model.node_count {
return None;
}
let offset = node_index.checked_mul(model.node_stride)?;
Some(Node38Metadata {
parent_or_link_raw: read_u16(&model.nodes_raw, offset + 2)?,
anim_map_start: read_u16(&model.nodes_raw, offset + 4)?,
fallback_key: read_u16(&model.nodes_raw, offset + 6)?,
})
}
/// Returns the source fallback pose of a standard node.
///
/// The legacy sampler uses this key when a node has no frame map or its current
/// frame falls outside that map. It is a static-pose input only; callers must
/// still recover animation state and the meaning of `parent_or_link_raw` before
/// assembling a runtime hierarchy.
#[must_use]
pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
let metadata = node38_metadata(model, node)?;
model
.animation
.as_ref()?
.keys
.get(usize::from(metadata.fallback_key))
.map(AnimKey24::sampling_pose)
}
/// Evaluates the static fallback pose hierarchy of a standard `Node38` model.
///
/// `0xFFFF` denotes a root. Every non-root source link must name an earlier
/// node, which preserves the producer's parent-before-child ordering. Models
/// that do not meet this established `Node38` contract return `None` so callers
/// can retain an explicitly unhierarchical diagnostic path.
#[must_use]
pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
if model.node_stride != 38 || model.node_count == 0 {
return None;
}
let mut parents = Vec::with_capacity(model.node_count);
let mut poses = Vec::with_capacity(model.node_count);
for index in 0..model.node_count {
let node = NodeId(u32::try_from(index).ok()?);
let metadata = node38_metadata(model, node)?;
let parent = if metadata.parent_or_link_raw == u16::MAX {
ParentIndex(None)
} else {
let parent = usize::from(metadata.parent_or_link_raw);
(parent < index).then_some(ParentIndex(Some(metadata.parent_or_link_raw)))?
};
parents.push(parent);
poses.push(node38_fallback_pose(model, node)?);
}
evaluate_hierarchy(&parents, &poses).ok()
}
/// Evaluates the portable-reference pose hierarchy of a standard `Node38`
/// model at one explicitly supplied logical frame.
///
/// A node without a usable type-19 map, or a requested frame outside the
/// declared map length, retains its exact fallback key. Mapped keys are
/// sampled against their immediate successor using the decoded key times.
/// This is an offline asset-sampling contract; it does not claim ownership of
/// the original runtime's animation clock or x87 numeric profile.
///
/// Returns `None` when the model lacks a complete standard-node animation
/// representation or the map cannot be safely resolved.
#[must_use]
pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePoseBuffer> {
if model.node_stride != 38 || model.node_count == 0 {
return None;
}
let animation = model.animation.as_ref()?;
let mut parents = Vec::with_capacity(model.node_count);
let mut poses = Vec::with_capacity(model.node_count);
for index in 0..model.node_count {
let node = NodeId(u32::try_from(index).ok()?);
let metadata = node38_metadata(model, node)?;
let parent = if metadata.parent_or_link_raw == u16::MAX {
ParentIndex(None)
} else {
let parent = usize::from(metadata.parent_or_link_raw);
(parent < index).then_some(ParentIndex(Some(metadata.parent_or_link_raw)))?
};
let fallback_index = usize::from(metadata.fallback_key);
let _fallback = animation.keys.get(fallback_index)?;
let key_index =
if metadata.anim_map_start == u16::MAX || u32::from(frame) >= animation.frame_count {
fallback_index
} else {
let mapped_index =
usize::from(*animation.frame_map.get(
usize::from(metadata.anim_map_start).checked_add(usize::from(frame))?,
)?);
if mapped_index < fallback_index {
mapped_index
} else {
fallback_index
}
};
let pose = sample_node38_key_pair(&animation.keys, key_index, fallback_index, frame)?;
parents.push(parent);
poses.push(pose);
}
evaluate_hierarchy(&parents, &poses).ok()
}
fn sample_node38_key_pair(
keys: &[AnimKey24],
key_index: usize,
fallback_index: usize,
frame: u16,
) -> Option<Pose> {
let key = *keys.get(key_index)?;
if key_index == fallback_index {
return Some(key.sampling_pose());
}
let next = *keys.get(key_index.checked_add(1)?)?;
if next.time.0 <= key.time.0 {
return Some(key.sampling_pose());
}
let track = TimedPoseTrack::new(
key.sampling_pose(),
vec![
TimedPoseKey {
time: key.time,
pose: key.sampling_pose(),
},
TimedPoseKey {
time: next.time,
pose: next.sampling_pose(),
},
],
)
.ok()?;
track.sample(AnimationTime(f32::from(frame))).ok()
}
/// Returns draw batches for a validated slot.
///
/// # Errors
@@ -507,6 +702,7 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
let node_names = read_optional_stream(document, STREAM_NAMES)?
.map(|raw| parse_res10_names(&raw.bytes, node_count))
.transpose()?;
let animation = parse_optional_animation(document)?;
Ok(ModelAsset {
node_stride,
@@ -519,9 +715,37 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
indices,
batches,
node_names,
animation,
})
}
fn parse_optional_animation(document: &NresDocument) -> Result<Option<ModelAnimation>, MshError> {
let keys = read_optional_stream(document, STREAM_ANIMATION_KEYS)?;
let frame_map = read_optional_stream(document, STREAM_ANIMATION_FRAME_MAP)?;
let (Some(keys), Some(frame_map)) = (keys, frame_map) else {
return Ok(None);
};
if !keys.bytes.len().is_multiple_of(24) {
return Err(invalid_resource_size("Res8", keys.bytes.len(), 24));
}
if !frame_map.bytes.len().is_multiple_of(2) {
return Err(invalid_resource_size("Res19", frame_map.bytes.len(), 2));
}
let keys = keys
.bytes
.chunks_exact(24)
.map(AnimKey24::decode)
.collect::<Result<Vec<_>, _>>()
.map_err(|err| MshError::InvalidGeometry(format!("invalid Res8 animation key: {err}")))?;
let frame_count = frame_map.attributes.attr2;
let frame_map = parse_u16_array(&frame_map.bytes, "Res19")?;
Ok(Some(ModelAnimation {
keys,
frame_map,
frame_count,
}))
}
struct RawStream {
attributes: EntryAttributes,
bytes: Vec<u8>,
@@ -1044,6 +1268,151 @@ mod tests {
assert_eq!(selected_slot(&model, NodeId(0), Lod(2), Group(4)), None);
}
#[test]
fn standard_node_exposes_fallback_pose_and_unassigned_link() {
let mut node = node38([u16::MAX; 15]);
node[2..4].copy_from_slice(&7_u16.to_le_bytes());
node[4..6].copy_from_slice(&u16::MAX.to_le_bytes());
node[6..8].copy_from_slice(&0_u16.to_le_bytes());
let mut key = Vec::new();
push_f32(&mut key, 1.0);
push_f32(&mut key, 2.0);
push_f32(&mut key, 3.0);
push_f32(&mut key, 0.0);
push_u16(&mut key, 0);
push_u16(&mut key, 0);
push_u16(&mut key, 0);
push_u16(&mut key, 32_767);
let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &node),
stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
stream(STREAM_POSITIONS, 0, b"Res3", &[]),
stream(STREAM_INDICES, 0, b"Res6", &[]),
stream(STREAM_ANIMATION_KEYS, 0, b"Res8", &key),
stream(STREAM_BATCHES, 0, b"Res13", &[]),
stream(STREAM_ANIMATION_FRAME_MAP, 0, b"Res19", &[]),
]))
.expect("nested NRes");
let model =
validate_msh(&decode_msh(&document).expect("msh document")).expect("model asset");
assert_eq!(
node38_metadata(&model, NodeId(0)),
Some(Node38Metadata {
parent_or_link_raw: 7,
anim_map_start: u16::MAX,
fallback_key: 0,
})
);
assert_eq!(
node38_fallback_pose(&model, NodeId(0)).map(|pose| pose.translation),
Some([1.0, 2.0, 3.0])
);
assert_eq!(
model
.animation
.as_ref()
.map(|animation| animation.keys.len()),
Some(1)
);
}
#[test]
fn standard_nodes_evaluate_fallback_parent_hierarchy() {
let mut root = node38([u16::MAX; 15]);
root[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
root[6..8].copy_from_slice(&0_u16.to_le_bytes());
let mut child = node38([u16::MAX; 15]);
child[2..4].copy_from_slice(&0_u16.to_le_bytes());
child[6..8].copy_from_slice(&1_u16.to_le_bytes());
let mut nodes = root;
nodes.extend(child);
let mut keys = Vec::new();
for (x, y, z, qz, qw) in [
(1.0, 0.0, 0.0, 23_170_i16, 23_170_i16),
(2.0, 0.0, 0.0, 0_i16, 32_767_i16),
] {
push_f32(&mut keys, x);
push_f32(&mut keys, y);
push_f32(&mut keys, z);
push_f32(&mut keys, 0.0);
push_u16(&mut keys, 0);
push_u16(&mut keys, 0);
push_u16(&mut keys, qz.cast_unsigned());
push_u16(&mut keys, qw.cast_unsigned());
}
let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &nodes),
stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
stream(STREAM_POSITIONS, 0, b"Res3", &[]),
stream(STREAM_INDICES, 0, b"Res6", &[]),
stream(STREAM_ANIMATION_KEYS, 0, b"Res8", &keys),
stream(STREAM_BATCHES, 0, b"Res13", &[]),
stream(STREAM_ANIMATION_FRAME_MAP, 0, b"Res19", &[]),
]))
.expect("nested NRes");
let model =
validate_msh(&decode_msh(&document).expect("msh document")).expect("model asset");
let hierarchy = node38_fallback_hierarchy(&model).expect("valid node hierarchy");
assert!((hierarchy.poses[1].translation[0] - 1.0).abs() < 0.001);
assert!((hierarchy.poses[1].translation[1] - 2.0).abs() < 0.001);
}
#[test]
fn standard_nodes_sample_mapped_key_before_fallback_key() {
let mut node = node38([u16::MAX; 15]);
node[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
node[4..6].copy_from_slice(&0_u16.to_le_bytes());
node[6..8].copy_from_slice(&2_u16.to_le_bytes());
let model = ModelAsset {
node_stride: 38,
node_count: 1,
nodes_raw: node,
slots: Vec::new(),
positions: Vec::new(),
normals: None,
uv0: None,
indices: Vec::new(),
batches: Vec::new(),
node_names: None,
animation: Some(ModelAnimation {
keys: vec![
AnimKey24 {
time: AnimationTime(0.0),
pose: Pose {
translation: [4.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(2.0),
pose: Pose {
translation: [8.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(9.0),
pose: Pose::default(),
},
],
frame_map: vec![0, 0, 0],
frame_count: 3,
}),
};
let pose = node38_sampled_hierarchy(&model, 1)
.expect("mapped hierarchy")
.poses[0];
assert!((pose.translation[0] - 6.0).abs() < f32::EPSILON);
assert_eq!(
node38_sampled_hierarchy(&model, 9)
.expect("fallback hierarchy")
.poses[0],
Pose::default()
);
}
#[test]
fn type2_header_and_slot_tail_framing_are_exact() {
let too_small = decode_nested(&build_nres(&[
@@ -1236,11 +1605,10 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_msh_assets_validate() {
for (corpus, expected) in [("IS", 435_usize), ("IS2", 511_usize)] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut count = 0usize;
for path in files_under(&root) {
let Ok(bytes) = std::fs::read(&path) else {
@@ -1279,6 +1647,7 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_animation_streams_sample_approved_pose_captures() {
for (
corpus,
@@ -1302,9 +1671,7 @@ mod tests {
13_040_438_305_408_523_893_u64,
),
] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut models = 0usize;
let mut animated_models = 0usize;
let mut node_samples = 0usize;
@@ -1338,6 +1705,13 @@ mod tests {
let model = validate_msh(&msh).unwrap_or_else(|err| {
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
});
if model.node_stride == 38 {
assert!(
node38_fallback_hierarchy(&model).is_some(),
"{corpus} {path:?} {:?}: Node38 hierarchy is not parent-before-child",
entry.name_bytes()
);
}
let preserved = msh.preserved_streams().unwrap_or_else(|err| {
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
});
@@ -1691,7 +2065,7 @@ mod tests {
out
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::similar_names, clippy::too_many_arguments)]
fn batch_record(
batch_flags: u16,
material_index: u16,
@@ -1723,12 +2097,21 @@ mod tests {
name.len() >= 4 && name[name.len() - 4..].eq_ignore_ascii_case(b".msh")
}
fn corpus_root(name: &str) -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join(name);
root.is_dir().then_some(root)
fn corpus_root(name: &str) -> PathBuf {
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => panic!("unknown licensed corpus part: {name}"),
};
let root = std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
root
}
fn files_under(root: &Path) -> Vec<PathBuf> {
+2 -2
View File
@@ -6,8 +6,8 @@ license.workspace = true
repository.workspace = true
[dependencies]
fparkan-binary = { path = "../fparkan-binary" }
fparkan-path = { path = "../fparkan-path" }
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
[lints]
workspace = true
+459 -70
View File
@@ -1,7 +1,26 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! 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 std::cmp::Ordering;
use std::fmt;
@@ -26,12 +45,39 @@ pub enum ReadProfile {
/// Write profile.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WriteProfile {
/// Return the original byte image when no edit model is active.
/// Preserve the original byte image or unindexed data-region bytes.
Lossless,
/// Repack active payloads and rebuild the lookup table.
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.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct NresHeader {
@@ -102,6 +148,7 @@ pub struct NresDocument {
#[derive(Clone, Debug)]
pub struct NresEditor {
entries: Vec<EditableEntry>,
layout: Vec<EditableSegment>,
}
#[derive(Clone, Debug)]
@@ -114,6 +161,12 @@ struct EditableEntry {
payload: Vec<u8>,
}
#[derive(Clone, Debug)]
enum EditableSegment {
Entry(usize),
Preserved(Vec<u8>),
}
/// `NRes` parse or write error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum NresError {
@@ -317,16 +370,31 @@ impl From<DecodeError> for NresError {
/// Returns [`NresError`] when the header, directory, payload ranges, or strict
/// lookup permutation are malformed for the selected [`ReadProfile`].
pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<NresDocument, NresError> {
let header = parse_header(&bytes)?;
let entries = parse_entries(&bytes, &header)?;
decode_with_limits(bytes, profile, DecodeLimits::default())
}
/// 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_payload_ranges(&entries)?;
validate_payload_ranges(&entries, limits)?;
let lookup_order_valid = match validate_lookup_order(&entries) {
Ok(valid) => valid,
Ok(()) => true,
Err(err) if profile == ReadProfile::Strict => return Err(err),
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 {
bytes,
header,
@@ -411,6 +479,23 @@ impl NresDocument {
Ok(&self.bytes[entry.data_range.clone()])
}
/// Returns an owned view of an entry payload without copying its bytes.
///
/// The returned owner is the immutable archive buffer. Consumers may retain
/// the view after dropping this document.
///
/// # Errors
///
/// Returns [`NresError::EntryIdOutOfRange`] when `id` is not present in
/// this document.
pub fn payload_view(&self, id: EntryId) -> Result<(Arc<[u8]>, Range<usize>), NresError> {
let entry = self.entry(id).ok_or_else(|| NresError::EntryIdOutOfRange {
id: id.0,
entry_count: saturating_u32_len(self.entries.len()),
})?;
Ok((Arc::clone(&self.bytes), entry.data_range.clone()))
}
/// Encodes the document according to the selected write profile.
#[must_use]
pub fn encode(&self, profile: WriteProfile) -> Vec<u8> {
@@ -506,7 +591,8 @@ impl NresEditor {
payload: document.payload(entry.id())?.to_vec(),
});
}
Ok(Self { entries })
let layout = build_edit_layout(document)?;
Ok(Self { entries, layout })
}
/// Replaces an entry payload.
@@ -537,13 +623,57 @@ impl NresEditor {
Ok(())
}
/// Encodes the edited document in canonical compact form.
/// Encodes the edited document while preserving unindexed bytes.
///
/// # Errors
///
/// Returns [`NresError`] when offsets or sizes exceed the on-disk `u32`
/// representation.
pub fn encode(&self) -> Result<Vec<u8>, NresError> {
self.encode_with_profile(WriteProfile::Lossless)
}
/// Encodes the edited document with an explicit write profile.
///
/// # Errors
///
/// Returns [`NresError`] when offsets or sizes exceed the on-disk `u32`
/// representation.
pub fn encode_with_profile(&self, profile: WriteProfile) -> Result<Vec<u8>, NresError> {
match profile {
WriteProfile::Lossless => self.encode_preserving_layout(),
WriteProfile::CanonicalCompact => self.encode_canonical_compact(),
}
}
fn encode_preserving_layout(&self) -> Result<Vec<u8>, NresError> {
let mut out = vec![0; HEADER_LEN];
let mut offsets = vec![0; self.entries.len()];
let mut sizes = vec![0; self.entries.len()];
for segment in &self.layout {
match segment {
EditableSegment::Entry(index) => {
let entry = self
.entries
.get(*index)
.ok_or(DecodeError::IntegerOverflow)?;
offsets[*index] = checked_u32_len(out.len())?;
sizes[*index] = checked_u32_len(entry.payload.len())?;
out.extend_from_slice(&entry.payload);
}
EditableSegment::Preserved(bytes) => {
out.len()
.checked_add(bytes.len())
.ok_or(DecodeError::IntegerOverflow)?;
out.extend_from_slice(bytes);
}
}
}
write_edit_archive_header_and_directory(&mut out, &self.entries, &offsets, &sizes)?;
Ok(out)
}
fn encode_canonical_compact(&self) -> Result<Vec<u8>, NresError> {
let mut out = vec![0; HEADER_LEN];
let mut offsets = Vec::with_capacity(self.entries.len());
let mut sizes = Vec::with_capacity(self.entries.len());
@@ -560,23 +690,7 @@ impl NresEditor {
);
}
let sort_order = build_edit_sort_order(&self.entries);
for (index, entry) in self.entries.iter().enumerate() {
push_u32(&mut out, entry.type_id);
push_u32(&mut out, entry.attr1);
push_u32(&mut out, entry.attr2);
push_u32(&mut out, sizes[index]);
push_u32(&mut out, entry.attr3);
out.extend_from_slice(&entry.name_raw);
push_u32(&mut out, offsets[index]);
push_u32(&mut out, checked_u32_len(sort_order[index])?);
}
let total_size = checked_u32_len(out.len())?;
out[0..4].copy_from_slice(b"NRes");
out[4..8].copy_from_slice(&VERSION_0100.to_le_bytes());
out[8..12].copy_from_slice(&checked_u32_len(self.entries.len())?.to_le_bytes());
out[12..16].copy_from_slice(&total_size.to_le_bytes());
write_edit_archive_header_and_directory(&mut out, &self.entries, &offsets, &sizes)?;
Ok(out)
}
@@ -629,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 {
let mut got = [0; 4];
let copy_len = bytes.len().min(4);
@@ -656,6 +774,7 @@ fn parse_header(bytes: &[u8]) -> Result<NresHeader, NresError> {
}
let entry_count =
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 actual = u64::try_from(bytes.len()).map_err(|_| DecodeError::IntegerOverflow)?;
if u64::from(total_size) != actual {
@@ -695,8 +814,14 @@ fn parse_header(bytes: &[u8]) -> Result<NresHeader, NresError> {
})
}
fn parse_entries(bytes: &[u8], header: &NresHeader) -> Result<Vec<NresEntry>, NresError> {
let mut entries = Vec::with_capacity(header.entry_count as usize);
fn parse_entries(
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 =
usize::try_from(header.directory_offset).map_err(|_| DecodeError::IntegerOverflow)?;
for index in 0..header.entry_count {
@@ -777,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
.iter()
.map(|entry| (entry.id.0, entry.data_range.clone()))
@@ -788,6 +913,15 @@ fn validate_payload_ranges(entries: &[NresEntry]) -> Result<(), NresError> {
.cmp(&right.1.start)
.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) {
if pair[0].1.end > pair[1].1.start {
return Err(NresError::EntryDataOverlap {
@@ -808,7 +942,7 @@ fn validate_names(entries: &[NresEntry]) -> Result<(), NresError> {
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 mut seen = vec![false; entries.len()];
for (position, entry) in entries.iter().enumerate() {
@@ -826,7 +960,7 @@ fn validate_lookup_order(entries: &[NresEntry]) -> Result<bool, NresError> {
}
seen[index_usize] = true;
}
for pair in entries.windows(2) {
for (position, pair) in entries.windows(2).enumerate() {
let left_index =
usize::try_from(pair[0].meta.sort_index).map_err(|_| DecodeError::IntegerOverflow)?;
let right_index =
@@ -834,16 +968,19 @@ fn validate_lookup_order(entries: &[NresEntry]) -> Result<bool, NresError> {
let left = entries[left_index].name_bytes();
let right = entries[right_index].name_bytes();
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(
bytes: &[u8],
entries: &[NresEntry],
directory_offset: u32,
limits: DecodeLimits,
) -> Result<Vec<PreservedRegion>, NresError> {
let mut ranges: Vec<Range<usize>> = entries
.iter()
@@ -859,18 +996,44 @@ fn find_preserved_regions(
let directory_offset =
usize::try_from(directory_offset).map_err(|_| DecodeError::IntegerOverflow)?;
let mut preserved = Vec::new();
let mut preserved_bytes = 0_u64;
for range in ranges {
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)?);
}
cursor = cursor.max(range.end);
}
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)?);
}
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> {
let all_zero = bytes[range.clone()].iter().all(|byte| *byte == 0);
Ok(PreservedRegion {
@@ -899,6 +1062,76 @@ fn build_edit_sort_order(entries: &[EditableEntry]) -> Vec<usize> {
order
}
fn build_edit_layout(document: &NresDocument) -> Result<Vec<EditableSegment>, NresError> {
let mut ranges: Vec<(Range<usize>, usize)> = document
.entries
.iter()
.enumerate()
.map(|(index, entry)| (entry.data_range.clone(), index))
.collect();
ranges.sort_by(|(left, _), (right, _)| {
left.start
.cmp(&right.start)
.then_with(|| left.end.cmp(&right.end))
});
let mut cursor = HEADER_LEN;
let directory_offset = usize::try_from(document.header.directory_offset)
.map_err(|_| DecodeError::IntegerOverflow)?;
let mut layout = Vec::new();
for (range, index) in ranges {
if cursor < range.start {
layout.push(EditableSegment::Preserved(
document.bytes[cursor..range.start].to_vec(),
));
}
layout.push(EditableSegment::Entry(index));
cursor = cursor.max(range.end);
}
if cursor < directory_offset {
layout.push(EditableSegment::Preserved(
document.bytes[cursor..directory_offset].to_vec(),
));
}
Ok(layout)
}
fn write_edit_archive_header_and_directory(
out: &mut Vec<u8>,
entries: &[EditableEntry],
offsets: &[u32],
sizes: &[u32],
) -> Result<(), NresError> {
if offsets.len() != entries.len() || sizes.len() != entries.len() {
return Err(DecodeError::IntegerOverflow.into());
}
let directory_len = ENTRY_LEN
.checked_mul(entries.len())
.ok_or(DecodeError::IntegerOverflow)?;
out.len()
.checked_add(directory_len)
.ok_or(DecodeError::IntegerOverflow)?;
let sort_order = build_edit_sort_order(entries);
for (index, entry) in entries.iter().enumerate() {
push_u32(out, entry.type_id);
push_u32(out, entry.attr1);
push_u32(out, entry.attr2);
push_u32(out, sizes[index]);
push_u32(out, entry.attr3);
out.extend_from_slice(&entry.name_raw);
push_u32(out, offsets[index]);
push_u32(out, checked_u32_len(sort_order[index])?);
}
let total_size = checked_u32_len(out.len())?;
out[0..4].copy_from_slice(b"NRes");
out[4..8].copy_from_slice(&VERSION_0100.to_le_bytes());
out[8..12].copy_from_slice(&checked_u32_len(entries.len())?.to_le_bytes());
out[12..16].copy_from_slice(&total_size.to_le_bytes());
Ok(())
}
fn editable_name_bytes(raw: &[u8; NAME_LEN]) -> &[u8] {
let len = name_len(raw).unwrap_or(NAME_LEN);
&raw[..len]
@@ -997,6 +1230,8 @@ mod tests {
assert_eq!(entry.data_range().end, HEADER_LEN + 1);
assert_eq!(doc.header().directory_offset % 8, 0);
assert_eq!(doc.payload(EntryId(0)).expect("payload"), b"x");
let (owner, range) = doc.payload_view(EntryId(0)).expect("payload view");
assert_eq!(&owner[range], b"x");
}
#[test]
@@ -1051,7 +1286,10 @@ mod tests {
assert!(matches!(
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)
));
}
@@ -1344,7 +1582,7 @@ mod tests {
}
#[test]
fn unsorted_lookup_table_falls_back_to_linear_lookup() {
fn strict_rejects_unsorted_lookup_table() {
let mut bytes = build_archive(&[
SyntheticEntry {
type_id: 1,
@@ -1372,9 +1610,99 @@ mod tests {
bytes[directory_offset + ENTRY_LEN + 60..directory_offset + ENTRY_LEN + 64]
.copy_from_slice(&1_u32.to_le_bytes());
let doc = decode(arc(bytes), ReadProfile::Strict).expect("strict nres");
assert!(!doc.lookup_order_valid());
assert_eq!(doc.find("A"), Some(EntryId(1)));
assert!(matches!(
decode(arc(bytes), ReadProfile::Strict),
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]
@@ -1414,7 +1742,7 @@ mod tests {
#[test]
fn preserves_nonzero_unindexed_region() {
let mut bytes = build_archive(&[SyntheticEntry {
let bytes = build_archive_with_nonzero_prefix_gap(&[SyntheticEntry {
type_id: 1,
attr1: 0,
attr2: 0,
@@ -1422,18 +1750,6 @@ mod tests {
name: "payload",
payload: b"data",
}]);
let directory_offset = bytes.len() - ENTRY_LEN;
bytes.splice(HEADER_LEN..HEADER_LEN, [0xAA, 0xBB, 0xCC, 0xDD]);
let total = u32::try_from(bytes.len()).expect("total size");
bytes[12..16].copy_from_slice(&total.to_le_bytes());
let offset = u32::from_le_bytes(
bytes[directory_offset + 4 + 56..directory_offset + 4 + 60]
.try_into()
.expect("shifted offset"),
);
let shifted_directory_offset = directory_offset + 4;
bytes[shifted_directory_offset + 56..shifted_directory_offset + 60]
.copy_from_slice(&(offset + 4).to_le_bytes());
let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("nres");
assert!(doc.has_nonzero_preserved_region());
@@ -1443,7 +1759,7 @@ mod tests {
#[test]
fn canonical_compact_roundtrip_preserves_entry_semantics() {
let mut bytes = build_archive(&[
let bytes = build_archive_with_nonzero_prefix_gap(&[
SyntheticEntry {
type_id: 7,
attr1: 10,
@@ -1461,16 +1777,6 @@ mod tests {
payload: b"aaaa",
},
]);
let directory_offset = bytes.len() - ENTRY_LEN * 2;
bytes.splice(HEADER_LEN..HEADER_LEN, [0xAA, 0xBB, 0xCC, 0xDD]);
let total = u32::try_from(bytes.len()).expect("total size");
bytes[12..16].copy_from_slice(&total.to_le_bytes());
for entry_index in 0..2 {
let field = directory_offset + 4 + entry_index * ENTRY_LEN + 56;
let offset =
u32::from_le_bytes(bytes[field..field + 4].try_into().expect("shifted offset"));
bytes[field..field + 4].copy_from_slice(&(offset + 4).to_le_bytes());
}
let original = decode(arc(bytes), ReadProfile::Strict).expect("original");
let compact = decode(
@@ -1529,8 +1835,13 @@ mod tests {
editor
.set_payload(EntryId(0), b"replacement".to_vec())
.expect("set payload");
let edited =
decode(arc(editor.encode().expect("encode")), ReadProfile::Strict).expect("edited");
let edited = decode(
arc(editor
.encode_with_profile(WriteProfile::CanonicalCompact)
.expect("encode")),
ReadProfile::Strict,
)
.expect("edited");
let first = edited.entry(EntryId(0)).expect("first");
let second = edited.entry(EntryId(1)).expect("second");
@@ -1545,6 +1856,64 @@ mod tests {
assert!(second.meta().data_offset > first.meta().data_offset + first.meta().data_size);
}
#[test]
fn editor_payload_update_preserves_nonzero_unindexed_region_by_default() {
let bytes = build_archive_with_nonzero_prefix_gap(&[
SyntheticEntry {
type_id: 1,
attr1: 0,
attr2: 0,
attr3: 0,
name: "first",
payload: b"one",
},
SyntheticEntry {
type_id: 2,
attr1: 0,
attr2: 0,
attr3: 0,
name: "second",
payload: b"two",
},
]);
let original = decode(arc(bytes), ReadProfile::Strict).expect("original");
let marker = original
.preserved_regions()
.iter()
.find(|region| !region.all_zero)
.expect("nonzero preserved region")
.range
.clone();
let marker = original.bytes[usize::try_from(marker.start).expect("start")
..usize::try_from(marker.end).expect("end")]
.to_vec();
let mut editor = original.editor().expect("editor");
editor
.set_payload(EntryId(0), b"replacement".to_vec())
.expect("set payload");
let edited_bytes = editor.encode().expect("encode");
let edited = decode(arc(edited_bytes.clone()), ReadProfile::Strict).expect("edited");
assert_eq!(
edited.payload(EntryId(0)).expect("first payload"),
b"replacement"
);
assert!(edited.has_nonzero_preserved_region());
assert!(edited_bytes
.windows(marker.len())
.any(|window| window == marker));
let compact = decode(
arc(editor
.encode_with_profile(WriteProfile::CanonicalCompact)
.expect("compact")),
ReadProfile::Strict,
)
.expect("compact");
assert!(!compact.has_nonzero_preserved_region());
}
#[test]
fn editor_rename_rebuilds_search_mapping() {
let bytes = build_archive(&[
@@ -1779,6 +2148,7 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpora_nres_roundtrip_gates() {
let part1 = corpus_gate("IS", 120, 6_804).expect("part 1 NRes gate");
let part2 = corpus_gate("IS2", 134, 8_171).expect("part 2 NRes gate");
@@ -1800,10 +2170,14 @@ mod tests {
expected_files: usize,
expected_entries: usize,
) -> Result<CorpusGateResult, String> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join(name);
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => return Err(format!("unknown licensed corpus part: {name}")),
};
let root = std::env::var_os(variable)
.map(PathBuf::from)
.ok_or_else(|| format!("{variable} is required for licensed corpus tests"))?;
if !root.is_dir() {
return Err(format!(
"licensed corpus root is missing: {}",
@@ -1818,7 +2192,7 @@ mod tests {
let mut has_nonzero_preserved_region = false;
for path in &files {
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()))?;
total_entries = total_entries
.checked_add(doc.entry_count())
@@ -1929,6 +2303,21 @@ mod tests {
out
}
fn build_archive_with_nonzero_prefix_gap(entries: &[SyntheticEntry<'_>]) -> Vec<u8> {
let mut bytes = build_archive(entries);
let directory_offset = bytes.len() - ENTRY_LEN * entries.len();
bytes.splice(HEADER_LEN..HEADER_LEN, [0xAA, 0xBB, 0xCC, 0xDD]);
let total = u32::try_from(bytes.len()).expect("total size");
bytes[12..16].copy_from_slice(&total.to_le_bytes());
for entry_index in 0..entries.len() {
let field = directory_offset + 4 + entry_index * ENTRY_LEN + 56;
let offset =
u32::from_le_bytes(bytes[field..field + 4].try_into().expect("shifted offset"));
bytes[field..field + 4].copy_from_slice(&(offset + 4).to_le_bytes());
}
bytes
}
fn arc(bytes: Vec<u8>) -> Arc<[u8]> {
Arc::from(bytes.into_boxed_slice())
}
+189 -20
View File
@@ -1,7 +1,28 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Legacy path normalization and ASCII lookup semantics.
use std::cmp::Ordering;
use std::fmt;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
/// Original bytes.
@@ -23,14 +44,73 @@ impl OriginalPathBytes {
}
/// Normalized relative path.
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct NormalizedPath(String);
#[derive(Clone, Debug)]
pub struct NormalizedPath {
raw: Vec<u8>,
display: String,
}
impl NormalizedPath {
/// Returns string view.
/// Returns normalized byte view used for identity, ordering, and hashing.
#[must_use]
pub fn identity_bytes(&self) -> &[u8] {
&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.
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
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.0
self.display_lossy()
}
/// Returns an OS path owned path buffer.
#[must_use]
pub fn as_path(&self) -> PathBuf {
as_os_path_from_bytes(&self.raw)
}
}
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);
}
}
@@ -91,13 +171,17 @@ pub enum PathError {
ParentTraversal,
/// Host path escape.
EscapesRoot,
/// Invalid UTF-8 after normalization.
InvalidUtf8,
}
impl fmt::Display for PathError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{self:?}")
match self {
Self::Empty => write!(f, "path is empty"),
Self::EmbeddedNul => write!(f, "path contains an embedded NUL byte"),
Self::Absolute => write!(f, "path must be relative and cannot be absolute"),
Self::ParentTraversal => write!(f, "path attempts to traverse outside its root"),
Self::EscapesRoot => write!(f, "normalized path escapes the configured root"),
}
}
}
@@ -108,33 +192,48 @@ impl std::error::Error for PathError {}
/// # Errors
///
/// Returns [`PathError`] when the input is empty, absolute, contains an
/// embedded NUL, attempts parent traversal, or is not valid UTF-8 after
/// legacy separator normalization.
pub fn normalize_relative(raw: &[u8], _policy: PathPolicy) -> Result<NormalizedPath, PathError> {
/// embedded NUL, attempts parent traversal, or has an invalid drive prefix.
pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPath, PathError> {
if raw.is_empty() {
return Err(PathError::Empty);
}
if raw.contains(&0) {
return Err(PathError::EmbeddedNul);
}
let text = std::str::from_utf8(raw).map_err(|_| PathError::InvalidUtf8)?;
if text.starts_with('/') || text.starts_with('\\') || has_drive_prefix(text) {
if raw.starts_with(b"/") || raw.starts_with(b"\\") || has_drive_prefix(raw) {
return Err(PathError::Absolute);
}
let mut parts = Vec::new();
for part in text.split(['/', '\\']) {
if part.is_empty() || part == "." {
for part in raw.split(|byte| *byte == b'/' || *byte == b'\\') {
if part.is_empty() || part == b"." {
if policy == PathPolicy::StrictLegacy {
return Err(PathError::ParentTraversal);
}
continue;
}
if part == ".." {
if part == b".." {
return Err(PathError::ParentTraversal);
}
if policy == PathPolicy::StrictLegacy && part.contains(&b':') {
return Err(PathError::Absolute);
}
parts.push(part);
}
if parts.is_empty() {
return Err(PathError::Empty);
}
Ok(NormalizedPath(parts.join("/")))
let mut normalized = Vec::new();
for (index, part) in parts.iter().enumerate() {
if index > 0 {
normalized.push(b'/');
}
normalized.extend_from_slice(part);
}
let display = String::from_utf8_lossy(&normalized).into_owned();
Ok(NormalizedPath {
raw: normalized,
display,
})
}
/// Normalizes a relative path while preserving its original bytes.
@@ -153,8 +252,7 @@ pub fn normalize_relative_with_original(
})
}
fn has_drive_prefix(text: &str) -> bool {
let bytes = text.as_bytes();
fn has_drive_prefix(bytes: &[u8]) -> bool {
bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic()
}
@@ -171,7 +269,11 @@ pub fn ascii_lookup_key(raw: &[u8]) -> LookupKey {
/// Returns [`PathError::ParentTraversal`] when a normalized segment attempts
/// to address a parent directory.
pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> {
if rel.0.split('/').any(|part| part == "..") {
if rel
.as_bytes()
.split(|byte| *byte == b'/')
.any(|part| part == b"..")
{
Err(PathError::ParentTraversal)
} else {
Ok(())
@@ -185,7 +287,20 @@ pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> {
/// Returns [`PathError`] if the normalized path fails the escape check.
pub fn join_under(root: &Path, rel: &NormalizedPath) -> Result<PathBuf, PathError> {
reject_escape(rel)?;
Ok(root.join(rel.as_str()))
Ok(root.join(rel.as_path()))
}
#[cfg(unix)]
fn as_os_path_from_bytes(raw: &[u8]) -> PathBuf {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
PathBuf::from(OsString::from_vec(raw.to_vec()))
}
#[cfg(not(unix))]
fn as_os_path_from_bytes(raw: &[u8]) -> PathBuf {
PathBuf::from(String::from_utf8_lossy(raw).into_owned())
}
#[cfg(test)]
@@ -223,6 +338,37 @@ mod tests {
);
}
#[test]
fn path_error_display_is_actionable() {
assert_eq!(
PathError::ParentTraversal.to_string(),
"path attempts to traverse outside its root"
);
assert_eq!(
PathError::EmbeddedNul.to_string(),
"path contains an embedded NUL byte"
);
}
#[test]
fn strict_legacy_rejects_host_only_segments() {
assert_eq!(
normalize_relative(b"./DATA/MAPS", PathPolicy::StrictLegacy),
Err(PathError::ParentTraversal)
);
assert_eq!(
normalize_relative(b"DATA//MAPS", PathPolicy::StrictLegacy),
Err(PathError::ParentTraversal)
);
assert_eq!(
normalize_relative(b"DATA/stream:name", PathPolicy::StrictLegacy),
Err(PathError::Absolute)
);
let host = normalize_relative(b"./DATA//MAPS", PathPolicy::HostCompatible).expect("host");
assert_eq!(host.as_str(), "DATA/MAPS");
}
#[test]
fn join_under_keeps_normalized_path_below_root() {
let rel = normalize_relative(b"DATA/MAPS/Land.map", PathPolicy::StrictLegacy)
@@ -248,6 +394,15 @@ mod tests {
assert_eq!(&ascii_lookup_key(raw).0[5..13], &raw[5..13]);
}
#[test]
fn accepts_non_utf8_legacy_bytes() {
let path = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
.expect("raw legacy bytes");
assert_eq!(path.display_lossy(), "DATA/\u{FFFD}.bin");
assert_eq!(path.identity_bytes(), b"DATA/\xFF.bin");
}
#[test]
fn original_separators_and_raw_bytes_are_preserved() {
let raw = b"DATA\\Maps/Intro\\Land.msh";
@@ -256,4 +411,18 @@ mod tests {
assert_eq!(path.normalized().as_str(), "DATA/Maps/Intro/Land.msh");
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());
}
}
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
repository.workspace = true
[dependencies]
raw-window-handle = "0.6"
[lints]
workspace = true
+179 -39
View File
@@ -1,39 +1,125 @@
#![forbid(unsafe_code)]
//! Platform ports for clocks, input, events, windows, and graphics requests.
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Platform ports for clocks, event sources and window descriptors.
/// Monotonic instant.
use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
/// Monotonic instant measured in milliseconds since process start.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct MonotonicInstant(pub u64);
/// Monotonic clock.
/// Platform clock.
pub trait MonotonicClock {
/// Current instant.
fn now(&self) -> MonotonicInstant;
}
/// Platform event.
#[derive(Clone, Debug, Eq, PartialEq)]
#[derive(Clone, Debug, PartialEq)]
pub enum PlatformEvent {
/// Quit requested.
Quit,
/// Window/application requested to quit.
QuitRequested,
/// Window focus changed.
FocusChanged {
/// Whether the window is focused.
focused: bool,
},
/// Window resize or move to a new drawable size.
Resize {
/// Drawable width in physical pixels.
width: u32,
/// Drawable height in physical pixels.
height: u32,
},
/// Device pixel ratio changed.
DpiChanged {
/// Logical-to-physical scale factor.
scale: f64,
},
/// Window minimized/hidden.
Minimized {
/// Whether the window is minimized.
minimized: bool,
},
/// Window occlusion state changed.
Occluded {
/// Whether the window is occluded.
occluded: bool,
},
/// Window is being suspended.
Suspended,
/// Window resumed from suspend.
Resumed,
/// Keyboard/scancode input.
KeyboardInput {
/// Platform scancode.
scancode: u32,
/// Pressed state.
pressed: bool,
},
/// Mouse button input.
MouseInput {
/// Mouse button code.
button: u16,
/// Pressed state.
pressed: bool,
/// X position in window coordinates.
x: f64,
/// Y position in window coordinates.
y: f64,
},
/// Mouse cursor movement.
CursorMoved {
/// Cursor x.
x: f64,
/// Cursor y.
y: f64,
},
}
/// Platform error.
/// Platform error with optional source detail.
#[derive(Debug)]
pub enum PlatformError {
/// Backend failed.
Backend,
/// Backend/backend-specific failure.
Backend {
/// Operation or subsystem.
context: &'static str,
/// Human-readable details.
message: String,
},
}
impl std::fmt::Display for PlatformError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{self:?}")
match self {
Self::Backend { context, message } => {
write!(f, "{context}: {message}")
}
}
}
}
impl std::error::Error for PlatformError {}
/// Event source.
/// Event source contract for polling platform events.
pub trait EventSource {
/// Polls events.
///
@@ -43,7 +129,7 @@ pub trait EventSource {
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError>;
}
/// Physical size.
/// Physical window size.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PhysicalSize {
/// Width.
@@ -52,42 +138,96 @@ pub struct PhysicalSize {
pub height: u32,
}
/// Window port.
/// Window identity as a stable opaque handle token.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct WindowHandle {
/// Opaque integer token.
pub id: u64,
}
/// Native raw window/display handles for render surface creation.
#[derive(Clone, Copy, Debug)]
pub struct NativeWindowHandles {
/// Raw display handle.
pub display: RawDisplayHandle,
/// Raw window handle.
pub window: RawWindowHandle,
}
/// Window presentation and lifecycle port.
///
/// Presentation is not owned by the window abstraction. Render adapters
/// own swapchain and present lifecycle.
pub trait WindowPort {
/// Drawable size.
/// Current drawable size.
fn drawable_size(&self) -> PhysicalSize;
/// Presents.
///
/// # Errors
///
/// Returns [`PlatformError`] when the backend cannot present the current
/// frame.
fn present(&mut self) -> Result<(), PlatformError>;
/// DPI scale for this window.
fn dpi_scale(&self) -> f64;
/// Whether the window is focused.
fn has_focus(&self) -> bool;
/// Whether the window is minimized.
fn is_minimized(&self) -> bool;
/// Whether the window is occluded.
fn is_occluded(&self) -> bool;
/// Opaque window identity.
fn handle(&self) -> WindowHandle;
/// Raw native handles for render surface creation, when backed by a native window.
fn native_handles(&self) -> Option<NativeWindowHandles> {
None
}
}
/// Graphics profile.
/// Render backend request contract.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GraphicsProfile {
/// Desktop core.
DesktopCore,
/// Embedded profile.
Embedded,
pub struct RenderRequest {
/// Preferred color-space profile.
pub color_space: ColorSpace,
/// Preferred presentation mode.
pub presentation: PresentationMode,
/// Requested depth/stencil format.
pub depth: DepthStencilSupport,
}
/// Version.
/// Color-space profile.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Version {
/// Major.
pub major: u8,
/// Minor.
pub minor: u8,
pub enum ColorSpace {
/// sRGB nonlinear.
Srgb,
/// Linear color-space.
Linear,
}
/// Graphics context request.
/// Presentation mode.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct GraphicsContextRequest {
/// Profile.
pub profile: GraphicsProfile,
/// Version.
pub version: Version,
pub enum PresentationMode {
/// `VSync`.
Fifo,
/// No `VSync`.
Immediate,
/// Triple-buffer mailbox fallback.
Mailbox,
}
/// Depth/stencil support profile requested by the composition root.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DepthStencilSupport {
/// Depth bits.
pub depth_bits: u8,
/// Stencil bits.
pub stencil_bits: u8,
}
impl RenderRequest {
/// Returns a conservative default request.
#[must_use]
pub const fn conservative() -> Self {
Self {
color_space: ColorSpace::Srgb,
presentation: PresentationMode::Fifo,
depth: DepthStencilSupport {
depth_bits: 24,
stencil_bits: 8,
},
}
}
}
+7 -8
View File
@@ -7,14 +7,13 @@ repository.workspace = true
[dependencies]
encoding_rs = "0.8"
fparkan-binary = { path = "../fparkan-binary" }
fparkan-material = { path = "../fparkan-material" }
fparkan-msh = { path = "../fparkan-msh" }
fparkan-nres = { path = "../fparkan-nres" }
fparkan-path = { path = "../fparkan-path" }
fparkan-resource = { path = "../fparkan-resource" }
fparkan-texm = { path = "../fparkan-texm" }
fparkan-vfs = { path = "../fparkan-vfs" }
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
[lints]
workspace = true
[dev-dependencies]
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -6,7 +6,7 @@ license.workspace = true
repository.workspace = true
[dependencies]
fparkan-world = { path = "../fparkan-world" }
fparkan-world = { path = "../fparkan-world", version = "0.1.0" }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -6,10 +6,11 @@ license.workspace = true
repository.workspace = true
[dependencies]
fparkan-nres = { path = "../fparkan-nres" }
fparkan-path = { path = "../fparkan-path" }
fparkan-rsli = { path = "../fparkan-rsli" }
fparkan-vfs = { path = "../fparkan-vfs" }
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
fparkan-rsli = { path = "../fparkan-rsli", version = "0.1.0" }
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
[lints]
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
[dependencies]
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
[lints]
File diff suppressed because it is too large Load Diff
+10 -11
View File
@@ -6,17 +6,16 @@ license.workspace = true
repository.workspace = true
[dependencies]
fparkan-mission-format = { path = "../fparkan-mission-format" }
fparkan-nres = { path = "../fparkan-nres" }
fparkan-path = { path = "../fparkan-path" }
fparkan-platform = { path = "../fparkan-platform" }
fparkan-prototype = { path = "../fparkan-prototype" }
fparkan-render = { path = "../fparkan-render" }
fparkan-resource = { path = "../fparkan-resource" }
fparkan-terrain = { path = "../fparkan-terrain" }
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
fparkan-vfs = { path = "../fparkan-vfs" }
fparkan-world = { path = "../fparkan-world" }
fparkan-assets = { path = "../fparkan-assets", version = "0.1.0" }
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
fparkan-platform = { path = "../fparkan-platform", version = "0.1.0" }
fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" }
fparkan-render = { path = "../fparkan-render", version = "0.1.0" }
fparkan-terrain = { path = "../fparkan-terrain", version = "0.1.0" }
fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
fparkan-script = { path = "../fparkan-script", version = "0.1.0" }
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
fparkan-world = { path = "../fparkan-world", version = "0.1.0" }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,12 @@
[package]
name = "fparkan-render-gl"
name = "fparkan-script"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-render = { path = "../../crates/fparkan-render" }
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
[dependencies]
fparkan-binary = { path = "../fparkan-binary" }
fparkan-nres = { path = "../fparkan-nres" }
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
[lints]
workspace = true
+296 -16
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Terrain disk format primitives.
use fparkan_binary::{checked_count_bytes, Cursor, DecodeError};
@@ -43,6 +62,69 @@ pub struct CompactSurfaceMask(pub u16);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MaterialClassMask(pub u8);
/// The two positional material-table selectors packed into a terrain face tag.
///
/// The high byte selects from map-local `Land1.wea`; `0xff` is the observed
/// no-selection sentinel. The low byte selects from `Land2.wea`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerrainMaterialLayers {
/// Optional high-byte selector for `Land1.wea`.
pub land1_selector: Option<u8>,
/// Low-byte selector for `Land2.wea`.
pub land2_selector: u8,
}
/// One `World3D` material-manager lookup key.
///
/// The original manager selects a WEAR table in the upper 16 bits and its
/// positional material row in the lower 16 bits.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerrainMaterialSelection {
/// Zero-based material-manager table index.
pub table_index: u16,
/// Positional material row in that WEAR table.
pub material_index: u16,
}
impl TerrainMaterialSelection {
/// Encodes the original material-manager phase lookup key.
#[must_use]
pub fn phase_key(self) -> u32 {
(u32::from(self.table_index) << 16) | u32::from(self.material_index)
}
}
impl TerrainMaterialLayers {
/// Constructs the decoded pair from its on-disk packed tag.
#[must_use]
pub const fn from_packed_tag(material_tag: u16) -> Self {
let [land2_selector, land1] = material_tag.to_le_bytes();
Self {
land1_selector: if land1 == u8::MAX { None } else { Some(land1) },
land2_selector,
}
}
/// Returns the table-zero `Land1.wea` selection when it exists.
#[must_use]
pub fn land1_selection(self) -> Option<TerrainMaterialSelection> {
self.land1_selector
.map(|material_index| TerrainMaterialSelection {
table_index: 0,
material_index: u16::from(material_index),
})
}
/// Returns the table-one `Land2.wea` selection.
#[must_use]
pub fn land2_selection(self) -> TerrainMaterialSelection {
TerrainMaterialSelection {
table_index: 1,
material_index: u16::from(self.land2_selector),
}
}
}
/// Terrain face with 28-byte source layout.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TerrainFace28 {
@@ -62,6 +144,14 @@ pub struct TerrainFace28 {
pub raw: [u8; 28],
}
impl TerrainFace28 {
/// Decodes the proven two-table selector layout of [`Self::material_tag`].
#[must_use]
pub const fn material_layers(&self) -> TerrainMaterialLayers {
TerrainMaterialLayers::from_packed_tag(self.material_tag)
}
}
/// Terrain stream descriptor.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TerrainStream {
@@ -93,6 +183,59 @@ pub struct TerrainSlotTable {
pub slots_raw: Vec<[u8; SLOT_STRIDE]>,
}
/// The proven front fields of one 68-byte terrain slot record.
///
/// `Terrain.dll!CLandscape` supplies these fields to `GetShade`'s terrain
/// batch dispatcher: `pair_table_index` selects the pointer-table entry and
/// `pair_count` is the number of adjacent `u16` pairs to process. The pair
/// payload and the later material/blend interpretation remain external to the
/// disk record.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerrainSlotRenderDispatch {
/// Index into the runtime pair-pointer table.
pub pair_table_index: u16,
/// Number of adjacent `u16` pairs in that selected payload.
pub pair_count: u16,
}
/// One pair consumed by the original terrain material-batch dispatcher.
///
/// These four bytes live in the type-11 stream. `material_lookup` is handed to
/// `GetShade`'s material lookup, while bit `0x10` of `flags` creates a batch
/// boundary. Other flag bits remain raw.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerrainMaterialPair {
/// Lookup id supplied to the shade material manager.
pub material_lookup: u16,
/// Opaque pair flags; bit `0x10` is a proven batch boundary.
pub flags: u16,
}
impl TerrainMaterialPair {
/// Returns the opaque lookup key supplied to `GetShade`.
///
/// This is deliberately separate from the `Land1.wea`/`Land2.wea`
/// selectors carried by [`TerrainMaterialLayers`]. Although `GetShade`
/// owns a manager loaded from `Shade.wea`, this key is not a WEAR row:
/// observed map values exceed that table's row count.
#[must_use]
pub const fn shade_lookup_key(self) -> u16 {
self.material_lookup
}
}
impl TerrainSlotTable {
/// Returns the render-dispatch header for one decoded slot record.
#[must_use]
pub fn render_dispatch(&self, slot_index: usize) -> Option<TerrainSlotRenderDispatch> {
let raw = self.slots_raw.get(slot_index)?;
Some(TerrainSlotRenderDispatch {
pair_table_index: u16::from_le_bytes([raw[0], raw[1]]),
pair_count: u16::from_le_bytes([raw[2], raw[3]]),
})
}
}
/// Land mesh document.
#[derive(Clone, Debug, PartialEq)]
pub struct LandMeshDocument {
@@ -112,12 +255,38 @@ pub struct LandMeshDocument {
pub accelerator: Vec<[u8; 4]>,
/// Type 14 auxiliary words.
pub aux14: Vec<[u8; 4]>,
/// Type 18 auxiliary words.
/// Type 18 microtexture mapping words.
///
/// `Terrain.dll!CLandscape` requires this stream and retains it as a
/// four-byte-per-entry mapping chunk.
pub aux18: Vec<[u8; 4]>,
/// Faces.
pub faces: Vec<TerrainFace28>,
}
impl LandMeshDocument {
/// Resolves the type-11 material pairs addressed by one 68-byte slot.
///
/// Returns `None` when the slot is absent or its pair range does not fit
/// the retained type-11 stream.
#[must_use]
pub fn slot_material_pairs(&self, slot_index: usize) -> Option<Vec<TerrainMaterialPair>> {
let dispatch = self.slots.render_dispatch(slot_index)?;
let start = usize::from(dispatch.pair_table_index);
let end = start.checked_add(usize::from(dispatch.pair_count))?;
let pairs = self.accelerator.get(start..end)?;
Some(
pairs
.iter()
.map(|raw| TerrainMaterialPair {
material_lookup: u16::from_le_bytes([raw[0], raw[1]]),
flags: u16::from_le_bytes([raw[2], raw[3]]),
})
.collect(),
)
}
}
/// Decoded `Land.map` document.
#[derive(Clone, Debug, PartialEq)]
pub struct LandMapDocument {
@@ -1367,6 +1536,111 @@ mod tests {
);
}
#[test]
fn slot_render_dispatch_retains_pair_table_index_and_count() {
let mut raw = [0_u8; SLOT_STRIDE];
raw[..2].copy_from_slice(&7_u16.to_le_bytes());
raw[2..4].copy_from_slice(&3_u16.to_le_bytes());
let slots = TerrainSlotTable {
header_raw: SLOT_HEADER_ZERO.to_vec(),
slots_raw: vec![raw],
};
assert_eq!(
slots.render_dispatch(0),
Some(TerrainSlotRenderDispatch {
pair_table_index: 7,
pair_count: 3,
})
);
assert_eq!(slots.render_dispatch(1), None);
}
#[test]
fn slot_material_pairs_use_type11_pair_table_and_preserve_flags() {
let nres =
decode_nres(&minimal_land_msh(&face([0, 1, 2], [None, None, None]))).expect("nres");
let mut document = decode_land_msh(&nres).expect("land mesh");
document.slots = TerrainSlotTable {
header_raw: SLOT_HEADER_ZERO.to_vec(),
slots_raw: vec![[0_u8; SLOT_STRIDE]],
};
document.slots.slots_raw[0][..2].copy_from_slice(&1_u16.to_le_bytes());
document.slots.slots_raw[0][2..4].copy_from_slice(&2_u16.to_le_bytes());
document.accelerator = vec![[0, 0, 0, 0], [2, 1, 0, 0], [3, 2, 0x10, 0]];
assert_eq!(
document.slot_material_pairs(0),
Some(vec![
TerrainMaterialPair {
material_lookup: 0x0102,
flags: 0,
},
TerrainMaterialPair {
material_lookup: 0x0203,
flags: 0x0010,
},
])
);
assert_eq!(
document.slot_material_pairs(0).expect("material pairs")[0].shade_lookup_key(),
0x0102
);
assert_eq!(document.slot_material_pairs(1), None);
document.slots.slots_raw[0][2..4].copy_from_slice(&3_u16.to_le_bytes());
assert_eq!(document.slot_material_pairs(0), None);
}
#[test]
fn terrain_material_tag_decodes_two_sidecar_selectors() {
let mut raw_face = face([0, 1, 2], [None, None, None]);
raw_face[4..6].copy_from_slice(&0x0102_u16.to_le_bytes());
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
let document = decode_land_msh(&nres).expect("land mesh");
assert_eq!(
document.faces[0].material_layers(),
TerrainMaterialLayers {
land1_selector: Some(1),
land2_selector: 2,
}
);
assert_eq!(
document.faces[0].material_layers().land1_selection(),
Some(TerrainMaterialSelection {
table_index: 0,
material_index: 1,
})
);
assert_eq!(
document.faces[0]
.material_layers()
.land2_selection()
.phase_key(),
0x0001_0002
);
raw_face[4..6].copy_from_slice(&0xff03_u16.to_le_bytes());
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
let document = decode_land_msh(&nres).expect("land mesh");
assert_eq!(
document.faces[0].material_layers(),
TerrainMaterialLayers {
land1_selector: None,
land2_selector: 3,
}
);
assert_eq!(document.faces[0].material_layers().land1_selection(), None);
assert_eq!(
document.faces[0].material_layers().land2_selection(),
TerrainMaterialSelection {
table_index: 1,
material_index: 3,
}
);
}
#[test]
fn decodes_minimal_land_map() {
let nres = decode_nres(&minimal_land_map([(-1, -1), (-1, -1)], 0)).expect("nres");
@@ -1488,14 +1762,13 @@ Generator 1
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_land_msh_validate() {
for (corpus, expected_files, expected_vertices, expected_faces) in [
("IS", 33_usize, 299_450_usize, 275_882_usize),
("IS2", 32_usize, 188_024_usize, 184_454_usize),
] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut files = 0usize;
let mut vertices = 0usize;
let mut faces = 0usize;
@@ -1536,11 +1809,10 @@ Generator 1
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_build_dat_validate() {
for (corpus, expected_ai_prefix) in [("IS", false), ("IS2", true)] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let path = root.join("BuildDat.lst");
let bytes = std::fs::read(&path).expect("read BuildDat.lst");
let categories =
@@ -1583,14 +1855,13 @@ Generator 1
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_land_map_validate() {
for (corpus, expected_files, expected_areals, expected_vertices, expected_max_hits) in [
("IS", 33_usize, 34_662_usize, 197_698_usize, 20_usize),
("IS2", 32_usize, 18_984_usize, 114_968_usize, 14_usize),
] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut files = 0usize;
let mut areals = 0usize;
let mut vertices = 0usize;
@@ -1880,12 +2151,21 @@ Generator 1
out.extend_from_slice(&value.to_le_bytes());
}
fn corpus_root(name: &str) -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join(name);
root.is_dir().then_some(root)
fn corpus_root(name: &str) -> PathBuf {
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => panic!("unknown licensed corpus part: {name}"),
};
let root = std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
root
}
fn files_under(root: &Path) -> Vec<PathBuf> {
+2 -2
View File
@@ -6,10 +6,10 @@ license.workspace = true
repository.workspace = true
[dependencies]
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" }
[dev-dependencies]
fparkan-nres = { path = "../fparkan-nres" }
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
[lints]
workspace = true
+377 -64
View File
@@ -1,9 +1,34 @@
#![forbid(unsafe_code)]
//! Validated terrain runtime queries.
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Validated terrain runtime queries using XY ground coordinates and Z height.
use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument};
use std::collections::VecDeque;
/// Terrain material-selector contract preserved from the decoded map mesh.
///
/// Applications access this semantic terrain API through this terrain crate
/// rather than taking a direct dependency on the binary-format parser.
pub use fparkan_terrain_format::{TerrainMaterialLayers, TerrainMaterialSelection};
/// Terrain world.
#[derive(Clone, Debug, Default)]
pub struct TerrainWorld {
@@ -11,12 +36,14 @@ pub struct TerrainWorld {
grid: RuntimeGrid,
adjacency: Vec<Vec<ArealId>>,
surfaces: Vec<RuntimeTriangle>,
surface_bvh: SurfaceBvh,
source_mesh: Option<LandMeshDocument>,
}
/// Surface hit.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SurfaceHit {
/// Height.
/// Source Z height.
pub height: f32,
/// Hit position.
pub position: [f32; 3],
@@ -115,7 +142,7 @@ impl std::error::Error for TerrainError {}
/// Surface query.
pub trait SurfaceQuery {
/// Height at position.
/// Source Z height at an XY ground position.
///
/// # Errors
///
@@ -137,7 +164,7 @@ pub trait SurfaceQuery {
/// Navigation query.
pub trait NavigationQuery {
/// Locate areal.
/// Locates an areal from the XY components of a world position.
///
/// # Errors
///
@@ -175,7 +202,7 @@ impl TerrainWorld {
polygon: areal
.vertices
.iter()
.map(|vertex| [vertex[0], vertex[2]])
.map(|vertex| [vertex[0], vertex[1]])
.collect(),
});
}
@@ -213,6 +240,8 @@ impl TerrainWorld {
grid,
adjacency,
surfaces: Vec::new(),
surface_bvh: SurfaceBvh::default(),
source_mesh: None,
})
}
@@ -225,8 +254,11 @@ impl TerrainWorld {
pub fn from_land_msh(mesh: &LandMeshDocument) -> Result<Self, TerrainError> {
Ok(Self {
surfaces: build_surfaces(mesh)?,
surface_bvh: SurfaceBvh::default(),
source_mesh: Some(mesh.clone()),
..Self::default()
})
}
.with_surface_bvh())
}
/// Builds terrain runtime data from decoded `Land.msh` and `Land.map`.
@@ -241,6 +273,8 @@ impl TerrainWorld {
) -> Result<Self, TerrainError> {
let mut world = Self::from_land_map(map)?;
world.surfaces = build_surfaces(mesh)?;
world.surface_bvh = SurfaceBvh::from_surfaces(&world.surfaces);
world.source_mesh = Some(mesh.clone());
Ok(world)
}
@@ -256,12 +290,35 @@ impl TerrainWorld {
self.surfaces.len()
}
/// Returns the validated source mesh retained for renderer integration.
///
/// Runtime collision queries use their own compact triangle representation;
/// keeping this document preserves the original geometry and UV streams for
/// rendering without asking the application layer to decode an archive again.
#[must_use]
pub fn source_mesh(&self) -> Option<&LandMeshDocument> {
self.source_mesh.as_ref()
}
/// Returns source terrain positions without exposing parser ownership to apps.
#[must_use]
pub fn source_positions(&self) -> Option<&[[f32; 3]]> {
self.source_mesh
.as_ref()
.map(|mesh| mesh.positions.as_slice())
}
fn with_surface_bvh(mut self) -> Self {
self.surface_bvh = SurfaceBvh::from_surfaces(&self.surfaces);
self
}
fn locate_by_candidates(
&self,
position: [f32; 3],
candidates: &[ArealId],
) -> Result<Option<ArealId>, TerrainError> {
let point = [position[0], position[2]];
let point = [position[0], position[1]];
for candidate in candidates {
let Some(areal) = usize::try_from(candidate.0)
.ok()
@@ -352,7 +409,8 @@ impl SurfaceQuery for TerrainWorld {
return Err(TerrainError::Unsupported);
}
let mut best = None;
for triangle in &self.surfaces {
for index in self.surface_bvh.xy_candidates(position) {
let triangle = &self.surfaces[index];
if let Some(height) = triangle.height_at(position) {
best = Some(best.map_or(height, |current: f32| current.max(height)));
}
@@ -369,8 +427,11 @@ impl SurfaceQuery for TerrainWorld {
if self.surfaces.is_empty() {
return Err(TerrainError::Unsupported);
}
let mut candidates = self.surface_bvh.ray_candidates(origin, direction);
candidates.sort_unstable();
let mut best: Option<SurfaceHit> = None;
for triangle in &self.surfaces {
for index in candidates {
let triangle = &self.surfaces[index];
if mask.0 != 0 && triangle.mask.0 & mask.0 == 0 {
continue;
}
@@ -386,7 +447,7 @@ impl SurfaceQuery for TerrainWorld {
origin[2] + direction[2] * distance,
];
best = Some(SurfaceHit {
height: position[1],
height: position[2],
position,
distance,
face: triangle.face,
@@ -425,20 +486,216 @@ struct RuntimeTriangle {
vertices: [[f32; 3]; 3],
}
const SURFACE_BVH_LEAF_SIZE: usize = 8;
const SURFACE_BOUNDS_EPSILON: f32 = 1.0e-4;
/// Deterministic CPU index over validated terrain triangles.
///
/// The index changes only candidate selection: triangle math and observable
/// tie-breaking remain in [`SurfaceQuery`] above.
#[derive(Clone, Debug, Default)]
struct SurfaceBvh {
nodes: Vec<SurfaceBvhNode>,
triangle_indices: Vec<usize>,
root: Option<usize>,
}
#[derive(Clone, Debug)]
struct SurfaceBvhNode {
min: [f32; 3],
max: [f32; 3],
kind: SurfaceBvhNodeKind,
}
#[derive(Clone, Debug)]
enum SurfaceBvhNodeKind {
Leaf { first: usize, count: usize },
Branch { left: usize, right: usize },
}
impl SurfaceBvh {
fn from_surfaces(surfaces: &[RuntimeTriangle]) -> Self {
if surfaces.is_empty() {
return Self::default();
}
let mut index = Self {
nodes: Vec::with_capacity(surfaces.len().saturating_mul(2)),
triangle_indices: Vec::with_capacity(surfaces.len()),
root: None,
};
let indices: Vec<usize> = (0..surfaces.len()).collect();
index.root = Some(index.build(surfaces, indices));
index
}
fn build(&mut self, surfaces: &[RuntimeTriangle], mut indices: Vec<usize>) -> usize {
let (min, max) = surface_bounds(surfaces, &indices);
if indices.len() <= SURFACE_BVH_LEAF_SIZE {
indices.sort_unstable();
let first = self.triangle_indices.len();
let count = indices.len();
self.triangle_indices.extend(indices);
let node = self.nodes.len();
self.nodes.push(SurfaceBvhNode {
min,
max,
kind: SurfaceBvhNodeKind::Leaf { first, count },
});
return node;
}
let axis = longest_axis(min, max);
indices.sort_by(|left, right| {
surface_centroid(&surfaces[*left])[axis]
.total_cmp(&surface_centroid(&surfaces[*right])[axis])
.then_with(|| left.cmp(right))
});
let right_indices = indices.split_off(indices.len() / 2);
let left = self.build(surfaces, indices);
let right = self.build(surfaces, right_indices);
let node = self.nodes.len();
self.nodes.push(SurfaceBvhNode {
min,
max,
kind: SurfaceBvhNodeKind::Branch { left, right },
});
node
}
fn xy_candidates(&self, point: [f32; 2]) -> Vec<usize> {
let mut candidates = Vec::new();
let Some(root) = self.root else {
return candidates;
};
let mut pending = vec![root];
while let Some(node_index) = pending.pop() {
let node = &self.nodes[node_index];
if !point_within_surface_bounds(point, node.min, node.max) {
continue;
}
match node.kind {
SurfaceBvhNodeKind::Leaf { first, count } => {
candidates.extend_from_slice(&self.triangle_indices[first..first + count]);
}
SurfaceBvhNodeKind::Branch { left, right } => {
pending.push(right);
pending.push(left);
}
}
}
candidates
}
fn ray_candidates(&self, origin: [f32; 3], direction: [f32; 3]) -> Vec<usize> {
let mut candidates = Vec::new();
let Some(root) = self.root else {
return candidates;
};
let mut pending = vec![root];
while let Some(node_index) = pending.pop() {
let node = &self.nodes[node_index];
if !ray_intersects_surface_bounds(origin, direction, node.min, node.max) {
continue;
}
match node.kind {
SurfaceBvhNodeKind::Leaf { first, count } => {
candidates.extend_from_slice(&self.triangle_indices[first..first + count]);
}
SurfaceBvhNodeKind::Branch { left, right } => {
pending.push(right);
pending.push(left);
}
}
}
candidates
}
}
fn surface_bounds(surfaces: &[RuntimeTriangle], indices: &[usize]) -> ([f32; 3], [f32; 3]) {
let mut min = [f32::INFINITY; 3];
let mut max = [f32::NEG_INFINITY; 3];
for index in indices {
for vertex in surfaces[*index].vertices {
for axis in 0..3 {
min[axis] = min[axis].min(vertex[axis]);
max[axis] = max[axis].max(vertex[axis]);
}
}
}
(min, max)
}
fn surface_centroid(triangle: &RuntimeTriangle) -> [f32; 3] {
let mut centroid = [0.0; 3];
for vertex in triangle.vertices {
for axis in 0..3 {
centroid[axis] += vertex[axis] / 3.0;
}
}
centroid
}
fn longest_axis(min: [f32; 3], max: [f32; 3]) -> usize {
let extent = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
if extent[1] > extent[0] && extent[1] >= extent[2] {
1
} else if extent[2] > extent[0] && extent[2] > extent[1] {
2
} else {
0
}
}
fn point_within_surface_bounds(point: [f32; 2], min: [f32; 3], max: [f32; 3]) -> bool {
point[0] >= min[0] - SURFACE_BOUNDS_EPSILON
&& point[0] <= max[0] + SURFACE_BOUNDS_EPSILON
&& point[1] >= min[1] - SURFACE_BOUNDS_EPSILON
&& point[1] <= max[1] + SURFACE_BOUNDS_EPSILON
}
fn ray_intersects_surface_bounds(
origin: [f32; 3],
direction: [f32; 3],
min: [f32; 3],
max: [f32; 3],
) -> bool {
let mut near = 0.0_f32;
let mut far = f32::INFINITY;
for axis in 0..3 {
if direction[axis].abs() <= f32::EPSILON {
if origin[axis] < min[axis] - SURFACE_BOUNDS_EPSILON
|| origin[axis] > max[axis] + SURFACE_BOUNDS_EPSILON
{
return false;
}
continue;
}
let inverse = 1.0 / direction[axis];
let first = (min[axis] - origin[axis]) * inverse;
let second = (max[axis] - origin[axis]) * inverse;
near = near.max(first.min(second));
far = far.min(first.max(second));
if far < near {
return false;
}
}
far >= 0.0
}
impl RuntimeTriangle {
fn height_at(&self, position: [f32; 2]) -> Option<f32> {
let a = [self.vertices[0][0], self.vertices[0][2]];
let b = [self.vertices[1][0], self.vertices[1][2]];
let c = [self.vertices[2][0], self.vertices[2][2]];
let a = [self.vertices[0][0], self.vertices[0][1]];
let b = [self.vertices[1][0], self.vertices[1][1]];
let c = [self.vertices[2][0], self.vertices[2][1]];
let weights = barycentric_2d(position, a, b, c)?;
if weights
.iter()
.all(|weight| *weight >= -1.0e-4 && *weight <= 1.0001)
{
Some(
weights[0] * self.vertices[0][1]
+ weights[1] * self.vertices[1][1]
+ weights[2] * self.vertices[2][1],
weights[0] * self.vertices[0][2]
+ weights[1] * self.vertices[1][2]
+ weights[2] * self.vertices[2][2],
)
} else {
None
@@ -524,26 +781,29 @@ struct RuntimeGrid {
impl RuntimeGrid {
fn from_land_map(map: &LandMapDocument) -> Result<Self, TerrainError> {
let mut min = [f32::INFINITY, f32::INFINITY];
let mut max = [f32::NEG_INFINITY, f32::NEG_INFINITY];
let mut bounds_min = [f32::INFINITY, f32::INFINITY];
let mut bounds_max = [f32::NEG_INFINITY, f32::NEG_INFINITY];
for areal in &map.areals {
for vertex in &areal.vertices {
min[0] = min[0].min(vertex[0]);
min[1] = min[1].min(vertex[2]);
max[0] = max[0].max(vertex[0]);
max[1] = max[1].max(vertex[2]);
bounds_min[0] = bounds_min[0].min(vertex[0]);
bounds_min[1] = bounds_min[1].min(vertex[1]);
bounds_max[0] = bounds_max[0].max(vertex[0]);
bounds_max[1] = bounds_max[1].max(vertex[1]);
}
}
if !min[0].is_finite() || !min[1].is_finite() || !max[0].is_finite() || !max[1].is_finite()
if !bounds_min[0].is_finite()
|| !bounds_min[1].is_finite()
|| !bounds_max[0].is_finite()
|| !bounds_max[1].is_finite()
{
min = [0.0, 0.0];
max = [1.0, 1.0];
bounds_min = [0.0, 0.0];
bounds_max = [1.0, 1.0];
}
if (min[0] - max[0]).abs() <= f32::EPSILON {
max[0] += 1.0;
if (bounds_min[0] - bounds_max[0]).abs() <= f32::EPSILON {
bounds_max[0] += 1.0;
}
if (min[1] - max[1]).abs() <= f32::EPSILON {
max[1] += 1.0;
if (bounds_min[1] - bounds_max[1]).abs() <= f32::EPSILON {
bounds_max[1] += 1.0;
}
let mut cells = Vec::with_capacity(map.grid.cells.len());
@@ -568,8 +828,8 @@ impl RuntimeGrid {
Ok(Self {
cells_x: map.grid.cells_x,
cells_y: map.grid.cells_y,
min,
max,
min: bounds_min,
max: bounds_max,
cells,
})
}
@@ -578,7 +838,7 @@ impl RuntimeGrid {
if self.cells_x == 0 || self.cells_y == 0 || self.cells.is_empty() {
return None;
}
let point = [position[0], position[2]];
let point = [position[0], position[1]];
if point[0] < self.min[0]
|| point[0] > self.max[0]
|| point[1] < self.min[1]
@@ -726,18 +986,18 @@ mod tests {
assert_eq!(world.areal_count(), 2);
assert_eq!(
world.locate_areal([0.25, 0.0, 0.25]).expect("locate"),
world.locate_areal([0.25, 0.25, 41.0]).expect("locate"),
Some(ArealId(0))
);
assert_eq!(
world.locate_areal([1.75, 0.0, 0.25]).expect("locate"),
world.locate_areal([1.75, 0.25, -12.0]).expect("locate"),
Some(ArealId(1))
);
assert_eq!(
world
.route(RouteRequest {
start: [0.25, 0.0, 0.25],
goal: [1.75, 0.0, 0.25],
start: [0.25, 0.25, 0.0],
goal: [1.75, 0.25, 0.0],
})
.expect("route"),
Some(ArealRoute {
@@ -753,8 +1013,8 @@ mod tests {
assert_eq!(
world
.route(RouteRequest {
start: [10.0, 0.0, 10.0],
goal: [1.75, 0.0, 0.25],
start: [10.0, 10.0, 0.0],
goal: [1.75, 0.25, 0.0],
})
.expect("route"),
None
@@ -771,8 +1031,8 @@ mod tests {
let hit = world
.raycast(
[0.25, 2.0, 0.25],
[0.0, -1.0, 0.0],
[0.25, 0.25, 2.0],
[0.0, 0.0, -1.0],
FullSurfaceMask(0x0000_0001),
)
.expect("raycast")
@@ -784,8 +1044,8 @@ mod tests {
assert_eq!(
world
.raycast(
[0.25, 2.0, 0.25],
[0.0, -1.0, 0.0],
[0.25, 0.25, 2.0],
[0.0, 0.0, -1.0],
FullSurfaceMask(0x8000_0000)
)
.expect("raycast"),
@@ -794,14 +1054,37 @@ mod tests {
}
#[test]
fn surface_bvh_reduces_candidates_without_changing_surface_math() {
let surfaces: Vec<_> = (0_u16..16)
.map(|face| {
let x = f32::from(face) * 10.0;
RuntimeTriangle {
face: usize::from(face),
mask: FullSurfaceMask(1),
vertices: [[x, 0.0, x], [x + 1.0, 0.0, x], [x, 1.0, x]],
}
})
.collect();
let index = SurfaceBvh::from_surfaces(&surfaces);
let xy = index.xy_candidates([0.25, 0.25]);
assert!(xy.contains(&0));
assert!(xy.len() < surfaces.len());
assert_eq!(surfaces[xy[0]].height_at([0.25, 0.25]), Some(0.0));
let ray = index.ray_candidates([0.25, 0.25, 5.0], [0.0, 0.0, -1.0]);
assert!(ray.contains(&0));
assert!(ray.len() < surfaces.len());
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_land_maps_build_navigation_worlds() {
for (corpus, expected_files, expected_areals) in [
("IS", 33_usize, 34_662_usize),
("IS2", 32_usize, 18_984_usize),
] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut files = 0usize;
let mut areals = 0usize;
let mut located_centers = 0usize;
@@ -849,16 +1132,16 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_land_meshes_build_surface_worlds() {
for (corpus, expected_files, expected_faces) in [
("IS", 33_usize, 275_882_usize),
("IS2", 32_usize, 184_454_usize),
] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut files = 0usize;
let mut faces = 0usize;
let mut indexed_queries = 0usize;
for path in files_under(&root) {
if !path
.file_name()
@@ -877,12 +1160,33 @@ mod tests {
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
let world = TerrainWorld::from_land_msh(&mesh)
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
if world.surface_count() > SURFACE_BVH_LEAF_SIZE {
let triangle = &world.surfaces[0];
let point = [
(triangle.vertices[0][0]
+ triangle.vertices[1][0]
+ triangle.vertices[2][0])
/ 3.0,
(triangle.vertices[0][1]
+ triangle.vertices[1][1]
+ triangle.vertices[2][1])
/ 3.0,
];
let candidates = world.surface_bvh.xy_candidates(point);
assert!(candidates.contains(&0), "{corpus} {path:?} face zero");
assert!(
candidates.len() < world.surface_count(),
"{corpus} {path:?} surface index did not reduce candidates"
);
indexed_queries += 1;
}
files += 1;
faces += world.surface_count();
}
assert_eq!(files, expected_files, "{corpus} Land.msh count");
assert_eq!(faces, expected_faces, "{corpus} surface face count");
assert_eq!(indexed_queries, files, "{corpus} indexed Land.msh count");
}
}
@@ -900,9 +1204,9 @@ mod tests {
},
positions: vec![
[0.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[1.0, 0.0, 1.0],
[0.0, 1.0, 1.0],
[1.0, 2.0, 1.0],
[1.0, 1.0, 2.0],
],
normals: Vec::new(),
uv0: Vec::new(),
@@ -945,10 +1249,10 @@ mod tests {
areals: vec![
Areal {
prefix_raw: [0; 56],
anchor: [0.5, 0.0, 0.5],
anchor: [0.5, 0.5, 0.0],
reserved_12: 0.0,
area_metric: 1.0,
normal: [0.0, 1.0, 0.0],
normal: [0.0, 0.0, 1.0],
logic_flag: 0,
reserved_36: 0,
class_id: 0,
@@ -956,8 +1260,8 @@ mod tests {
vertices: vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 0.0, 1.0],
[0.0, 0.0, 1.0],
[1.0, 1.0, 0.0],
[0.0, 1.0, 0.0],
],
links: vec![
EdgeLink {
@@ -989,10 +1293,10 @@ mod tests {
},
Areal {
prefix_raw: [0; 56],
anchor: [1.5, 0.0, 0.5],
anchor: [1.5, 0.5, 0.0],
reserved_12: 0.0,
area_metric: 1.0,
normal: [0.0, 1.0, 0.0],
normal: [0.0, 0.0, 1.0],
logic_flag: 0,
reserved_36: 0,
class_id: 0,
@@ -1000,8 +1304,8 @@ mod tests {
vertices: vec![
[1.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
[2.0, 0.0, 1.0],
[1.0, 0.0, 1.0],
[2.0, 1.0, 0.0],
[1.0, 1.0, 0.0],
],
links: vec![
EdgeLink {
@@ -1049,12 +1353,21 @@ mod tests {
vertices.first().copied()
}
fn corpus_root(name: &str) -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join(name);
root.is_dir().then_some(root)
fn corpus_root(name: &str) -> PathBuf {
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => panic!("unknown licensed corpus part: {name}"),
};
let root = std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
root
}
fn files_under(root: &Path) -> Vec<PathBuf> {
+1 -1
View File
@@ -6,7 +6,7 @@ license.workspace = true
repository.workspace = true
[dependencies]
fparkan-render = { path = "../fparkan-render" }
fparkan-render = { path = "../fparkan-render", version = "0.1.0" }
[lints]
workspace = true
+19
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Dev-only synthetic builders and fake ports.
use fparkan_render::{FrameOutput, RenderBackend, RenderCommandList, RenderError};
+1 -1
View File
@@ -8,7 +8,7 @@ repository.workspace = true
[dependencies]
[dev-dependencies]
fparkan-nres = { path = "../fparkan-nres" }
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
[lints]
workspace = true
+36 -9
View File
@@ -1,4 +1,23 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Stage-3 Texm texture contract.
use std::sync::Arc;
@@ -1071,11 +1090,10 @@ mod tests {
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_texm_assets_validate_and_decode_mip0() {
for (corpus, expected) in [("IS", 518_usize), ("IS2", 631_usize)] {
let Some(root) = corpus_root(corpus) else {
continue;
};
let root = corpus_root(corpus);
let mut count = 0usize;
for path in files_under(&root) {
let Ok(bytes) = std::fs::read(&path) else {
@@ -1157,12 +1175,21 @@ mod tests {
}
}
fn corpus_root(name: &str) -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("testdata")
.join(name);
root.is_dir().then_some(root)
fn corpus_root(name: &str) -> PathBuf {
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => panic!("unknown licensed corpus part: {name}"),
};
let root = std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
root
}
fn files_under(root: &Path) -> Vec<PathBuf> {
+2 -1
View File
@@ -6,7 +6,8 @@ license.workspace = true
repository.workspace = true
[dependencies]
fparkan-path = { path = "../fparkan-path" }
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
[lints]
workspace = true
+395 -72
View File
@@ -1,9 +1,35 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Virtual filesystem ports for resource loading.
use fparkan_path::{join_under, NormalizedPath};
use fparkan_binary::{sha256, Sha256Digest};
use fparkan_path::{ascii_lookup_key, join_under, NormalizedPath};
use std::collections::BTreeMap;
use std::fs;
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
#[cfg(windows)]
use std::os::windows::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -12,8 +38,8 @@ use std::sync::Arc;
pub struct VfsMetadata {
/// Byte length.
pub len: u64,
/// Stable-enough source fingerprint for cache invalidation.
pub fingerprint: u64,
/// SHA-256 content fingerprint for cache invalidation.
pub fingerprint: Sha256Digest,
}
/// VFS entry.
@@ -92,22 +118,40 @@ impl DirectoryVfs {
}
fn host_path(&self, path: &NormalizedPath) -> Result<PathBuf, VfsError> {
let exact = join_under(&self.root, path).map_err(|_| VfsError::Path)?;
if exact.exists() {
return Ok(exact);
}
resolve_casefolded(&self.root, path.as_str())
join_under(&self.root, path).map_err(|_| VfsError::Path)?;
resolve_casefolded(&self.root, path)
}
fn metadata_from_host_file(path: &Path) -> Result<VfsMetadata, VfsError> {
let metadata = fs::symlink_metadata(path).map_err(VfsError::Io)?;
metadata_from_host_file(path, &metadata)
}
}
impl Vfs for DirectoryVfs {
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
let meta = fs::metadata(self.host_path(path)?).map_err(VfsError::Io)?;
Ok(metadata_from_fs(&meta))
Self::metadata_from_host_file(&self.host_path(path)?)
}
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
let bytes = fs::read(self.host_path(path)?).map_err(VfsError::Io)?;
let host = self.host_path(path)?;
let pre_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?;
if pre_metadata.file_type().is_symlink() || !pre_metadata.is_file() {
return Err(VfsError::Path);
}
let pre_identity = file_identity(&pre_metadata);
let pre_len = pre_metadata.len();
let pre_modified = pre_metadata.modified().ok();
let bytes = fs::read(&host).map_err(VfsError::Io)?;
let post_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?;
if post_metadata.file_type().is_symlink()
|| !post_metadata.is_file()
|| post_metadata.len() != pre_len
|| post_metadata.modified().ok() != pre_modified
|| file_identity(&post_metadata) != pre_identity
{
return Err(VfsError::Path);
}
Ok(Arc::from(bytes.into_boxed_slice()))
}
@@ -115,20 +159,63 @@ impl Vfs for DirectoryVfs {
let base = self.host_path(prefix)?;
let mut entries = Vec::new();
if base.is_file() {
let metadata = fs::metadata(&base).map_err(VfsError::Io)?;
let metadata = fs::symlink_metadata(&base).map_err(VfsError::Io)?;
entries.push(VfsEntry {
path: prefix.clone(),
metadata: metadata_from_fs(&metadata),
metadata: metadata_from_host_file(&base, &metadata)?,
});
return Ok(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)
}
}
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();
for segment in normalized.split('/') {
let read_dir = fs::read_dir(&current).map_err(VfsError::Io)?;
@@ -140,6 +227,9 @@ fn resolve_casefolded(root: &Path, normalized: &str) -> Result<PathBuf, VfsError
continue;
};
if name.eq_ignore_ascii_case(segment) {
if entry.file_type().map_err(VfsError::Io)?.is_symlink() {
return Err(VfsError::Path);
}
matches.push(entry.path());
}
}
@@ -151,10 +241,11 @@ fn resolve_casefolded(root: &Path, normalized: &str) -> Result<PathBuf, VfsError
fn select_casefolded_match(
normalized: &str,
current: &Path,
segment: &str,
segment: impl AsRef<[u8]>,
mut matches: Vec<PathBuf>,
) -> Result<PathBuf, VfsError> {
matches.sort();
let segment = String::from_utf8_lossy(segment.as_ref());
match matches.len() {
0 => Err(VfsError::NotFound(normalized.to_string())),
1 => Ok(matches.remove(0)),
@@ -175,7 +266,10 @@ fn list_recursive(root: &Path, dir: &Path, out: &mut Vec<VfsEntry>) -> Result<()
}
children.sort();
for child in children {
let metadata = fs::metadata(&child).map_err(VfsError::Io)?;
let metadata = fs::symlink_metadata(&child).map_err(VfsError::Io)?;
if metadata.file_type().is_symlink() {
return Err(VfsError::Path);
}
if metadata.is_dir() {
list_recursive(root, &child, out)?;
continue;
@@ -184,86 +278,132 @@ fn list_recursive(root: &Path, dir: &Path, out: &mut Vec<VfsEntry>) -> Result<()
continue;
}
let rel = child.strip_prefix(root).map_err(|_| VfsError::Path)?;
let rel_text = rel.to_str().ok_or(VfsError::Path)?;
let path = fparkan_path::normalize_relative(
rel_text.as_bytes(),
fparkan_path::PathPolicy::HostCompatible,
)
.map_err(|_| VfsError::Path)?;
#[cfg(unix)]
let rel_bytes = rel.as_os_str().as_bytes();
#[cfg(not(unix))]
let rel_bytes = rel.to_str().ok_or(VfsError::Path)?.as_bytes();
let path =
fparkan_path::normalize_relative(rel_bytes, fparkan_path::PathPolicy::HostCompatible)
.map_err(|_| VfsError::Path)?;
out.push(VfsEntry {
path,
metadata: metadata_from_fs(&metadata),
metadata: metadata_from_host_file(&child, &metadata)?,
});
}
Ok(())
}
fn metadata_from_fs(metadata: &fs::Metadata) -> VfsMetadata {
let mut fingerprint = 0xcbf2_9ce4_8422_2325;
hash_u64(&mut fingerprint, metadata.len());
if let Ok(modified) = metadata.modified() {
if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
hash_u64(&mut fingerprint, duration.as_secs());
hash_u64(&mut fingerprint, u64::from(duration.subsec_nanos()));
}
}
VfsMetadata {
len: metadata.len(),
fingerprint,
fn metadata_from_host_file(path: &Path, metadata: &fs::Metadata) -> Result<VfsMetadata, VfsError> {
if !metadata.is_file() {
return Err(VfsError::Path);
}
let len = metadata.len();
let bytes = fs::read(path).map_err(VfsError::Io)?;
let fingerprint = sha256(&bytes);
Ok(VfsMetadata { len, fingerprint })
}
/// In-memory VFS.
#[derive(Clone, Debug, Default)]
pub struct MemoryVfs {
files: BTreeMap<String, Arc<[u8]>>,
files: BTreeMap<Vec<u8>, Arc<[u8]>>,
lookup: BTreeMap<Vec<u8>, Vec<Vec<u8>>>,
}
impl MemoryVfs {
/// Inserts a file.
#[allow(clippy::needless_pass_by_value)]
pub fn insert(&mut self, path: NormalizedPath, bytes: Arc<[u8]>) {
self.files.insert(path.as_str().to_string(), bytes);
let path = path.as_bytes().to_vec();
self.files.insert(path, bytes);
self.rebuild_lookup();
}
fn rebuild_lookup(&mut self) {
self.lookup.clear();
for path in self.files.keys() {
self.lookup
.entry(ascii_lookup_key(path).0)
.or_default()
.push(path.clone());
}
for paths in self.lookup.values_mut() {
paths.sort();
}
}
fn resolve_path(&self, path: &NormalizedPath) -> Result<&[u8], VfsError> {
let key = ascii_lookup_key(path.as_bytes()).0;
let matches = self
.lookup
.get(&key)
.ok_or_else(|| VfsError::NotFound(path.display_lossy().to_string()))?;
match matches.as_slice() {
[single] => Ok(single.as_slice()),
[] => Err(VfsError::NotFound(path.display_lossy().to_string())),
_ => Err(VfsError::Ambiguous(path.display_lossy().to_string())),
}
}
}
#[cfg(unix)]
#[allow(clippy::unnecessary_wraps)]
fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
Some(metadata.dev().rotate_left(32) ^ metadata.ino())
}
#[cfg(windows)]
#[allow(clippy::unnecessary_wraps)]
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(
metadata.creation_time().rotate_left(17)
^ metadata.last_write_time().rotate_left(31)
^ u64::from(metadata.file_attributes()).rotate_left(47),
)
}
#[cfg(not(any(unix, windows)))]
fn file_identity(_metadata: &fs::Metadata) -> Option<u64> {
None
}
impl Vfs for MemoryVfs {
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
let resolved = self.resolve_path(path)?;
let bytes = self
.files
.get(path.as_str())
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
.get(resolved)
.ok_or_else(|| VfsError::NotFound(path.display_lossy().to_string()))?;
Ok(VfsMetadata {
len: bytes.len() as u64,
fingerprint: stable_hash(bytes),
fingerprint: sha256(bytes),
})
}
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
let resolved = self.resolve_path(path)?;
self.files
.get(path.as_str())
.get(resolved)
.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> {
let mut out = Vec::new();
for (path, bytes) in &self.files {
if path
.as_bytes()
.get(..prefix.as_str().len())
.is_some_and(|head| head.eq_ignore_ascii_case(prefix.as_str().as_bytes()))
{
let normalized = fparkan_path::normalize_relative(
path.as_bytes(),
fparkan_path::PathPolicy::StrictLegacy,
)
.map_err(|_| VfsError::Path)?;
if has_segment_boundary_prefix_bytes(path, prefix.as_bytes()) {
let normalized =
fparkan_path::normalize_relative(path, fparkan_path::PathPolicy::StrictLegacy)
.map_err(|_| VfsError::Path)?;
out.push(VfsEntry {
path: normalized,
metadata: VfsMetadata {
len: bytes.len() as u64,
fingerprint: stable_hash(bytes),
fingerprint: sha256(bytes),
},
});
}
@@ -272,20 +412,23 @@ impl Vfs for MemoryVfs {
}
}
fn stable_hash(bytes: &[u8]) -> u64 {
let mut state = 0xcbf2_9ce4_8422_2325;
for byte in bytes {
state ^= u64::from(*byte);
state = state.wrapping_mul(0x0000_0100_0000_01b3);
fn has_segment_boundary_prefix_bytes(haystack: &[u8], needle: &[u8]) -> bool {
if haystack.len() < needle.len() {
return false;
}
state
}
fn hash_u64(state: &mut u64, value: u64) {
for byte in value.to_le_bytes() {
*state ^= u64::from(byte);
*state = state.wrapping_mul(0x0000_0100_0000_01b3);
if haystack.len() == needle.len() {
return haystack
.iter()
.zip(needle.iter())
.all(|(left, right)| left.eq_ignore_ascii_case(right));
}
if haystack[needle.len()] != b'/' {
return false;
}
haystack[..needle.len()]
.iter()
.zip(needle.iter())
.all(|(left, right)| left.eq_ignore_ascii_case(right))
}
/// Layered VFS with deterministic first-layer precedence.
@@ -330,7 +473,7 @@ impl Vfs for OverlayVfs {
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> {
@@ -341,7 +484,7 @@ impl Vfs for OverlayVfs {
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> {
@@ -350,7 +493,7 @@ impl Vfs for OverlayVfs {
match layer.list(prefix) {
Ok(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);
}
}
@@ -359,7 +502,7 @@ impl Vfs for OverlayVfs {
}
}
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)
}
}
@@ -368,6 +511,10 @@ impl Vfs for OverlayVfs {
mod tests {
use super::*;
use fparkan_path::{normalize_relative, PathPolicy};
#[cfg(unix)]
use std::ffi::OsString;
#[cfg(unix)]
use std::os::unix::ffi::OsStringExt;
#[test]
fn directory_vfs_resolves_ascii_casefolded_segments() {
@@ -384,6 +531,36 @@ mod tests {
std::fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn directory_vfs_reports_casefold_ambiguity_even_for_exact_host_path() {
let root = unique_test_dir("casefold-ambiguous");
std::fs::create_dir_all(root.join("Data")).expect("mkdir first");
std::fs::create_dir_all(root.join("data")).expect("mkdir second");
std::fs::write(root.join("Data").join("File.bin"), b"first").expect("write first");
std::fs::write(root.join("data").join("File.bin"), b"second").expect("write second");
let collision_count = std::fs::read_dir(&root)
.expect("read root")
.flatten()
.filter(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| name.eq_ignore_ascii_case("data"))
})
.count();
if collision_count < 2 {
std::fs::remove_dir_all(root).expect("cleanup");
return;
}
let vfs = DirectoryVfs::new(&root);
let path = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("path");
assert!(matches!(vfs.read(&path), Err(VfsError::Ambiguous(_))));
std::fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn directory_vfs_lists_files_below_prefix() {
let root = unique_test_dir("list");
@@ -403,6 +580,88 @@ mod tests {
std::fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn memory_vfs_list_prefix_is_boundary_safe() {
let mut vfs = MemoryVfs::default();
let exact = normalize_relative(b"DATA/Land.map", PathPolicy::StrictLegacy).expect("path");
let sibling =
normalize_relative(b"DATA2/Land.map", PathPolicy::StrictLegacy).expect("path");
vfs.insert(exact.clone(), Arc::from(b"exact".as_slice()));
vfs.insert(sibling, Arc::from(b"sibling".as_slice()));
let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
let entries = vfs.list(&prefix).expect("list");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].path.as_str(), exact.as_str());
}
#[test]
fn directory_vfs_fingerprint_changes_for_same_length_content() {
let root = unique_test_dir("content-fingerprint");
std::fs::create_dir_all(root.join("DATA")).expect("mkdir");
std::fs::write(root.join("DATA").join("File.bin"), b"before").expect("write before");
let vfs = DirectoryVfs::new(&root);
let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path");
let before = vfs.metadata(&path).expect("before metadata");
std::fs::write(root.join("DATA").join("File.bin"), b"after!").expect("write after");
let after = vfs.metadata(&path).expect("after metadata");
assert_eq!(before.len, after.len);
assert_ne!(before.fingerprint, after.fingerprint);
std::fs::remove_dir_all(root).expect("cleanup");
}
#[cfg(unix)]
#[test]
fn directory_vfs_rejects_symlink_escape() {
let root = unique_test_dir("symlink-escape");
let outside = unique_test_dir("symlink-outside");
std::fs::create_dir_all(&root).expect("mkdir root");
std::fs::create_dir_all(&outside).expect("mkdir outside");
std::fs::write(outside.join("secret.bin"), b"secret").expect("write outside");
std::os::unix::fs::symlink(&outside, root.join("DATA")).expect("symlink");
let vfs = DirectoryVfs::new(&root);
let path = normalize_relative(b"DATA/secret.bin", PathPolicy::StrictLegacy).expect("path");
let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
assert!(matches!(vfs.read(&path), Err(VfsError::Path)));
assert!(matches!(vfs.list(&prefix), Err(VfsError::Path)));
std::fs::remove_dir_all(root).expect("cleanup root");
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]
fn casefold_selector_reports_ambiguous_segments() {
let err = select_casefolded_match(
@@ -417,7 +676,7 @@ mod tests {
}
#[test]
fn memory_vfs_uses_exact_lookup() {
fn memory_vfs_uses_ascii_casefold_lookup() {
let path = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("path");
let mut vfs = MemoryVfs::default();
vfs.insert(path.clone(), Arc::from(b"payload".as_slice()));
@@ -427,7 +686,43 @@ mod tests {
let other_case =
normalize_relative(b"data/file.bin", PathPolicy::StrictLegacy).expect("path");
assert!(matches!(vfs.read(&other_case), Err(VfsError::NotFound(_))));
assert_eq!(
vfs.read(&other_case).expect("casefold read").as_ref(),
b"payload"
);
}
#[test]
fn memory_vfs_reports_casefold_ambiguity() {
let first = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("first");
let second =
normalize_relative(b"DATA/file.BIN", PathPolicy::StrictLegacy).expect("second");
let query = normalize_relative(b"data/file.bin", PathPolicy::StrictLegacy).expect("query");
let mut vfs = MemoryVfs::default();
vfs.insert(first, Arc::from(b"first".as_slice()));
vfs.insert(second, Arc::from(b"second".as_slice()));
assert!(matches!(vfs.read(&query), Err(VfsError::Ambiguous(_))));
}
#[test]
fn memory_vfs_distinguishes_non_utf8_path_bytes() {
let mut vfs = MemoryVfs::default();
let ascii =
normalize_relative(b"DATA/normal.bin", PathPolicy::HostCompatible).expect("ascii path");
let binary =
normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible).expect("binary path");
vfs.insert(ascii.clone(), Arc::from(b"ascii".as_slice()));
vfs.insert(binary.clone(), Arc::from(b"binary".as_slice()));
let binary_query =
normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible).expect("binary query");
assert_eq!(
vfs.read(&binary_query).expect("read binary").as_ref(),
b"binary"
);
assert_eq!(vfs.read(&ascii).expect("read ascii").as_ref(), b"ascii");
}
#[test]
@@ -447,6 +742,34 @@ mod tests {
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 {
let mut path = std::env::temp_dir();
path.push(format!("fparkan-vfs-{name}-{}", std::process::id()));
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
repository.workspace = true
[dependencies]
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
[lints]
workspace = true
+437 -58
View File
@@ -1,8 +1,30 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Deterministic world identity, queue, lifecycle, and snapshots.
use fparkan_binary::sha256;
use std::collections::VecDeque;
const WORLD_STATE_HASH_SCHEMA: &[u8] = b"fparkan-world-state-v3\0";
/// Object handle with generation.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct ObjectHandle {
@@ -28,6 +50,20 @@ pub struct Tick(pub u64);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct StateHash(pub [u8; 32]);
/// Exact source transform carried by one world object.
///
/// Values are IEEE-754 bit patterns so mission transforms retain their input
/// identity in deterministic state before a controller interprets them.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct TransformState {
/// Position words in source axis order.
pub position: [u32; 3],
/// Orientation words in source axis order.
pub orientation: [u32; 3],
/// Non-uniform scale words in source axis order.
pub scale: [u32; 3],
}
/// World phase.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WorldPhase {
@@ -88,6 +124,8 @@ pub struct WorldSnapshot {
pub tick: Tick,
/// Live object handles.
pub objects: Vec<ObjectHandle>,
/// Exact transforms for live registered objects in object-handle order.
pub transforms: Vec<(ObjectHandle, TransformState)>,
/// Commands processed during this step.
pub events: Vec<WorldEvent>,
/// State hash.
@@ -105,6 +143,8 @@ pub struct FixedStepClock {
tick: Tick,
paused: bool,
platform_event_collections: u64,
dropped_presentation_millis: u64,
dropped_presentation_frames: u64,
}
/// Fixed-step configuration.
@@ -112,11 +152,16 @@ pub struct FixedStepClock {
pub struct FixedStepConfig {
/// Milliseconds per simulation tick.
pub step_millis: u32,
/// Maximum simulation ticks executed for a single presentation frame.
pub max_steps_per_frame: u32,
}
impl Default for FixedStepConfig {
fn default() -> Self {
Self { step_millis: 16 }
Self {
step_millis: 16,
max_steps_per_frame: 8,
}
}
}
@@ -138,6 +183,7 @@ struct Slot {
owner_id: Option<OwnerId>,
mirror_id: Option<OriginalObjectId>,
registration_sequence: Option<u64>,
transform: TransformState,
}
/// World.
@@ -169,7 +215,17 @@ pub enum WorldError {
impl std::fmt::Display for WorldError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{self:?}")
match self {
Self::InvalidHandle => write!(f, "object handle does not reference a known slot"),
Self::StaleHandle => write!(f, "object handle belongs to an older slot generation"),
Self::Deleted => write!(f, "object has already been deleted"),
Self::DuplicateOriginalObjectId(id) => {
write!(f, "original object id {} is already registered", id.0)
}
Self::InvalidFixedStep => {
write!(f, "fixed-step configuration values must be non-zero")
}
}
}
}
@@ -209,6 +265,7 @@ pub fn construct_object(world: &mut World, draft: ObjectDraft) -> Result<ObjectH
owner_id: None,
mirror_id: None,
registration_sequence: None,
transform: TransformState::default(),
});
Ok(handle)
}
@@ -296,6 +353,43 @@ pub fn identity_metadata(
})
}
/// Sets the exact source transform for a live object.
///
/// # Errors
///
/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
pub fn set_transform(
world: &mut World,
handle: ObjectHandle,
transform: TransformState,
) -> Result<(), WorldError> {
checked_slot_mut(world, handle)?.transform = transform;
Ok(())
}
/// Returns the exact source transform for a live object.
///
/// # Errors
///
/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
pub fn transform_state(world: &World, handle: ObjectHandle) -> Result<TransformState, WorldError> {
Ok(checked_slot(world, handle)?.transform)
}
/// Returns the live registered handle carrying an original mission id.
#[must_use]
pub fn handle_by_original_id(world: &World, original_id: OriginalObjectId) -> Option<ObjectHandle> {
world.slots.iter().enumerate().find_map(|(index, slot)| {
let index = u32::try_from(index).ok()?;
(slot.live && slot.registered && slot.original_id == Some(original_id)).then_some(
ObjectHandle {
generation: slot.generation,
slot: index,
},
)
})
}
/// Requests deletion.
///
/// # Errors
@@ -357,68 +451,92 @@ pub fn step_with_handler<F>(
where
F: FnMut(&mut World, &WorldCommand) -> Result<(), WorldError>,
{
let before = world.clone();
world.phase = WorldPhase::Calculating;
let mut events = Vec::new();
while let Some(command) = world.queue.pop_front() {
if let Some(handle) = command.target {
if world.deferred_delete.contains(&handle) {
continue;
let result = (|| {
while let Some(command) = world.queue.pop_front() {
if let Some(handle) = command.target {
if world.deferred_delete.contains(&handle) {
continue;
}
checked_slot(world, handle)?;
}
checked_slot(world, handle)?;
handler(world, &command)?;
events.push(WorldEvent {
sequence: command.sequence,
target: command.target,
});
}
handler(world, &command)?;
events.push(WorldEvent {
sequence: command.sequence,
target: command.target,
});
world.phase = WorldPhase::ApplyingDeferred;
let deletes = std::mem::take(&mut world.deferred_delete);
for handle in deletes {
delete_now(world, handle)?;
}
world.tick.0 = world.tick.0.saturating_add(1);
world.phase = WorldPhase::PublishingSnapshot;
let snapshot = WorldSnapshot {
tick: world.tick,
objects: live_registered(world),
transforms: live_registered_with_transforms(world),
events,
hash: canonical_state_hash(world),
};
world.phase = WorldPhase::Idle;
Ok(snapshot)
})();
if let Err(err) = result {
*world = before;
world.phase = WorldPhase::Idle;
return Err(err);
}
world.phase = WorldPhase::ApplyingDeferred;
let deletes = std::mem::take(&mut world.deferred_delete);
for handle in deletes {
let _ = delete_now(world, handle);
}
world.tick.0 = world.tick.0.saturating_add(1);
world.phase = WorldPhase::PublishingSnapshot;
let snapshot = WorldSnapshot {
tick: world.tick,
objects: live_registered(world),
events,
hash: canonical_state_hash(world),
};
world.phase = WorldPhase::Idle;
Ok(snapshot)
result
}
/// Computes canonical state hash.
#[must_use]
pub fn canonical_state_hash(world: &World) -> StateHash {
let mut state = 0xcbf2_9ce4_8422_2325_u64;
hash_u64(&mut state, world.tick.0);
StateHash(sha256(&canonical_state_bytes(world)))
}
fn canonical_state_bytes(world: &World) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(WORLD_STATE_HASH_SCHEMA);
push_u64(&mut out, world.tick.0);
push_u64(&mut out, world.next_sequence);
push_u64(&mut out, world.next_registration_sequence);
push_len(&mut out, world.slots.len());
for (idx, slot) in world.slots.iter().enumerate() {
hash_u64(&mut state, idx as u64);
hash_u64(&mut state, u64::from(slot.generation));
hash_u64(&mut state, u64::from(u8::from(slot.live)));
hash_u64(&mut state, u64::from(u8::from(slot.registered)));
hash_u64(&mut state, slot.original_id.map_or(0, |id| u64::from(id.0)));
hash_u64(&mut state, slot.mirror_id.map_or(0, |id| u64::from(id.0)));
hash_u64(&mut state, slot.owner_id.map_or(0, |id| u64::from(id.0)));
hash_u64(&mut state, slot.registration_sequence.unwrap_or(u64::MAX));
push_len(&mut out, idx);
push_u32(&mut out, slot.generation);
push_bool(&mut out, slot.live);
push_bool(&mut out, slot.registered);
push_optional_u32(&mut out, slot.original_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_u64(&mut out, slot.registration_sequence);
push_transform(&mut out, slot.transform);
}
let mut out = [0; 32];
out[..8].copy_from_slice(&state.to_le_bytes());
out[8..16].copy_from_slice(&state.rotate_left(13).to_le_bytes());
out[16..24].copy_from_slice(&state.rotate_left(29).to_le_bytes());
out[24..32].copy_from_slice(&state.rotate_left(47).to_le_bytes());
StateHash(out)
push_len(&mut out, world.queue.len());
for command in &world.queue {
push_u64(&mut out, command.sequence);
push_optional_handle(&mut out, command.target);
}
push_len(&mut out, world.deferred_delete.len());
for handle in &world.deferred_delete {
push_handle(&mut out, *handle);
}
out
}
/// Creates a fixed-step clock.
///
/// # Errors
///
/// Returns [`WorldError::InvalidFixedStep`] when the configured step is zero.
/// Returns [`WorldError::InvalidFixedStep`] when the configured step or
/// per-frame catch-up limit is zero.
pub fn fixed_step_clock(config: FixedStepConfig) -> Result<FixedStepClock, WorldError> {
if config.step_millis == 0 {
if config.step_millis == 0 || config.max_steps_per_frame == 0 {
return Err(WorldError::InvalidFixedStep);
}
Ok(FixedStepClock {
@@ -426,6 +544,8 @@ pub fn fixed_step_clock(config: FixedStepConfig) -> Result<FixedStepClock, World
tick: Tick(0),
paused: false,
platform_event_collections: 0,
dropped_presentation_millis: 0,
dropped_presentation_frames: 0,
})
}
@@ -445,13 +565,14 @@ pub fn set_paused(clock: &mut FixedStepClock, paused: bool) {
///
/// # Errors
///
/// Returns [`WorldError::InvalidFixedStep`] when the configured step is zero.
/// Returns [`WorldError::InvalidFixedStep`] when the configured step or
/// per-frame catch-up limit is zero.
pub fn advance_fixed_step(
clock: &mut FixedStepClock,
config: FixedStepConfig,
elapsed_millis: u64,
) -> Result<u32, WorldError> {
if config.step_millis == 0 {
if config.step_millis == 0 || config.max_steps_per_frame == 0 {
return Err(WorldError::InvalidFixedStep);
}
if clock.paused {
@@ -459,12 +580,20 @@ pub fn advance_fixed_step(
}
clock.accumulated_millis = clock.accumulated_millis.saturating_add(elapsed_millis);
let step = u64::from(config.step_millis);
let mut ticks = 0_u32;
while clock.accumulated_millis >= step {
clock.accumulated_millis -= step;
clock.tick.0 = clock.tick.0.saturating_add(1);
ticks = ticks.saturating_add(1);
let available_steps = clock.accumulated_millis / step;
let ticks_u64 = available_steps.min(u64::from(config.max_steps_per_frame));
let consumed = ticks_u64.saturating_mul(step);
if available_steps > u64::from(config.max_steps_per_frame) {
let dropped = clock.accumulated_millis.saturating_sub(consumed);
clock.dropped_presentation_millis =
clock.dropped_presentation_millis.saturating_add(dropped);
clock.dropped_presentation_frames = clock.dropped_presentation_frames.saturating_add(1);
clock.accumulated_millis = 0;
} else {
clock.accumulated_millis = clock.accumulated_millis.saturating_sub(consumed);
}
let ticks = u32::try_from(ticks_u64).unwrap_or(u32::MAX);
clock.tick.0 = clock.tick.0.saturating_add(ticks_u64);
Ok(ticks)
}
@@ -480,6 +609,18 @@ pub fn platform_event_collections(clock: &FixedStepClock) -> u64 {
clock.platform_event_collections
}
/// Returns total presentation time dropped by fixed-step catch-up limits.
#[must_use]
pub fn dropped_presentation_millis(clock: &FixedStepClock) -> u64 {
clock.dropped_presentation_millis
}
/// Returns how many presentation frames exceeded fixed-step catch-up limits.
#[must_use]
pub fn dropped_presentation_frames(clock: &FixedStepClock) -> u64 {
clock.dropped_presentation_frames
}
/// Runs end-frame callbacks in stable sequence order.
#[must_use]
pub fn end_frame_callback_order(mut callbacks: Vec<WorldEvent>) -> Vec<u64> {
@@ -502,10 +643,67 @@ pub fn shutdown(mut world: World) -> ShutdownReport {
}
}
fn hash_u64(state: &mut u64, value: u64) {
for byte in value.to_le_bytes() {
*state ^= u64::from(byte);
*state = state.wrapping_mul(0x0000_0100_0000_01b3);
fn push_len(out: &mut Vec<u8>, value: usize) {
push_u64(out, u64::try_from(value).unwrap_or(u64::MAX));
}
fn push_u64(out: &mut Vec<u8>, value: u64) {
out.extend_from_slice(&value.to_le_bytes());
}
fn push_u32(out: &mut Vec<u8>, value: u32) {
out.extend_from_slice(&value.to_le_bytes());
}
fn push_u16(out: &mut Vec<u8>, value: u16) {
out.extend_from_slice(&value.to_le_bytes());
}
fn push_bool(out: &mut Vec<u8>, value: bool) {
out.push(u8::from(value));
}
fn push_optional_u64(out: &mut Vec<u8>, value: Option<u64>) {
push_bool(out, value.is_some());
if let Some(value) = value {
push_u64(out, value);
}
}
fn push_optional_u32(out: &mut Vec<u8>, value: Option<u32>) {
push_bool(out, value.is_some());
if let Some(value) = value {
push_u32(out, value);
}
}
fn push_optional_u16(out: &mut Vec<u8>, value: Option<u16>) {
push_bool(out, value.is_some());
if let Some(value) = value {
push_u16(out, value);
}
}
fn push_optional_handle(out: &mut Vec<u8>, handle: Option<ObjectHandle>) {
push_bool(out, handle.is_some());
if let Some(handle) = handle {
push_handle(out, handle);
}
}
fn push_handle(out: &mut Vec<u8>, handle: ObjectHandle) {
push_u32(out, handle.generation);
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);
}
}
@@ -559,6 +757,24 @@ fn live_registered(world: &World) -> Vec<ObjectHandle> {
.collect()
}
fn live_registered_with_transforms(world: &World) -> Vec<(ObjectHandle, TransformState)> {
world
.slots
.iter()
.enumerate()
.filter_map(|(idx, slot)| {
let slot_index = u32::try_from(idx).ok()?;
(slot.live && slot.registered).then_some((
ObjectHandle {
generation: slot.generation,
slot: slot_index,
},
slot.transform,
))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -574,6 +790,25 @@ mod tests {
assert_eq!(after.objects, vec![handle]);
}
#[test]
fn transform_is_exactly_snapshotted_and_changes_state_hash() {
let mut world = new(WorldConfig);
let handle =
construct_object(&mut world, ObjectDraft { original_id: None }).expect("object");
register_object(&mut world, handle).expect("register");
let before = step(&mut world, &InputSnapshot).expect("before");
let transform = TransformState {
position: [1.0_f32.to_bits(), (-2.0_f32).to_bits(), 3.0_f32.to_bits()],
orientation: [0.0_f32.to_bits(), 0.5_f32.to_bits(), (-0.25_f32).to_bits()],
scale: [1.0_f32.to_bits(), 2.0_f32.to_bits(), 0.5_f32.to_bits()],
};
set_transform(&mut world, handle, transform).expect("set transform");
let after = step(&mut world, &InputSnapshot).expect("after");
assert_eq!(transform_state(&world, handle), Ok(transform));
assert_eq!(after.transforms, vec![(handle, transform)]);
assert_ne!(before.hash, after.hash);
}
#[test]
fn registration_sequence_stale_and_duplicate_original_contracts() {
let mut world = new(WorldConfig);
@@ -593,10 +828,16 @@ mod tests {
.expect("second");
register_object(&mut world, first).expect("register first");
register_object(&mut world, second).expect("register second");
assert_eq!(
handle_by_original_id(&world, OriginalObjectId(7)),
Some(first)
);
assert_eq!(handle_by_original_id(&world, OriginalObjectId(9)), None);
assert_eq!(registration_sequence(&world, first), Ok(Some(0)));
assert_eq!(registration_sequence(&world, second), Ok(Some(1)));
request_delete(&mut world, first).expect("delete");
assert_eq!(handle_by_original_id(&world, OriginalObjectId(7)), None);
assert_eq!(
register_object(&mut world, first),
Err(WorldError::StaleHandle)
@@ -623,6 +864,18 @@ mod tests {
);
}
#[test]
fn world_error_display_is_actionable() {
assert_eq!(
WorldError::StaleHandle.to_string(),
"object handle belongs to an older slot generation"
);
assert_eq!(
WorldError::DuplicateOriginalObjectId(OriginalObjectId(8)).to_string(),
"original object id 8 is already registered"
);
}
#[test]
fn identity_metadata_keeps_original_mirror_and_owner_distinct() {
let mut world = new(WorldConfig);
@@ -710,6 +963,44 @@ mod tests {
);
}
#[test]
fn callback_error_rolls_back_phase_queue_and_deferred_deletes() {
let mut world = new(WorldConfig);
let first = construct_object(&mut world, ObjectDraft { original_id: None }).expect("first");
register_object(&mut world, first).expect("register");
enqueue(
&mut world,
WorldCommand {
sequence: 7,
target: Some(first),
},
)
.expect("enqueue");
let err = step_with_handler(&mut world, &InputSnapshot, |world, _| {
request_delete(world, first)?;
Err(WorldError::InvalidFixedStep)
})
.expect_err("handler error");
assert_eq!(err, WorldError::InvalidFixedStep);
assert_eq!(world.phase, WorldPhase::Idle);
assert_eq!(world.tick, Tick(0));
assert!(world.deferred_delete.is_empty());
assert_eq!(world.queue.len(), 1);
let snapshot = step(&mut world, &InputSnapshot).expect("retry step");
assert_eq!(snapshot.tick, Tick(1));
assert_eq!(
snapshot.events,
vec![WorldEvent {
sequence: 0,
target: Some(first)
}]
);
assert_eq!(snapshot.objects, vec![first]);
}
#[test]
fn snapshot_hash_determinism_and_immutability() {
let mut left = new(WorldConfig);
@@ -736,9 +1027,40 @@ mod tests {
);
}
#[test]
fn state_hash_uses_canonical_sha256_instead_of_legacy_rotated_fnv() {
let mut world = new(WorldConfig);
let handle = construct_object(
&mut world,
ObjectDraft {
original_id: Some(OriginalObjectId(42)),
},
)
.expect("object");
set_mirror_original(&mut world, handle, Some(OriginalObjectId(420))).expect("mirror");
set_owner(&mut world, handle, Some(OwnerId(9))).expect("owner");
register_object(&mut world, handle).expect("register");
enqueue(
&mut world,
WorldCommand {
sequence: 999,
target: Some(handle),
},
)
.expect("enqueue");
let snapshot = step(&mut world, &InputSnapshot).expect("step");
assert_ne!(snapshot.hash, legacy_rotated_fnv_hash(&world));
assert_ne!(snapshot.hash, canonical_state_hash(&new(WorldConfig)));
}
#[test]
fn fixed_step_pause_and_long_determinism_are_stable() {
let config = FixedStepConfig { step_millis: 20 };
let config = FixedStepConfig {
step_millis: 20,
max_steps_per_frame: 8,
};
let mut clock = fixed_step_clock(config).expect("clock");
collect_platform_events(&mut clock);
set_paused(&mut clock, true);
@@ -762,6 +1084,32 @@ mod tests {
assert_eq!(first_hashes, second_hashes);
}
#[test]
fn fixed_step_catch_up_is_capped_and_reports_dropped_time() {
let config = FixedStepConfig {
step_millis: 20,
max_steps_per_frame: 3,
};
let mut clock = fixed_step_clock(config).expect("clock");
assert_eq!(advance_fixed_step(&mut clock, config, 95), Ok(3));
assert_eq!(fixed_step_tick(&clock), Tick(3));
assert_eq!(dropped_presentation_millis(&clock), 35);
assert_eq!(dropped_presentation_frames(&clock), 1);
assert_eq!(advance_fixed_step(&mut clock, config, 10), Ok(0));
assert_eq!(advance_fixed_step(&mut clock, config, 10), Ok(1));
assert_eq!(fixed_step_tick(&clock), Tick(4));
assert_eq!(dropped_presentation_millis(&clock), 35);
assert_eq!(dropped_presentation_frames(&clock), 1);
assert_eq!(
advance_fixed_step(&mut clock, config, u64::MAX),
Ok(config.max_steps_per_frame)
);
assert_eq!(dropped_presentation_frames(&clock), 2);
}
#[test]
fn render_disabled_does_not_change_hash_end_callbacks_and_shutdown_order() {
let callbacks = vec![
@@ -816,7 +1164,7 @@ mod tests {
handles.push(handle);
}
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");
} else {
enqueue(
@@ -837,4 +1185,35 @@ mod tests {
}
}
}
fn legacy_rotated_fnv_hash(world: &World) -> StateHash {
let mut state = 0xcbf2_9ce4_8422_2325_u64;
legacy_hash_u64(&mut state, world.tick.0);
for (idx, slot) in world.slots.iter().enumerate() {
legacy_hash_u64(
&mut state,
u64::try_from(idx).expect("slot index should fit"),
);
legacy_hash_u64(&mut state, u64::from(slot.generation));
legacy_hash_u64(&mut state, u64::from(u8::from(slot.live)));
legacy_hash_u64(&mut state, u64::from(u8::from(slot.registered)));
legacy_hash_u64(&mut state, slot.original_id.map_or(0, |id| u64::from(id.0)));
legacy_hash_u64(&mut state, slot.mirror_id.map_or(0, |id| u64::from(id.0)));
legacy_hash_u64(&mut state, slot.owner_id.map_or(0, |id| u64::from(id.0)));
legacy_hash_u64(&mut state, slot.registration_sequence.unwrap_or(u64::MAX));
}
let mut out = [0; 32];
out[..8].copy_from_slice(&state.to_le_bytes());
out[8..16].copy_from_slice(&state.rotate_left(13).to_le_bytes());
out[16..24].copy_from_slice(&state.rotate_left(29).to_le_bytes());
out[24..32].copy_from_slice(&state.rotate_left(47).to_le_bytes());
StateHash(out)
}
fn legacy_hash_u64(state: &mut u64, value: u64) {
for byte in value.to_le_bytes() {
*state ^= u64::from(byte);
*state = state.wrapping_mul(0x0000_0100_0000_01b3);
}
}
}
+30
View File
@@ -0,0 +1,30 @@
[graph]
all-features = true
[advisories]
yanked = "deny"
[bans]
multiple-versions = "allow"
wildcards = "deny"
deny = [
{ name = "native-tls" },
{ name = "openssl" },
{ name = "openssl-sys" },
]
[licenses]
allow = [
"Apache-2.0",
"BSD-3-Clause",
"GPL-2.0-only",
"ISC",
"MIT",
"Unicode-3.0",
"Zlib",
]
[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
+13 -27
View File
@@ -113,36 +113,22 @@ key, configuration, device profile, initial state, input/time script и верс
## Local evidence requests
На текущем рабочем месте закрыты статические, corpus и headless runtime gates.
Для macOS Desktop GL подтверждены безопасный command/state trace и offscreen
pixel capture:
Поддерживаемая desktop-платформа — только Windows/Vulkan. Локальный smoke
`fparkan-vulkan-smoke` уже подтверждает настоящий Win32 surface/swapchain,
300 кадров, controlled resize и отсутствие validation warnings/errors.
Это закрывает фундамент Stage 0, но не доказывает визуальный паритет игры.
- `cargo test -p fparkan-render-gl --offline desktop_gl33_triangle_command_capture`;
- `fixtures/acceptance/macos-gl33-triangle-capture.json`.
Следующие доказательства всё ещё нужны именно для Iron3D-compatible рендера:
`S3-GL-001` считается закрытым для текущей macOS-focused цели: временный
`rustc` probe создал CGL/OpenGL offscreen FBO, выполнил shader-based triangle
draw, прочитал RGBA pixels и сохранил hash capture. Probe не добавляет
project-owned `unsafe` в workspace; постоянный adapter API остаётся safe.
- capability-gated capture из оригинального GOG процесса с camera/matrix,
draw/state и frame-boundary provenance;
- фиксированные Windows Vulkan captures статической модели, lightmapped модели
и terrain после совпадения backend-neutral command capture;
- затем controlled captures анимации, FX, прозрачности, теней и атмосферы.
Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2
backend profile: GLES2 должен создать кадр, сохранить pixel capture и тот же
command/state trace. Локальный Docker probe существующего Rust image не нашёл
`libGL`, `libEGL`, `libGLES` или `libOSMesa`, поэтому закрытие этого gate требует
отдельно предоставленного Docker image с Rust + Mesa/EGL/OSMesa либо разрешения
на установку соответствующего проверочного окружения.
Для текущей macOS-focused цели `S3-GL-002`, `L3-DEVICE-001` и `L5-RG40-001`
помечены как `omitted`: они остаются требованиями portable target scope, но не
блокируют локальный macOS acceptance-аудит. При возврате RG40XX/GLES2 в область
цели эти gates снова должны требовать внешнего evidence.
`L3-DEVICE-001` и `L5-RG40-001` не закрываются локально без RG40XX H или
эквивалентного удалённого runner-а. Требуемое доказательство: запуск выбранной
миссии при 640x480 на целевом профиле, сохранённые stdout/stderr, build
fingerprint, manifest игрового каталога, frame/tick budget, memory budget и
итоговый pass/fail report. Desktop/headless результаты не считаются заменой
on-device smoke.
Linux/macOS, GLES2, RG40XX и удалённые portability runners не являются
acceptance-гейтами этого проекта. Они не должны появляться в списке блокеров
или подменять Windows evidence.
## Closure criteria
+50
View File
@@ -0,0 +1,50 @@
# Кампания, сохранения и восстановление сессии
## Известная файловая поверхность
Demo содержит `MISSIONS/dispatcher.ini` и `SAVE/saveslots.cfg`.
`dispatcher.ini` хранит campaign progression (в demo — `[COMPLETE]`), а
`saveslots.cfg` — ordered UI-метаданные slots, не полный snapshot мира.
В Части 2 slots 1 и 7 помечены занятыми без соответствующего payload; поэтому
проверяются независимо: наличие metadata record, физического файла и
format/version/integrity payload.
Campaign различает существование миссии, её доступность, старт, успешное или
неуспешное завершение и уже применённый результат. Обработка одного
mission-complete event идемпотентна. Пустой slot и повреждённый существующий
payload — разные состояния.
## Контракт standalone save
Полный snapshot сохраняет campaign/mission context, time/pause/step phase,
stable object IDs, ownership, transforms, lifecycle/properties/cross-links,
world changes, Behavior/Control/AI/research/economy state, script variables/IP/
timers, authoritative RNG, gameplay-relevant FX и queued messages. Camera/UI
можно сохранить как presentation context; GPU/audio handles и draw buffers
восстанавливаются, а не сериализуются.
Снимок разрешён только после calculation и deferred operations, вне queue
traversal, после применённых network messages и до чтения mutable state
renderer-ом. Native pointers, vtable/allocator addresses и resource mapping
pointers запрещены: ссылки идут через stable IDs и resource keys.
Новый формат — versioned chunks (`WORLD`, `OBJECTS`, `BEHAVIOR`, `PHYSICS`,
`AI`, `SCRIPT`, `RESEARCH`, `RNG`, `CAMERA_UI`, optional network) с magic,
format/profile/content fingerprint, size и checksum. Неизвестный optional
chunk пропускается; required chunk блокирует load. Это дизайн FParkan, не
утверждение о binary format оригинала.
Запись транзакционна: временный файл → повторное чтение/checksum → fs sync →
атомарная замена payload → обновление slot metadata. Загрузка создаёт mission
и objects без публикации, восстанавливает IDs/cross-links/controllers/RNG,
регистрирует их, валидирует и только затем разрешает следующий tick.
## Проверки и граница
`save -> load` обязан давать тот же canonical state hash, OriginalObjectId,
cross-links и продолжение RNG. Corrupt required chunk не публикует частичный
мир; crash при записи не уничтожает старый slot; completion event идемпотентен.
Для native format нужны controlled original saves, binary diffs и trace
serializer-а. До этого FParkan может иметь совместимую семантику собственных
saves, но не заявляет byte/network interoperability с оригинальными файлами.
+335
View File
@@ -0,0 +1,335 @@
# Сценарная VM, формулы и игровые свойства
## Подтверждённый surface
Миссионный сценарный слой задаёт стартовые события, completion/failure,
messages, teleports, задачи, research и campaign transitions. Точки входа и
файлы: `ai.dll: CreateSuperAI/GetSuperAI`, `MisLoad.dll: LoadResearch`,
`ArealMap.dll: CalcFullResearchCost`, `MISSIONS/SCRIPTS/*.scr`, `*.fml`,
`*.trf`, `varset.var`, `MISSIONS/dispatcher.ini`, `mission.cfg`, `messages.cfg`
и `briefing.cfg`.
`.scr` — binary package с version checks, symbol/event sections и offsets;
полная opcode grammar не доказана. Его внешний framing теперь читает
`fparkan-script`: первый little-endian `u32` является числом required opcode
handlers, второй — числом event records. Каждый event хранит `name_len`,
`name_len + 1` raw bytes с обязательным NUL, opaque event word и count вложенных
records. Вложенный record сохраняет семь `u32` header words (в disk order),
список `u32` references после шестого header word и trailing seventh word.
Никакой из этих words ещё не получает semantic name. `.fml` — текстовый
symbol/formula oracle; `varset.var` задаёт `VAR(...)`/`STRING(...)` defaults.
`fparkan-script::parse_varset` уже читает подтверждённые numeric
`VAR(float|DWORD, name, default)` declarations byte-safe (comments остаются
opaque, поэтому legacy non-UTF-8 text не ломает загрузку); `STRING(...)` и
`FUNCTION(...)` пока сохранены за границей этого numeric contract;
GOG `MISSIONS/SCRIPTS/varset.var` даёт через него ровно 231 declaration:
31 `float` и 200 `DWORD` (от `f0` до `fY`);
loader `ai.dll!0x10001000` сначала открывает `<bundle-base>.var` и только при
`not found` откатывается к этому shared file. Runtime повторяет данный порядок
транзакционно и публикует selected `MissionScriptVarSet` с путём/provenance, но
ещё не исполняет declarations как VM state;
`.trf` — NRes tables, чей framing подтверждён, а field semantics местами лишь
consumer-inferred.
## Безопасная модель исполнения
Новая VM разделяет immutable package (bytecode, symbols, events, constants),
per-mission variables/timers/frames, bindings logical-name/ObjectId/clan/
research key и typed commands к World3D/Behavior/UI/campaign. После varset
defaults и bindings она dispatches Init/start, на каждом tick обновляет timers,
ставит готовые events в стабильную очередь и исполняет bounded instruction
budget. Опасное удаление идёт через World3D queue и общий deferred lifecycle.
До восстановления opcode table package mode читает header/strings/symbols/
event offsets/raw bytecode losslessly. Статический анализ уже выделил отдельный
five-way evaluator condition records (`ai.dll` VA `0x10005180`): tags `1..5`,
type guards, object lookup и completion flag. Это не следует выдавать за
instruction dispatcher или jump table `.scr`: bytecode opcode table всё ещё
требует отдельного доказательства. Unknown opcode нельзя пропустить как один
byte: это ломает синхронизацию. Для каждого доказанного opcode фиксируются
number, size, operands, control flow, effects, errors и минимальный test.
GOG `ai.dll` доказывает этот framing двумя consumer-ами: loader по
`0x10001000` открывает `<bundle>.scr`, `varset.var`, `<bundle>.fml`, затем
собирает ровно 73 pointers handlers; `0x10011b20` читает описанную count-driven
структуру. Команда
```powershell
cargo run -p fparkan-cli -- script inspect `
'C:\GOG Games\Parkan - Iron Strategy\MISSIONS\SCRIPTS\c1m2p.scr' --format json
```
на исходном пакете возвращает `opcode_handler_count=73`, 9 events, 17 nested
records, 20 references и 0 trailing bytes. Это corpus evidence для reader-а,
но не разрешение на исполнение неизвестных 73 opcodes.
Теперь установлен selector: loader `0x10001000` создаёт 73 pointers в
фиксированном порядке, а `0x10011e70` копирует их без перестановки в runtime
array. Во всех 58 GOG `.scr` первый header word каждого nested record равен
`0..72` либо `0xffff_ffff`: соответственно 2095 handler selectors и 3992
sentinel records. Поэтому `ScriptInstruction::dispatch_selector()` возвращает
`Handler(0..72)`, `Sentinel` или сохраняемый `Unknown(u32)`. Первый handler
(`Handler(0)`, VA `0x10008034`) только устанавливает current context и flag
`+0x50 = 1`; это не даёт ему игрового имени и не заменяет runtime trace.
`Handler(1)` — второй table entry, VA `0x10007fd0`, — не создаёт игровую
команду. Он сохраняет active VM context, берёт один instruction-derived index
через current event/instruction offsets `+0x48/+0x4c`, а затем разрешает его
в varset object по `this + 0x18`. Resolver `0x10002d30` проверяет
`0 <= index < count` и возвращает record `base + index * 0x30`; invalid index
вызывает C++ exception, а не становится нулём. Полученный 48-byte record
передаётся в `0x10013190`, который возвращает x87 floating result: kinds `0`
и `4` идут через отдельный opaque conversion path, kind `1` — signed integer,
kind `2` выбирает одну из двух static scalar constants по нулевости payload,
kind `3` — float, kind `5` — unsigned integer; остальные и пустые cases дают
один fixed fallback scalar. Это доказанный numeric
bridge для VM, но пока не Rust handler: неизвестны точный disk operand slot,
ownership значения на FPU stack и следующий consumer, поэтому нельзя назвать
его арифметическим opcode или silently заменить portable `f32` execution.
Отдельный проход по всем 58 GOG `.scr` (6 087 instruction records) не нашёл
ни одного selector `1`: из них 2 095 записей выбирают один из handlers, а
3 992 являются sentinel. Значит, это установленная, но не corpus-reachable
ветка данного издания; её нельзя делать приоритетным execution path без
отдельного dynamic/evidence route.
`Handler(2)` (третья entry table, VA `0x10009610`) уже имеет статический
contract, но ещё не Rust execution: он выбирает active event/instruction через
runtime offsets `+0x48/+0x4c` и разрешает семь 32-bit slots через varset object
`+0x18`. Их доказанный dataflow: slot 0 даёт один `u32` и base string, slot 1
даёт numeric scalar, slots 2 и 3 — по `u32`, slots 4, 5 и 6 принимают только
kind `5`/`3` и иначе дают `0.0`. Затем он вызывает `0x100059f0` объекта по
`this + 0x7c` и очищает flag `+0x50`.
Этот callee больше не opaque. Он строит key из семи значений, ищет matching
record в своей collection по `this + 0x24` и при совпадении обновляет только
record fields `+0x0c` и `+0x14`, затем вызывает его refresh path `0x10005070`.
При отсутствии record он лениво ищет в event table имена `<base>_Start` и
`<base>_Continue`, сохраняет их IDs в indexed state и materializes новый
internal record. В этой ветке не видно прямого World3D/Behavior call, поэтому
это доказанная scheduler/event-record boundary, а не команда движения, атаки
или строительства. Semantic names семи slots и consumer нового record остаются
открытыми; до dynamic capture Rust возвращает явный
unsupported result, а не «примерный» game command.
У этой границы также нет скрытого immediate dispatch: после добавления новой
записи `0x100059f0` вызывает `0x1000f920`, а Ghidra 12.1.2 декомпилирует эту
функцию как пустой `return`. Следовательно, найденные `<base>_Start` и
`<base>_Continue` только кэшируются в scheduler state; их фактический consumer
находится в отдельном позднем update path. Воспроизводимый read-only extractor:
`tools/ghidra/ExportAiVmHandler2Dispatch.java`.
Corpus priority теперь измерен, а не предполагается: во всех 58 GOG `.scr`
имеются 6 087 instruction records, из них 3 992 sentinel; самый частый
non-sentinel selector — `Handler(30)`, 246 records. Его VA `0x1000c266`
читает первые два reference words активной instruction, разрешает каждый
через varset (`0x10002d30` и `0x10013570`) и вызывает внешний callback с
тремя `u32`: `(0, first, second)`. Callback не принадлежит `ai.dll`: его
кладёт десятый argument экспортного `CreateSuperAI`. Тот же callback встречен
у `Handler(57)` с первым word `2` и у отдельного lifecycle path с первым word
`1`; предметная семантика этих modes ещё не доказана. В частности, это пока
не основание назвать Handler(30) сообщением, приказом или UI opcode. Точный
text-to-varset resolver расположен за wrapper `0x10011ea0` в
`0x100174a0`. Воспроизводимые exports: `ExportAiVmHandler30.java`,
`FindAiVmHandler30Callback.java`, `ExportAiVarSetLoader.java`.
Следующий pass восстанавливает эту индексацию. `0x100174a0` добавляет каждый
recognized source declaration в encounter order как 48-byte record; GOG shared
`varset.var` не содержит `STRING(...)`, поэтому его 231 numeric `VAR` entries
образуют точно это index space. `0x10013570` возвращает `DWORD` record kind
raw `u32`; float kind проходит `__ftol`, чей x87 rounding profile ещё требует
capture. Полный GOG scan всех 246 Handler(30) instructions показывает 492
operand references: все 492 in-range и указывают на `DWORD`. Поэтому
`VarSet::resolve_handler30` уже materializes точный opaque callback command
`(mode=0, first, second)` для данного corpus path, но явно отклоняет float,
out-of-range и incomplete instructions вместо silent coercion. Extractors:
`ExportAiVarSetParser.java`, `ExportAiVarSetU32Resolver.java`.
Следующий static pass закрывает equality/update policy. Identity ровно равна
`(slot0 word, slot4 IEEE-754 bits, slot5 IEEE-754 bits)`, поэтому `-0.0` и
`+0.0` различаются. Новый 100-byte record получает slot1 в поле `+0x14`,
slot2 одновременно в `+0x24/+0x28`, slot3 в `+0x2c` и slot6 в `+0x0c`. При
совпавшем key refresh случается только когда slot1 сравнивается unequal
(включая NaN); он заменяет `+0x14` и `+0x0c`, затем прибавляет сохранённый
`+0x28` к `+0x24` с x86 wrapping arithmetic. `fparkan-script` отражает эту
изолированную часть как `Handler2RecordScheduler`; он не выполняет bytecode,
не назначает игровых имён и не делает event lookup за original VM.
На границе mission runtime выбранный TMA clan `first_resource` теперь
материализуется как отдельный `MissionScriptBundle`: loader нормализует
`<base>.scr`, декодирует его тем же bounded reader-ом и публикует immutable
package вместе с clan provenance. Headless report выводит число таких packages
и их named events. Это именно wiring входных данных, не VM execution: Init и
остальные events пока не dispatch-ятся, а ошибка чтения сохраняет
transactional rollback mission loader-а.
TMA properties остаются four raw `u32` words плюс имя, пока consumer/schema не
задаст тип (integer/float bits/ObjectId/enum/fixed-point/index). В том числе
сохраняются `NOT USED`; corpus подтверждает `Invulnerability`, life state,
`ClanID`, ore, speed и free-time properties.
Research/economy работают в simulation: `LoadResearch` и
`CalcFullResearchCost` доказывают данные и вычислимую стоимость, но не полный
layout prerequisites/modifiers/unlocks. Formula evaluator требует strict
grammar/version, typed operands, deterministic numeric policy, bounded stack и
явных errors; x87-compatible rounding нужен там, где оно выбирает ветку.
### Handler(19): AutoDemo Init varset initialization
`Handler(19)` is the twentieth VM-table entry at GOG `ai.dll` VA `0x1000aa38`.
It is the only instruction in the `Init` event of the two `default.scr` bundles
referenced by `MISSIONS\\Autodemo.00\\data.tma`; together those bundles account for
the observed 18 named script events. Each instruction references varset records
`224`, `225`, and `226`: `ClanBaseX`, `ClanBaseY`, and `ClanID` respectively.
The original writes three raw DWORD values in order. It converts the VM fields
at `+0x80` and `+0x84` through the x87 `__ftol` helper and stores the resulting
words into references 0 and 1. It copies the raw word from `+0x7c` into reference
2, then clears VM field `+0x50`. The default targets are `DWORD` records; the
shared setter preserves the incoming word for that type. Therefore this is not
a license to replace the first two conversions with Rust float casts: their
rounding behavior remains an x87 compatibility boundary until it has captured
test vectors.
The missing source-field provenance is now constrained by the public creation
boundary. `CreateSuperAI` at `ai.dll` VA `0x1000f710` allocates `0x8b0` bytes
and calls constructor `0x10001000` with its first eight arguments. That
constructor calls `0x10006340(this + 0x7c, clan_id, base_x, base_y)`: these are
the fields later read by `Handler(19)`. `base_x` and `base_y` are unsigned and
the constructor rejects values greater than `10000`. The actual `__ftol` helper
at `0x1001df70` saves the x87 control word, sets its rounding-control bits to
truncate, executes `fistp qword`, then restores the control word.
Consequently the runtime resolves and retains this one proved vertical slice
during mission loading: each selected clan's TMA anchor is accepted only in the recovered
`0..=10000` base range, truncated through the recovered x87 rule, and paired
with its zero-based clan index. For every `Init` instruction whose selector is
`Handler(19)`, `VarSet::resolve_handler19` produces the three per-clan DWORD
writes. The runtime then materializes an independent declaration-ordered value
array for each selected clan and applies those writes, so later recovered
handlers can consume `ClanBaseX`, `ClanBaseY`, and `ClanID` as runtime cells
rather than loader defaults. Other Init selectors and all other events remain
decoded but unexecuted. AutoDemo validates the path end-to-end: its non-integral first
anchor (`500.2857`) yields captured `ClanBaseX=500`, and the live GOG process
contains two initialized SuperAI entries `(500, 752, 0)` and `(728, 449, 1)`;
the Rust loader reports `script_init_states=2` and `script_varset_states=2`.
`GetSuperAI` returns element `n` of the 64-pointer global table at preferred
`ai.dll + 0x55398` for `n <= 63`. The read-only
`tools/capture-ai-init.ps1` probe observed the running GOG AutoDemo values
`(500, 752, 0)` for entry 0 and `(728, 449, 1)` for entry 1 at fields
`(+0x80, +0x84, +0x7c)`. These values are integral samples, not a rounding
profile.
The Rust reader exposes `VarSet::resolve_handler19`. It accepts the already
converted first two words and the third raw word, produces three typed writes,
and rejects missing, out-of-range, or non-`DWORD` targets. The runtime only
binds it to the proven creation/anchor path above; it does not guess the
remaining script event semantics. The associated Ghidra scripts are
`ExportAiVmHandler19.java`, `ExportAiVmHandler19Setter.java`,
`ExportAiVmHandler19SetterCallee.java`, and `ExportAiGetSuperAi.java`.
The creation and conversion boundaries are reproducible with
`ExportAiCreateSuperAi.java`, `ExportAiSuperAiConstructor.java`, and
`ExportAiFtol.java`.
### Runtime Handler(30) operand binding
`resolve_handler30_with_values` preserves the recovered declaration-kind ABI
but reads operands from instantiated per-clan cells rather than textual
defaults. Runtime exposes `resolve_loaded_handler30` for the exact opaque
`(mode=0, first, second)` callback command. It intentionally returns that
command without automatically invoking a game-side consumer: only one
consumer branch has a safe Rust meaning so far.
The live GOG AutoDemo closes that consumer boundary: the read-only callback
pointer at `ai.dll + 0x555e4` is `0x100611d0`, or `iron3d.dll + 0x611d0` at
the observed load base. Its recovered `__cdecl` ABI is `(mode, command,
payload)`, matching `Handler(30)` as `(0, first, second)`. In `mode == 0`,
`command == 0` and `payload == 0` selects `VOICE_MISSION_FAIL`, records the
failed status, and clears an IGame byte; `payload == 1` selects
`VOICE_MISSION_COMPLETE`, records completion, and sets that byte. Commands
3, 4, and 5 have additional game-side paths; mode 2 is a separate IGame
call.
`command == 1` is now statically traced, but still has no justified domain
name. Its payload is a signed integer key for a binary-search tree: the
receiver's `+0x04` is the sentinel/root, each node has child links at `+0x08`
and `+0x0c`, a key at `+0x10`, and a dispatched payload at `+0x14`. A matching
node reaches `FUN_10095600(node + 0x14)`. That payload has one-shot flag
`+0x19` and a second flag `+0x18`. On its first dispatch, Iron3D sets `+0x19`,
obtains a resource-manager string through virtual slot `+0x1c`, and passes the
payload's `+0x0c` through `FUN_10061fb0`. On later dispatches it uses
resource-manager IDs `0x181a` and `0x184f`, an IGame selector `0x2ed`, and
emits an opaque request with category `3` or `4` selected by `+0x18`. This
proves the lookup and one-shot/repeat split, not the UI/message subject or the
semantics of either resource ID; Rust therefore retains command `1` as
`Unhandled`.
Reproduce the callback and the command-one consumers with
`capture-ai-init.ps1`, `ExportIron3dAiCallback.java`,
`ExportIron3dAiCallbackCommand1.java`, and
`ExportIron3dAiCallbackCommand1Dispatch.java`.
Runtime now applies only this recovered branch as
`apply_loaded_script_host_callback`: `(0, 0, 0)` transitions a loaded mission
to `Failed`, `(0, 0, 1)` transitions it to `Completed`, and a repeated target
state is a no-op just as the Iron3D guards require. The effect is deliberately
separate from audio/UI playback; commands 1, 3, 4, 5 and mode 2 return
`Unhandled` until their consumers are recovered.
### Handler(8): problem-record state write
`Handler(8)` is the ninth VM-table entry at GOG `ai.dll` VA `0x10009b0d`.
All 179 corpus records have exactly one in-range `DWORD` reference; the two
observed entries are `ST_SOLVING=1` (122 records) and `ST_SOLVED=2` (57).
The handler resolves loader-bound `dCurrentProblem` through varset index
`this+0x868`, uses that live DWORD as a bounds-checked index into a table at
`this+0xa0` with 100-byte records, then resolves the instruction's one DWORD
and writes it to the selected record at `+0x18`.
The write has two statically proven exceptional branches. State `2` calls a
reset helper that zeroes record words `0..=3` and `6` before invoking two
opaque callback slots; state `3` does the same except word `3` is preserved.
Both then write `+0x18`. Every other state simply writes the state word.
`VarSet::resolve_handler8` emits a `Handler8StateChange` with the caller-owned
live record index, resolved state, and explicit reset kind. It does not invent
the table owner, the pre-reset helper, or callback semantics. Reproduce the
evidence with `ExportAiVmHandler8.java`, `ExportAiVmHandler8Callees.java`, and
`ExportAiVmHandler8Transitions.java`.
### Handler(15): typed target-call boundary
`Handler(15)` is the sixteenth VM-table entry at GOG `ai.dll` VA `0x10008054`
and the second most frequent non-sentinel selector: 236 records in 28 of 58
GOG packages. It is not a one-word opcode. Across the complete corpus,
references `0..3` are `DWORD`, `4..7` are `float`, and `8` is the DWORD mode.
The original resolves the first four through `0x10013570`, the next four
through x87 scalar helper `0x10013190`, then reads the mode through
`0x10013570`.
The shipped `varset.var` names the only observed mode values: `NONE=0` uses
9 total references; `TARGET_BY_LOGIC_ID=0x0201`, `TARGET_BY_TYPE=0x0203`,
`TARGET_NOT_DEFINED=0x0204`, and `TARGET_BY_NAME=0x0205` use 10; and
`TARGET_BY_PLACE=0x0202` uses 11. The corpus contains 34/122/80 records in
these three arities. The trailing references at positions 9 and, only for
`TARGET_BY_PLACE`, 10 are all in-range DWORD declarations.
After resolving those inputs, the handler looks up an opaque target through
virtual slot `+0x1c` on `this+0x3d8` using the first word. A missing target
sets VM flag `+0x50=5`; otherwise the handler builds a temporary call record,
applies the mode-specific tail, and invokes the target's `+0x0c` virtual slot
with that record and the third word. Its zero/non-zero result becomes
`+0x50=0/1`. The target type, the virtual method's semantic action, and its
return value remain unproven. Accordingly `VarSet::resolve_handler15` only
materializes a type-checked `Handler15Invocation` and `Handler15TargetPayload`;
it never executes the opaque target call. Missing, out-of-range, wrong-type,
and unobserved-mode inputs are explicit errors. Reproduce the static evidence
with `tools/ghidra/ExportAiVmHandler15.java`.
## Готовность
Все demo packages должны проходить package/version checks, offsets оставаться
в bytecode, а confirmed disassembler — не терять синхронизацию. VM считается
готовой после deterministic Init/basic mission events, stable object bindings,
typed research/property tests и save/load script state. Для закрытия остаются
dispatcher/jump table, minimal differential packages и traces world/variable
effects; до них unknown opcode — явная unsupported branch, не no-op.
+74
View File
@@ -0,0 +1,74 @@
# Shell, HUD, шрифты и локализация
## Доказанная граница
`iron_3d.exe` создаёт процесс и окно; `iron3d.dll` экспортирует `createShell`,
`deleteShell` и `getIShell`; `services.dll` предоставляет `getGUIServer` и
`getDisplay`. Поэтому меню, briefing, HUD и системные диалоги — отдельная
оболочка над World3D, а не игровые объекты, созданные ради отрисовки.
```text
Win32 messages
-> shell/GUI input и manual events
-> simulation World3D
-> world render
-> shell/HUD overlay и presentation state
```
Точное положение legacy DirectDraw flip относительно последнего UI draw пока
требует API capture. Разделение world и UI pass доказано, но не следует
выдавать его за восстановленный layout классов виджетов.
## Ресурсы и идентичность
В demo найдены `ui/shell_ctrls.cfg`, `ui/menu_resources.cfg`, `ui/cursor.cfg`,
`ui/game_resources.cfg`, `ui/hq.cfg`, `DATA/TextRes.cfg`, `gamefont.rlb`,
`sprites.lib` и `Palettes.lib`. Конфигурации задают controls/resources/cursor,
overlays и HQ; `TextRes.cfg` связывает символические ключи с текстом.
`gamefont.rlb` содержит две LZSS-записи (`0x040`), `sprites.lib` — 24 записи
raw Deflate (`0x100`). `sprites.lib::INTERF8.TEX` использует известный
`deflate_eof_plus_one` quirk. UI обязан пользоваться общим RsLi reader, а не
отдельной похожей распаковкой.
Ключ ресурса, legacy path, локализованный текст и исходные bytes — четыре
разные идентичности. Archive keys остаются ASCII-casefold; `TextRes.cfg`,
briefing/messages и script strings могут требовать ANSI/CP1251 decoding.
Unicode-представление хранит raw bytes для roundtrip и diagnostics.
`gamefont.rlb` и `sprites.lib` побайтно совпадают в Частях 1 и 2. Во второй
части добавлен `ui_factory.lib` (NRes с шестью Texm) и расширен
`ui/minimap.lib`; при этом пересобранные `iron3d.dll`/`services.dll` требуют
отдельной трассировки lifecycle и HUD state.
## Контракт новой реализации
UI scene хранит корневые widgets, focus, viewport/clip rectangles и modal
depth. Demo `640x480` — полезный baseline, но не доказанное универсальное
design resolution. Отдельно хранятся legacy-layout coordinates, реальный
viewport, scale/letterbox policy, mouse-to-layout transform, clipping и
z-order. Cursor, sprite и hit-test обязаны применять одно преобразование.
Font contract включает glyph image, advance, bearing/offset, line height и
fallback glyph. Binary glyph metrics `gamefont.rlb` пока не восстановлены:
payload читается lossless через RsLi, а семантика устанавливается по consumer
trace.
Нормализованное событие проходит modal widget, затем shell command map; лишь
разрешённая gameplay-команда превращается в World3D manual event. Held state и
axes попадают в calculation snapshot. Это не позволяет UI-click одновременно
исполнить команду мира. HUD только читает presentation view (selected object,
resources, mission text, timers, research/build state, camera mode); authority
остаётся у simulation.
## Проверки и открытые вопросы
- UI cfg читаются до EOF с сохранением неизвестных fields; symbolic key
разрешается в RsLi entry независимо от регистра.
- Visual rect и hit-test совпадают при разных viewport; modal scene блокирует
gameplay input; missing optional sprite/font имеет named fallback.
- UI-only и world-only command captures собираются раздельно.
Не закрыты grammar всех `ui/*.cfg`, hierarchy original widgets, glyph metrics,
HUD state machine и pixel-perfect layout. Нужны GUI-factory hooks, event traces
и captures меню, briefing, HQ и игрового HUD.
+75 -5
View File
@@ -3,11 +3,81 @@
Baseline command:
```text
env RUSTC=/Users/valentineus/.rustup/toolchains/stable-aarch64-apple-darwin/bin/rustc /opt/homebrew/bin/rustup run stable cargo test --workspace --offline
cargo xtask ci
```
Result on 2026-06-22:
Result on 2026-07-18:
- library and binary unit tests compile and pass after aligning SDL2 versions and pinning `toml` to cached `0.8`;
- doctests fail in this shell because `rustdoc` is not in PATH unless `RUSTDOC` is also set to the real toolchain binary;
- full online dependency resolution is unavailable in the sandbox.
- canonical pipeline passes locally with Rust 1.97.1: formatting, policy,
shader provenance, workspace tests, `clippy`, docs and `cargo deny` are
executed through `cargo xtask ci`;
- `rust-toolchain.toml` pins Rust 1.97.1 and installs only the
`x86_64-pc-windows-msvc` target; Linux and macOS target installation is not
part of the default developer setup;
- internal path dependencies are version-pinned and the Windows-only winit
graph enables only `rwh_06`, excluding Unix window-system dependencies;
- `cargo deny` runs without advisory exceptions in the supported Windows graph.
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.

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