azurkit 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (547) hide show
  1. azurkit/__init__.py +46 -0
  2. azurkit/__main__.py +18 -0
  3. azurkit/__pycache__/__init__.cpython-314.pyc +0 -0
  4. azurkit/__pycache__/__main__.cpython-314.pyc +0 -0
  5. azurkit/__pycache__/cli.cpython-314.pyc +0 -0
  6. azurkit/__pycache__/sdk.cpython-314.pyc +0 -0
  7. azurkit/__pycache__/store.cpython-314.pyc +0 -0
  8. azurkit/app/__init__.py +1290 -0
  9. azurkit/app/__pycache__/__init__.cpython-314.pyc +0 -0
  10. azurkit/app/__pycache__/ahead.cpython-314.pyc +0 -0
  11. azurkit/app/__pycache__/art.cpython-314.pyc +0 -0
  12. azurkit/app/__pycache__/engine.cpython-314.pyc +0 -0
  13. azurkit/app/__pycache__/live.cpython-314.pyc +0 -0
  14. azurkit/app/__pycache__/memo.cpython-314.pyc +0 -0
  15. azurkit/app/__pycache__/procpool.cpython-314.pyc +0 -0
  16. azurkit/app/__pycache__/swapcache.cpython-314.pyc +0 -0
  17. azurkit/app/__pycache__/warm.cpython-314.pyc +0 -0
  18. azurkit/app/ahead.py +359 -0
  19. azurkit/app/art.py +45 -0
  20. azurkit/app/engine.py +660 -0
  21. azurkit/app/live.py +186 -0
  22. azurkit/app/memo.py +235 -0
  23. azurkit/app/procpool.py +428 -0
  24. azurkit/app/swapcache.py +320 -0
  25. azurkit/app/warm.py +335 -0
  26. azurkit/cli.py +455 -0
  27. azurkit/commit.txt +1 -0
  28. azurkit/core/__init__.py +2 -0
  29. azurkit/core/__pycache__/__init__.cpython-314.pyc +0 -0
  30. azurkit/core/__pycache__/coff.cpython-314.pyc +0 -0
  31. azurkit/core/__pycache__/facts.cpython-314.pyc +0 -0
  32. azurkit/core/__pycache__/fdlibm.cpython-314.pyc +0 -0
  33. azurkit/core/__pycache__/filelist.cpython-314.pyc +0 -0
  34. azurkit/core/__pycache__/gate.cpython-314.pyc +0 -0
  35. azurkit/core/__pycache__/host.cpython-314.pyc +0 -0
  36. azurkit/core/__pycache__/inflate.cpython-314.pyc +0 -0
  37. azurkit/core/__pycache__/iso.cpython-314.pyc +0 -0
  38. azurkit/core/__pycache__/jsondepth.cpython-314.pyc +0 -0
  39. azurkit/core/__pycache__/native.cpython-314.pyc +0 -0
  40. azurkit/core/__pycache__/progress.cpython-314.pyc +0 -0
  41. azurkit/core/__pycache__/xbe.cpython-314.pyc +0 -0
  42. azurkit/core/_native/emscripten-wasm32.so +0 -0
  43. azurkit/core/_native/linux-aarch64.so +0 -0
  44. azurkit/core/_native/linux-x86_64.so +0 -0
  45. azurkit/core/_native/macos-arm64.dylib +0 -0
  46. azurkit/core/_native/macos-x86_64.dylib +0 -0
  47. azurkit/core/_native/windows-x86_64.dll +0 -0
  48. azurkit/core/coff.py +211 -0
  49. azurkit/core/facts.europe.json +4097 -0
  50. azurkit/core/facts.py +156 -0
  51. azurkit/core/facts.usa.json +3826 -0
  52. azurkit/core/fdlibm.py +532 -0
  53. azurkit/core/filelist.py +68 -0
  54. azurkit/core/gate.py +287 -0
  55. azurkit/core/host.py +166 -0
  56. azurkit/core/inflate.py +36 -0
  57. azurkit/core/iso.py +823 -0
  58. azurkit/core/jsondepth.py +28 -0
  59. azurkit/core/manifests/europe.json +98 -0
  60. azurkit/core/manifests/usa.json +1200 -0
  61. azurkit/core/native.py +805 -0
  62. azurkit/core/progress.py +132 -0
  63. azurkit/core/xbe.py +224 -0
  64. azurkit/core/xbr/__init__.py +8 -0
  65. azurkit/core/xbr/__pycache__/__init__.cpython-314.pyc +0 -0
  66. azurkit/core/xbr/__pycache__/append.cpython-314.pyc +0 -0
  67. azurkit/core/xbr/__pycache__/clone.cpython-314.pyc +0 -0
  68. azurkit/core/xbr/__pycache__/document.cpython-314.pyc +0 -0
  69. azurkit/core/xbr/__pycache__/edits.cpython-314.pyc +0 -0
  70. azurkit/core/xbr/__pycache__/graph.cpython-314.pyc +0 -0
  71. azurkit/core/xbr/__pycache__/keyed.cpython-314.pyc +0 -0
  72. azurkit/core/xbr/__pycache__/nodes.cpython-314.pyc +0 -0
  73. azurkit/core/xbr/append.py +430 -0
  74. azurkit/core/xbr/clone.py +182 -0
  75. azurkit/core/xbr/document.py +195 -0
  76. azurkit/core/xbr/edits.py +128 -0
  77. azurkit/core/xbr/graph.py +514 -0
  78. azurkit/core/xbr/keyed.py +161 -0
  79. azurkit/core/xbr/node_types.json +764 -0
  80. azurkit/core/xbr/nodes.py +213 -0
  81. azurkit/edits/__init__.py +3 -0
  82. azurkit/edits/__pycache__/__init__.cpython-314.pyc +0 -0
  83. azurkit/edits/__pycache__/files.cpython-314.pyc +0 -0
  84. azurkit/edits/__pycache__/layer.cpython-314.pyc +0 -0
  85. azurkit/edits/__pycache__/manifest.cpython-314.pyc +0 -0
  86. azurkit/edits/__pycache__/preset.cpython-314.pyc +0 -0
  87. azurkit/edits/__pycache__/xbr.cpython-314.pyc +0 -0
  88. azurkit/edits/files.py +145 -0
  89. azurkit/edits/layer.py +306 -0
  90. azurkit/edits/manifest.py +335 -0
  91. azurkit/edits/preset.py +205 -0
  92. azurkit/edits/xbr.py +211 -0
  93. azurkit/sdk.py +1646 -0
  94. azurkit/store.py +276 -0
  95. azurkit/tools/__init__.py +3 -0
  96. azurkit/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  97. azurkit/tools/characters/__init__.py +547 -0
  98. azurkit/tools/characters/__pycache__/__init__.cpython-314.pyc +0 -0
  99. azurkit/tools/characters/__pycache__/_player_borrow.cpython-314.pyc +0 -0
  100. azurkit/tools/characters/__pycache__/_player_character.cpython-314.pyc +0 -0
  101. azurkit/tools/characters/__pycache__/_player_crossfade.cpython-314.pyc +0 -0
  102. azurkit/tools/characters/__pycache__/_player_damage_marker.cpython-314.pyc +0 -0
  103. azurkit/tools/characters/__pycache__/_player_fx_ring.cpython-314.pyc +0 -0
  104. azurkit/tools/characters/__pycache__/_player_kit.cpython-314.pyc +0 -0
  105. azurkit/tools/characters/__pycache__/_player_match.cpython-314.pyc +0 -0
  106. azurkit/tools/characters/__pycache__/_player_power_fx.cpython-314.pyc +0 -0
  107. azurkit/tools/characters/__pycache__/_player_slots.cpython-314.pyc +0 -0
  108. azurkit/tools/characters/__pycache__/_player_sounds.cpython-314.pyc +0 -0
  109. azurkit/tools/characters/__pycache__/_player_throw.cpython-314.pyc +0 -0
  110. azurkit/tools/characters/__pycache__/_player_variants.cpython-314.pyc +0 -0
  111. azurkit/tools/characters/__pycache__/_player_victory.cpython-314.pyc +0 -0
  112. azurkit/tools/characters/__pycache__/_player_weapons.cpython-314.pyc +0 -0
  113. azurkit/tools/characters/__pycache__/body_codec.cpython-314.pyc +0 -0
  114. azurkit/tools/characters/__pycache__/characters.cpython-314.pyc +0 -0
  115. azurkit/tools/characters/__pycache__/clip_import.cpython-314.pyc +0 -0
  116. azurkit/tools/characters/__pycache__/content.cpython-314.pyc +0 -0
  117. azurkit/tools/characters/__pycache__/decimate.cpython-314.pyc +0 -0
  118. azurkit/tools/characters/__pycache__/draw.cpython-314.pyc +0 -0
  119. azurkit/tools/characters/__pycache__/fx_edit.cpython-314.pyc +0 -0
  120. azurkit/tools/characters/__pycache__/fx_mesh.cpython-314.pyc +0 -0
  121. azurkit/tools/characters/__pycache__/fx_templates.cpython-314.pyc +0 -0
  122. azurkit/tools/characters/__pycache__/gltf.cpython-314.pyc +0 -0
  123. azurkit/tools/characters/__pycache__/gltf_import.cpython-314.pyc +0 -0
  124. azurkit/tools/characters/__pycache__/grades.cpython-314.pyc +0 -0
  125. azurkit/tools/characters/__pycache__/index_xbr.cpython-314.pyc +0 -0
  126. azurkit/tools/characters/__pycache__/jiggle.cpython-314.pyc +0 -0
  127. azurkit/tools/characters/__pycache__/jiggle_rt.cpython-314.pyc +0 -0
  128. azurkit/tools/characters/__pycache__/lods.cpython-314.pyc +0 -0
  129. azurkit/tools/characters/__pycache__/mesh_edit.cpython-314.pyc +0 -0
  130. azurkit/tools/characters/__pycache__/model_chains.cpython-314.pyc +0 -0
  131. azurkit/tools/characters/__pycache__/model_inspect.cpython-314.pyc +0 -0
  132. azurkit/tools/characters/__pycache__/paint.cpython-314.pyc +0 -0
  133. azurkit/tools/characters/__pycache__/picker.cpython-314.pyc +0 -0
  134. azurkit/tools/characters/__pycache__/pool.cpython-314.pyc +0 -0
  135. azurkit/tools/characters/__pycache__/ports.cpython-314.pyc +0 -0
  136. azurkit/tools/characters/__pycache__/pose.cpython-314.pyc +0 -0
  137. azurkit/tools/characters/__pycache__/render.cpython-314.pyc +0 -0
  138. azurkit/tools/characters/__pycache__/retarget.cpython-314.pyc +0 -0
  139. azurkit/tools/characters/__pycache__/shipped.cpython-314.pyc +0 -0
  140. azurkit/tools/characters/__pycache__/skeleton.cpython-314.pyc +0 -0
  141. azurkit/tools/characters/__pycache__/synth.cpython-314.pyc +0 -0
  142. azurkit/tools/characters/__pycache__/textures.cpython-314.pyc +0 -0
  143. azurkit/tools/characters/__pycache__/xbox_adpcm.cpython-314.pyc +0 -0
  144. azurkit/tools/characters/_player_borrow.py +150 -0
  145. azurkit/tools/characters/_player_character.py +11603 -0
  146. azurkit/tools/characters/_player_crossfade.py +219 -0
  147. azurkit/tools/characters/_player_damage_marker.py +229 -0
  148. azurkit/tools/characters/_player_fx_ring.py +815 -0
  149. azurkit/tools/characters/_player_kit.py +309 -0
  150. azurkit/tools/characters/_player_match.py +156 -0
  151. azurkit/tools/characters/_player_power_fx.py +554 -0
  152. azurkit/tools/characters/_player_slots.py +536 -0
  153. azurkit/tools/characters/_player_sounds.py +648 -0
  154. azurkit/tools/characters/_player_throw.py +762 -0
  155. azurkit/tools/characters/_player_variants.py +4366 -0
  156. azurkit/tools/characters/_player_victory.py +238 -0
  157. azurkit/tools/characters/_player_weapons.py +3257 -0
  158. azurkit/tools/characters/body_codec.py +934 -0
  159. azurkit/tools/characters/chains.json +47 -0
  160. azurkit/tools/characters/characters.py +835 -0
  161. azurkit/tools/characters/clip_import.py +561 -0
  162. azurkit/tools/characters/clips/townsfolk/children.glb +0 -0
  163. azurkit/tools/characters/clips/townsfolk/kit.json +179 -0
  164. azurkit/tools/characters/clips/townsfolk/townsmen.glb +0 -0
  165. azurkit/tools/characters/clips/townsfolk/townswomen.glb +0 -0
  166. azurkit/tools/characters/content.py +521 -0
  167. azurkit/tools/characters/decimate.py +711 -0
  168. azurkit/tools/characters/draw.py +412 -0
  169. azurkit/tools/characters/fx/air_elemental/air_funnel.glb +0 -0
  170. azurkit/tools/characters/fx/air_elemental/air_funnel_swirl.png +0 -0
  171. azurkit/tools/characters/fx_edit.py +705 -0
  172. azurkit/tools/characters/fx_mesh.py +232 -0
  173. azurkit/tools/characters/fx_templates.json +250 -0
  174. azurkit/tools/characters/fx_templates.py +417 -0
  175. azurkit/tools/characters/gltf.py +1027 -0
  176. azurkit/tools/characters/gltf_import.py +489 -0
  177. azurkit/tools/characters/grades.py +32 -0
  178. azurkit/tools/characters/index_xbr.py +182 -0
  179. azurkit/tools/characters/jiggle.py +1602 -0
  180. azurkit/tools/characters/jiggle_rt.py +1750 -0
  181. azurkit/tools/characters/lods.py +143 -0
  182. azurkit/tools/characters/mesh_edit.py +5718 -0
  183. azurkit/tools/characters/mode_grades.json +112 -0
  184. azurkit/tools/characters/model_chains.py +245 -0
  185. azurkit/tools/characters/model_inspect.py +694 -0
  186. azurkit/tools/characters/paint.py +1112 -0
  187. azurkit/tools/characters/picker.py +399 -0
  188. azurkit/tools/characters/player_model/air_blast.c +215 -0
  189. azurkit/tools/characters/player_model/air_blast.o +0 -0
  190. azurkit/tools/characters/player_model/attack_box.c +143 -0
  191. azurkit/tools/characters/player_model/attack_box.o +0 -0
  192. azurkit/tools/characters/player_model/attack_carry.c +423 -0
  193. azurkit/tools/characters/player_model/attack_carry.o +0 -0
  194. azurkit/tools/characters/player_model/attack_rotation.c +131 -0
  195. azurkit/tools/characters/player_model/attack_rotation.o +0 -0
  196. azurkit/tools/characters/player_model/attack_sets.c +335 -0
  197. azurkit/tools/characters/player_model/attack_sets.o +0 -0
  198. azurkit/tools/characters/player_model/body_key.c +51 -0
  199. azurkit/tools/characters/player_model/body_key.o +0 -0
  200. azurkit/tools/characters/player_model/borrow.c +178 -0
  201. azurkit/tools/characters/player_model/borrow.o +0 -0
  202. azurkit/tools/characters/player_model/borrow_first.c +76 -0
  203. azurkit/tools/characters/player_model/borrow_first.o +0 -0
  204. azurkit/tools/characters/player_model/climb.c +598 -0
  205. azurkit/tools/characters/player_model/climb.o +0 -0
  206. azurkit/tools/characters/player_model/damage_marker.c +219 -0
  207. azurkit/tools/characters/player_model/damage_marker.o +0 -0
  208. azurkit/tools/characters/player_model/drain.c +907 -0
  209. azurkit/tools/characters/player_model/drain.o +0 -0
  210. azurkit/tools/characters/player_model/eye.c +459 -0
  211. azurkit/tools/characters/player_model/eye.o +0 -0
  212. azurkit/tools/characters/player_model/eye_hang.c +112 -0
  213. azurkit/tools/characters/player_model/eye_hang.o +0 -0
  214. azurkit/tools/characters/player_model/eye_sweep.c +353 -0
  215. azurkit/tools/characters/player_model/eye_sweep.o +0 -0
  216. azurkit/tools/characters/player_model/fade_floor.c +513 -0
  217. azurkit/tools/characters/player_model/fade_floor.o +0 -0
  218. azurkit/tools/characters/player_model/finish_range.c +69 -0
  219. azurkit/tools/characters/player_model/finish_range.o +0 -0
  220. azurkit/tools/characters/player_model/fx_default.c +56 -0
  221. azurkit/tools/characters/player_model/fx_default.o +0 -0
  222. azurkit/tools/characters/player_model/fx_ring.c +1178 -0
  223. azurkit/tools/characters/player_model/fx_ring.o +0 -0
  224. azurkit/tools/characters/player_model/hang_ease.c +356 -0
  225. azurkit/tools/characters/player_model/hang_ease.o +0 -0
  226. azurkit/tools/characters/player_model/hang_hold.c +749 -0
  227. azurkit/tools/characters/player_model/hang_hold.o +0 -0
  228. azurkit/tools/characters/player_model/hit_delay.c +94 -0
  229. azurkit/tools/characters/player_model/hit_delay.o +0 -0
  230. azurkit/tools/characters/player_model/hit_fx.c +83 -0
  231. azurkit/tools/characters/player_model/hit_fx.o +0 -0
  232. azurkit/tools/characters/player_model/hit_reaction.c +114 -0
  233. azurkit/tools/characters/player_model/hit_reaction.o +0 -0
  234. azurkit/tools/characters/player_model/idle_pick.c +141 -0
  235. azurkit/tools/characters/player_model/idle_pick.o +0 -0
  236. azurkit/tools/characters/player_model/jiggle.c +1079 -0
  237. azurkit/tools/characters/player_model/jiggle.o +0 -0
  238. azurkit/tools/characters/player_model/light_hit.c +156 -0
  239. azurkit/tools/characters/player_model/light_hit.o +0 -0
  240. azurkit/tools/characters/player_model/near_draw.c +91 -0
  241. azurkit/tools/characters/player_model/near_draw.o +0 -0
  242. azurkit/tools/characters/player_model/over_top.c +179 -0
  243. azurkit/tools/characters/player_model/over_top.o +0 -0
  244. azurkit/tools/characters/player_model/roll.c +125 -0
  245. azurkit/tools/characters/player_model/roll.o +0 -0
  246. azurkit/tools/characters/player_model/seek.c +109 -0
  247. azurkit/tools/characters/player_model/seek.o +0 -0
  248. azurkit/tools/characters/player_model/slots.c +126 -0
  249. azurkit/tools/characters/player_model/slots.o +0 -0
  250. azurkit/tools/characters/player_model/sounds.c +89 -0
  251. azurkit/tools/characters/player_model/sounds.o +0 -0
  252. azurkit/tools/characters/player_model/stance_term.c +183 -0
  253. azurkit/tools/characters/player_model/stance_term.o +0 -0
  254. azurkit/tools/characters/player_model/target_search.c +89 -0
  255. azurkit/tools/characters/player_model/target_search.o +0 -0
  256. azurkit/tools/characters/player_model/throw.c +774 -0
  257. azurkit/tools/characters/player_model/throw.o +0 -0
  258. azurkit/tools/characters/player_model/thud.c +78 -0
  259. azurkit/tools/characters/player_model/thud.o +0 -0
  260. azurkit/tools/characters/player_model/touch.c +61 -0
  261. azurkit/tools/characters/player_model/touch.o +0 -0
  262. azurkit/tools/characters/player_model/victory_cancel.c +103 -0
  263. azurkit/tools/characters/player_model/victory_cancel.o +0 -0
  264. azurkit/tools/characters/player_model/water_push.c +464 -0
  265. azurkit/tools/characters/player_model/water_push.o +0 -0
  266. azurkit/tools/characters/player_model/wings.c +336 -0
  267. azurkit/tools/characters/player_model/wings.o +0 -0
  268. azurkit/tools/characters/pool.py +88 -0
  269. azurkit/tools/characters/ports.py +1068 -0
  270. azurkit/tools/characters/pose.py +61 -0
  271. azurkit/tools/characters/render.py +305 -0
  272. azurkit/tools/characters/retarget.py +1303 -0
  273. azurkit/tools/characters/shipped.json +3437 -0
  274. azurkit/tools/characters/shipped.py +451 -0
  275. azurkit/tools/characters/skeleton.py +203 -0
  276. azurkit/tools/characters/species.json +1 -0
  277. azurkit/tools/characters/synth.py +125 -0
  278. azurkit/tools/characters/textures.py +322 -0
  279. azurkit/tools/characters/variant_grades.json +59 -0
  280. azurkit/tools/characters/variants.json +1028 -0
  281. azurkit/tools/characters/weapon_grades.json +52 -0
  282. azurkit/tools/characters/weapons.json +104 -0
  283. azurkit/tools/characters/xbox_adpcm.py +184 -0
  284. azurkit/tools/data/__init__.py +441 -0
  285. azurkit/tools/data/__pycache__/__init__.cpython-314.pyc +0 -0
  286. azurkit/tools/data/__pycache__/views.cpython-314.pyc +0 -0
  287. azurkit/tools/data/config.xbr +0 -0
  288. azurkit/tools/data/schema.json +1371 -0
  289. azurkit/tools/data/views.py +574 -0
  290. azurkit/tools/mods/__init__.py +142 -0
  291. azurkit/tools/mods/__pycache__/__init__.cpython-314.pyc +0 -0
  292. azurkit/tools/mods/__pycache__/community.cpython-314.pyc +0 -0
  293. azurkit/tools/mods/__pycache__/engine.cpython-314.pyc +0 -0
  294. azurkit/tools/mods/community.py +259 -0
  295. azurkit/tools/mods/engine.py +651 -0
  296. azurkit/tools/mods/packs/__init__.py +1 -0
  297. azurkit/tools/mods/packs/__pycache__/__init__.cpython-314.pyc +0 -0
  298. azurkit/tools/mods/packs/__pycache__/air_shield_flaps.cpython-314.pyc +0 -0
  299. azurkit/tools/mods/packs/__pycache__/enemy_damage.cpython-314.pyc +0 -0
  300. azurkit/tools/mods/packs/__pycache__/enemy_scaling.cpython-314.pyc +0 -0
  301. azurkit/tools/mods/packs/__pycache__/fps_unlock.cpython-314.pyc +0 -0
  302. azurkit/tools/mods/packs/__pycache__/player_max_hp.cpython-314.pyc +0 -0
  303. azurkit/tools/mods/packs/__pycache__/player_physics.cpython-314.pyc +0 -0
  304. azurkit/tools/mods/packs/__pycache__/qol_alloc_race.cpython-314.pyc +0 -0
  305. azurkit/tools/mods/packs/__pycache__/qol_memory.cpython-314.pyc +0 -0
  306. azurkit/tools/mods/packs/__pycache__/qol_photo_mode.cpython-314.pyc +0 -0
  307. azurkit/tools/mods/packs/__pycache__/qol_pickup_anims.cpython-314.pyc +0 -0
  308. azurkit/tools/mods/packs/__pycache__/qol_popups.cpython-314.pyc +0 -0
  309. azurkit/tools/mods/packs/__pycache__/qol_save_warp.cpython-314.pyc +0 -0
  310. azurkit/tools/mods/packs/__pycache__/qol_skip_logo.cpython-314.pyc +0 -0
  311. azurkit/tools/mods/packs/air_shield_flaps.py +28 -0
  312. azurkit/tools/mods/packs/enemy_damage.py +98 -0
  313. azurkit/tools/mods/packs/enemy_scaling.py +240 -0
  314. azurkit/tools/mods/packs/fps_unlock.py +125 -0
  315. azurkit/tools/mods/packs/player_max_hp.py +37 -0
  316. azurkit/tools/mods/packs/player_physics.py +311 -0
  317. azurkit/tools/mods/packs/qol_alloc_race.py +148 -0
  318. azurkit/tools/mods/packs/qol_memory.py +124 -0
  319. azurkit/tools/mods/packs/qol_photo_mode.py +37 -0
  320. azurkit/tools/mods/packs/qol_pickup_anims.py +16 -0
  321. azurkit/tools/mods/packs/qol_popups.py +31 -0
  322. azurkit/tools/mods/packs/qol_save_warp.py +28 -0
  323. azurkit/tools/mods/packs/qol_skip_logo.py +31 -0
  324. azurkit/tools/mods/shims/enemy_scaling.c +356 -0
  325. azurkit/tools/mods/shims/enemy_scaling.o +0 -0
  326. azurkit/tools/mods/shims/player_physics.c +273 -0
  327. azurkit/tools/mods/shims/player_physics.o +0 -0
  328. azurkit/tools/mods/shims/qol_alloc_race.c +222 -0
  329. azurkit/tools/mods/shims/qol_alloc_race.o +0 -0
  330. azurkit/tools/mods/shims/qol_memory_budget.c +45 -0
  331. azurkit/tools/mods/shims/qol_memory_budget.o +0 -0
  332. azurkit/tools/mods/shims/qol_memory_heap.h +330 -0
  333. azurkit/tools/mods/shims/qol_memory_upper256.c +57 -0
  334. azurkit/tools/mods/shims/qol_memory_upper256.o +0 -0
  335. azurkit/tools/mods/shims/qol_memory_upper64.c +80 -0
  336. azurkit/tools/mods/shims/qol_memory_upper64.o +0 -0
  337. azurkit/tools/mods/shims/qol_memory_upper64_gpu.c +183 -0
  338. azurkit/tools/mods/shims/qol_memory_upper64_gpu.o +0 -0
  339. azurkit/tools/mods/shims/qol_photo_mode.c +208 -0
  340. azurkit/tools/mods/shims/qol_photo_mode.o +0 -0
  341. azurkit/tools/mods/shims/qol_save_warp.c +306 -0
  342. azurkit/tools/mods/shims/qol_save_warp.o +0 -0
  343. azurkit/tools/mods/shims/qol_skip_logo.c +26 -0
  344. azurkit/tools/mods/shims/qol_skip_logo.o +0 -0
  345. azurkit/tools/mods/shims/xboxkrnl_resolver.c +74 -0
  346. azurkit/tools/mods/shims/xboxkrnl_resolver.o +0 -0
  347. azurkit/tools/randomizer/__init__.py +461 -0
  348. azurkit/tools/randomizer/__pycache__/__init__.cpython-314.pyc +0 -0
  349. azurkit/tools/randomizer/__pycache__/dbcache.cpython-314.pyc +0 -0
  350. azurkit/tools/randomizer/__pycache__/generator.cpython-314.pyc +0 -0
  351. azurkit/tools/randomizer/__pycache__/layer.cpython-314.pyc +0 -0
  352. azurkit/tools/randomizer/__pycache__/levels.cpython-314.pyc +0 -0
  353. azurkit/tools/randomizer/__pycache__/movement.cpython-314.pyc +0 -0
  354. azurkit/tools/randomizer/__pycache__/needs.cpython-314.pyc +0 -0
  355. azurkit/tools/randomizer/__pycache__/physics.cpython-314.pyc +0 -0
  356. azurkit/tools/randomizer/__pycache__/ports.cpython-314.pyc +0 -0
  357. azurkit/tools/randomizer/__pycache__/tracker.cpython-314.pyc +0 -0
  358. azurkit/tools/randomizer/__pycache__/tracking.cpython-314.pyc +0 -0
  359. azurkit/tools/randomizer/__pycache__/writer.cpython-314.pyc +0 -0
  360. azurkit/tools/randomizer/art/__init__.py +196 -0
  361. azurkit/tools/randomizer/art/__pycache__/__init__.cpython-314.pyc +0 -0
  362. azurkit/tools/randomizer/art/__pycache__/collision.cpython-314.pyc +0 -0
  363. azurkit/tools/randomizer/art/__pycache__/draw.cpython-314.pyc +0 -0
  364. azurkit/tools/randomizer/art/__pycache__/icons.cpython-314.pyc +0 -0
  365. azurkit/tools/randomizer/art/__pycache__/maps.cpython-314.pyc +0 -0
  366. azurkit/tools/randomizer/art/__pycache__/nodes.cpython-314.pyc +0 -0
  367. azurkit/tools/randomizer/art/__pycache__/reach.cpython-314.pyc +0 -0
  368. azurkit/tools/randomizer/art/__pycache__/render.cpython-314.pyc +0 -0
  369. azurkit/tools/randomizer/art/__pycache__/tern.cpython-314.pyc +0 -0
  370. azurkit/tools/randomizer/art/collision.py +142 -0
  371. azurkit/tools/randomizer/art/draw.py +345 -0
  372. azurkit/tools/randomizer/art/icons.py +265 -0
  373. azurkit/tools/randomizer/art/maps.py +805 -0
  374. azurkit/tools/randomizer/art/node_types.json +764 -0
  375. azurkit/tools/randomizer/art/nodes.py +526 -0
  376. azurkit/tools/randomizer/art/reach.py +75 -0
  377. azurkit/tools/randomizer/art/render.py +305 -0
  378. azurkit/tools/randomizer/art/tern.py +188 -0
  379. azurkit/tools/randomizer/data/contact.json +509 -0
  380. azurkit/tools/randomizer/data/derived/a1.json.gz +0 -0
  381. azurkit/tools/randomizer/data/derived/a3.json.gz +0 -0
  382. azurkit/tools/randomizer/data/derived/a5.json.gz +0 -0
  383. azurkit/tools/randomizer/data/derived/a6.json.gz +0 -0
  384. azurkit/tools/randomizer/data/derived/airship.json.gz +0 -0
  385. azurkit/tools/randomizer/data/derived/d1.json.gz +0 -0
  386. azurkit/tools/randomizer/data/derived/d2.json.gz +0 -0
  387. azurkit/tools/randomizer/data/derived/e2.json.gz +0 -0
  388. azurkit/tools/randomizer/data/derived/e5.json.gz +0 -0
  389. azurkit/tools/randomizer/data/derived/e6.json.gz +0 -0
  390. azurkit/tools/randomizer/data/derived/e7.json.gz +0 -0
  391. azurkit/tools/randomizer/data/derived/f1.json.gz +0 -0
  392. azurkit/tools/randomizer/data/derived/f2.json.gz +0 -0
  393. azurkit/tools/randomizer/data/derived/f3.json.gz +0 -0
  394. azurkit/tools/randomizer/data/derived/f4.json.gz +0 -0
  395. azurkit/tools/randomizer/data/derived/f6.json.gz +0 -0
  396. azurkit/tools/randomizer/data/derived/life.json.gz +0 -0
  397. azurkit/tools/randomizer/data/derived/town.json.gz +0 -0
  398. azurkit/tools/randomizer/data/derived/w1.json.gz +0 -0
  399. azurkit/tools/randomizer/data/derived/w2.json.gz +0 -0
  400. azurkit/tools/randomizer/data/derived/w3.json.gz +0 -0
  401. azurkit/tools/randomizer/data/derived/w4.json.gz +0 -0
  402. azurkit/tools/randomizer/data/forcing.json +2452 -0
  403. azurkit/tools/randomizer/data/generated/a1.json +2148 -0
  404. azurkit/tools/randomizer/data/generated/a3.json +606 -0
  405. azurkit/tools/randomizer/data/generated/a5.json +2301 -0
  406. azurkit/tools/randomizer/data/generated/a6.json +1687 -0
  407. azurkit/tools/randomizer/data/generated/airship.json +156 -0
  408. azurkit/tools/randomizer/data/generated/d1.json +1423 -0
  409. azurkit/tools/randomizer/data/generated/d2.json +1297 -0
  410. azurkit/tools/randomizer/data/generated/e2.json +1598 -0
  411. azurkit/tools/randomizer/data/generated/e5.json +3671 -0
  412. azurkit/tools/randomizer/data/generated/e6.json +1902 -0
  413. azurkit/tools/randomizer/data/generated/e7.json +3607 -0
  414. azurkit/tools/randomizer/data/generated/f1.json +3022 -0
  415. azurkit/tools/randomizer/data/generated/f2.json +2243 -0
  416. azurkit/tools/randomizer/data/generated/f3.json +2369 -0
  417. azurkit/tools/randomizer/data/generated/f4.json +2669 -0
  418. azurkit/tools/randomizer/data/generated/f6.json +2104 -0
  419. azurkit/tools/randomizer/data/generated/life.json +2115 -0
  420. azurkit/tools/randomizer/data/generated/town.json +3555 -0
  421. azurkit/tools/randomizer/data/generated/training_room.json +375 -0
  422. azurkit/tools/randomizer/data/generated/w1.json +2692 -0
  423. azurkit/tools/randomizer/data/generated/w2.json +2400 -0
  424. azurkit/tools/randomizer/data/generated/w3.json +2964 -0
  425. azurkit/tools/randomizer/data/generated/w4.json +2686 -0
  426. azurkit/tools/randomizer/data/items.toml +398 -0
  427. azurkit/tools/randomizer/data/levels/a1.toml +1213 -0
  428. azurkit/tools/randomizer/data/levels/a3.toml +344 -0
  429. azurkit/tools/randomizer/data/levels/a5.toml +1397 -0
  430. azurkit/tools/randomizer/data/levels/a6.toml +1263 -0
  431. azurkit/tools/randomizer/data/levels/airship.toml +37 -0
  432. azurkit/tools/randomizer/data/levels/d1.toml +876 -0
  433. azurkit/tools/randomizer/data/levels/d2.toml +716 -0
  434. azurkit/tools/randomizer/data/levels/e2.toml +3393 -0
  435. azurkit/tools/randomizer/data/levels/e5.toml +1580 -0
  436. azurkit/tools/randomizer/data/levels/e6.toml +1349 -0
  437. azurkit/tools/randomizer/data/levels/e7.toml +865 -0
  438. azurkit/tools/randomizer/data/levels/f1.toml +2544 -0
  439. azurkit/tools/randomizer/data/levels/f2.toml +1590 -0
  440. azurkit/tools/randomizer/data/levels/f3.toml +1249 -0
  441. azurkit/tools/randomizer/data/levels/f4.toml +1061 -0
  442. azurkit/tools/randomizer/data/levels/f6.toml +1100 -0
  443. azurkit/tools/randomizer/data/levels/life.toml +571 -0
  444. azurkit/tools/randomizer/data/levels/town.toml +1139 -0
  445. azurkit/tools/randomizer/data/levels/w1.toml +1131 -0
  446. azurkit/tools/randomizer/data/levels/w2.toml +1024 -0
  447. azurkit/tools/randomizer/data/levels/w3.toml +1743 -0
  448. azurkit/tools/randomizer/data/levels/w4.toml +422 -0
  449. azurkit/tools/randomizer/data/movement/a1.json +321 -0
  450. azurkit/tools/randomizer/data/movement/a3.json +93 -0
  451. azurkit/tools/randomizer/data/movement/a5.json +141 -0
  452. azurkit/tools/randomizer/data/movement/a6.json +110 -0
  453. azurkit/tools/randomizer/data/movement/airship.json +12 -0
  454. azurkit/tools/randomizer/data/movement/d1.json +60 -0
  455. azurkit/tools/randomizer/data/movement/d2.json +60 -0
  456. azurkit/tools/randomizer/data/movement/e2.json +867 -0
  457. azurkit/tools/randomizer/data/movement/e5.json +392 -0
  458. azurkit/tools/randomizer/data/movement/e6.json +234 -0
  459. azurkit/tools/randomizer/data/movement/e7.json +124 -0
  460. azurkit/tools/randomizer/data/movement/f1.json +601 -0
  461. azurkit/tools/randomizer/data/movement/f2.json +395 -0
  462. azurkit/tools/randomizer/data/movement/f3.json +332 -0
  463. azurkit/tools/randomizer/data/movement/f4.json +250 -0
  464. azurkit/tools/randomizer/data/movement/f6.json +220 -0
  465. azurkit/tools/randomizer/data/movement/life.json +12 -0
  466. azurkit/tools/randomizer/data/movement/town.json +142 -0
  467. azurkit/tools/randomizer/data/movement/w1.json +188 -0
  468. azurkit/tools/randomizer/data/movement/w2.json +193 -0
  469. azurkit/tools/randomizer/data/movement/w3.json +155 -0
  470. azurkit/tools/randomizer/data/movement/w4.json +59 -0
  471. azurkit/tools/randomizer/data/proofs.jsonl +21 -0
  472. azurkit/tools/randomizer/data/route.toml +3627 -0
  473. azurkit/tools/randomizer/data/settings/defaults.toml +16 -0
  474. azurkit/tools/randomizer/data/slider_tiers.json +19 -0
  475. azurkit/tools/randomizer/data/tricks.toml +2432 -0
  476. azurkit/tools/randomizer/data/world.toml +368 -0
  477. azurkit/tools/randomizer/dbcache.py +197 -0
  478. azurkit/tools/randomizer/draft/__init__.py +11 -0
  479. azurkit/tools/randomizer/draft/__pycache__/__init__.cpython-314.pyc +0 -0
  480. azurkit/tools/randomizer/draft/__pycache__/envelope.cpython-314.pyc +0 -0
  481. azurkit/tools/randomizer/draft/__pycache__/floors.cpython-314.pyc +0 -0
  482. azurkit/tools/randomizer/draft/__pycache__/geometry.cpython-314.pyc +0 -0
  483. azurkit/tools/randomizer/draft/__pycache__/wiring.cpython-314.pyc +0 -0
  484. azurkit/tools/randomizer/draft/envelope.py +211 -0
  485. azurkit/tools/randomizer/draft/floors.py +221 -0
  486. azurkit/tools/randomizer/draft/geometry.py +682 -0
  487. azurkit/tools/randomizer/draft/tiers.toml +97 -0
  488. azurkit/tools/randomizer/draft/wiring.py +632 -0
  489. azurkit/tools/randomizer/generator.py +466 -0
  490. azurkit/tools/randomizer/layer.py +302 -0
  491. azurkit/tools/randomizer/levels.py +1134 -0
  492. azurkit/tools/randomizer/live/__init__.py +15 -0
  493. azurkit/tools/randomizer/live/__pycache__/__init__.cpython-314.pyc +0 -0
  494. azurkit/tools/randomizer/live/__pycache__/errors.cpython-314.pyc +0 -0
  495. azurkit/tools/randomizer/live/__pycache__/game.cpython-314.pyc +0 -0
  496. azurkit/tools/randomizer/live/__pycache__/link.cpython-314.pyc +0 -0
  497. azurkit/tools/randomizer/live/__pycache__/pages.cpython-314.pyc +0 -0
  498. azurkit/tools/randomizer/live/__pycache__/recipes.cpython-314.pyc +0 -0
  499. azurkit/tools/randomizer/live/__pycache__/regions.cpython-314.pyc +0 -0
  500. azurkit/tools/randomizer/live/errors.py +8 -0
  501. azurkit/tools/randomizer/live/game.py +775 -0
  502. azurkit/tools/randomizer/live/link.py +534 -0
  503. azurkit/tools/randomizer/live/pages.py +76 -0
  504. azurkit/tools/randomizer/live/recipes.py +168 -0
  505. azurkit/tools/randomizer/live/regions.py +42 -0
  506. azurkit/tools/randomizer/movement.py +116 -0
  507. azurkit/tools/randomizer/needs.py +344 -0
  508. azurkit/tools/randomizer/physics.py +711 -0
  509. azurkit/tools/randomizer/planner/__init__.py +8 -0
  510. azurkit/tools/randomizer/planner/__pycache__/__init__.cpython-314.pyc +0 -0
  511. azurkit/tools/randomizer/planner/__pycache__/difficulty.cpython-314.pyc +0 -0
  512. azurkit/tools/randomizer/planner/__pycache__/fill.cpython-314.pyc +0 -0
  513. azurkit/tools/randomizer/planner/__pycache__/grammar.cpython-314.pyc +0 -0
  514. azurkit/tools/randomizer/planner/__pycache__/loader.cpython-314.pyc +0 -0
  515. azurkit/tools/randomizer/planner/__pycache__/plan.cpython-314.pyc +0 -0
  516. azurkit/tools/randomizer/planner/__pycache__/proofs.cpython-314.pyc +0 -0
  517. azurkit/tools/randomizer/planner/__pycache__/settings.cpython-314.pyc +0 -0
  518. azurkit/tools/randomizer/planner/__pycache__/solver.cpython-314.pyc +0 -0
  519. azurkit/tools/randomizer/planner/difficulty.py +547 -0
  520. azurkit/tools/randomizer/planner/fill.py +1084 -0
  521. azurkit/tools/randomizer/planner/grammar.py +138 -0
  522. azurkit/tools/randomizer/planner/loader.py +1764 -0
  523. azurkit/tools/randomizer/planner/plan.py +905 -0
  524. azurkit/tools/randomizer/planner/proofs.py +144 -0
  525. azurkit/tools/randomizer/planner/settings.py +414 -0
  526. azurkit/tools/randomizer/planner/solver.py +1192 -0
  527. azurkit/tools/randomizer/ports.py +70 -0
  528. azurkit/tools/randomizer/shipped/ec11036b553991593c84383ad37ebd62.pickle +4 -0
  529. azurkit/tools/randomizer/tracker.py +376 -0
  530. azurkit/tools/randomizer/tracking.py +309 -0
  531. azurkit/tools/randomizer/writer.py +384 -0
  532. azurkit/tools/saves/__init__.py +93 -0
  533. azurkit/tools/saves/__pycache__/__init__.cpython-314.pyc +0 -0
  534. azurkit/tools/saves/__pycache__/container.cpython-314.pyc +0 -0
  535. azurkit/tools/saves/__pycache__/fatx.cpython-314.pyc +0 -0
  536. azurkit/tools/saves/__pycache__/session.cpython-314.pyc +0 -0
  537. azurkit/tools/saves/__pycache__/sign.cpython-314.pyc +0 -0
  538. azurkit/tools/saves/container.py +275 -0
  539. azurkit/tools/saves/fatx.py +275 -0
  540. azurkit/tools/saves/session.py +92 -0
  541. azurkit/tools/saves/sign.py +117 -0
  542. azurkit-1.0.0.dist-info/METADATA +102 -0
  543. azurkit-1.0.0.dist-info/RECORD +547 -0
  544. azurkit-1.0.0.dist-info/WHEEL +4 -0
  545. azurkit-1.0.0.dist-info/entry_points.txt +3 -0
  546. azurkit-1.0.0.dist-info/licenses/LICENSE +21 -0
  547. azurkit-1.0.0.dist-info/licenses/NOTICE.md +2108 -0
@@ -0,0 +1,1290 @@
1
+ """The app host (D732): the web page (``web/``) with the native engine
2
+ behind it (D866), in a pywebview window, or, where there is no webview
3
+ (Linux without pywebview's GTK or Qt extras, say), in the default browser.
4
+
5
+ Either way the page comes from a server on 127.0.0.1 (:func:`serve`) that
6
+ answers only a request that names it as the host (no DNS rebinding) and
7
+ carries this launch's token: in its URL the first time, then in the cookie
8
+ that answer sets. The cookie is named for the launch's port: browsers keep
9
+ cookies by host, not port, so a second launch's never replaces the first's.
10
+ The engine below never starts a thread; this host does, for the server,
11
+ and the app's engine process (:mod:`.engine`).
12
+
13
+ The server is the page's engine too. ``host.json`` tells the page it is in
14
+ the app, with the catalog, AzurKit's version and the commit it was built
15
+ from (``azurkit.commit``, About's), the image to start from (the
16
+ one the last build read, remembered in platformdirs' config folder, else a
17
+ release's own image), whether there are file dialogs (the window's), the
18
+ site's address, which its share links name (:data:`SITE`, D884), and the
19
+ theme the player last picked (dark until they pick light, D897; ``POST
20
+ api/theme`` ``{"theme"}`` remembers it there too: the page's address, and
21
+ so its storage, is new each launch) and the page's setup as it was last
22
+ left (D915: the packs ticked, the settings changed and a loaded recipe's
23
+ config.xbr cells, never the image or anything read from it; ``POST
24
+ api/setup`` ``{"setup"}`` remembers it, ``null`` forgets it, and ``POST
25
+ api/restore`` ``{"setup"}`` gives back what still applies,
26
+ ``sdk.restore``). The page's ``theme.js``, which sets
27
+ the theme before its first paint, is the server's own (:func:`_theme_js`),
28
+ and the window opens on that theme's ground (:data:`GROUNDS`), so a player
29
+ who picked light never sees a dark first frame.
30
+ ``POST api/build`` ``{"iso", "packs", "xbr", "randomizer"}`` plans the build (``sdk.image``: the
31
+ report, the size, the build's id, and where to save it: the Downloads
32
+ folder, under the site's download's name), and ``POST api/save``
33
+ ``{"recipe", "out", "build"}`` writes it: the image that build made, kept
34
+ and hashed in the engine from the moment it was made (``sdk.save``), else,
35
+ when the engine keeps it no more or its input changed, the recipe built
36
+ again (``sdk.build``); never over an existing file but the one the save
37
+ dialog chose (it asked first), with ``POST api/cancel``. ``GET
38
+ api/progress`` gives the progress of the calls in flight, each a
39
+ ``core.progress`` record (``{"stage", "label", "done", "total",
40
+ "percent"}``): the newest's at its top, and each kind's in ``calls``
41
+ (``build``, ``save``: their named stages, the swap's items counted;
42
+ ``preview``, ``match``: the call's own line, the swap's items counted;
43
+ ``plan``: its logic read, fill and placements; ``tracker``: a plan's
44
+ first view's seed logic and world map, the maps' exits counted on it
45
+ outside Live's view; ``randomizer``, ``restore``,
46
+ ``vanilla``, ``spoiler``: the call's own line, no count), which the page
47
+ shows as its bar.
48
+ Both run in the engine (:mod:`.engine`): ``serve``'s on a thread of the
49
+ server, the app's (:func:`main`) in a process of its own, with the seed's
50
+ plan and the Characters tab's pictures and G4 match, so the server's own
51
+ answers never wait on a build, and Cancel answers at once.
52
+ Work ahead (:mod:`.ahead`, D1007): ``POST api/prefetch`` ``{"gen", "recipe",
53
+ "view"}`` plans the page's typed seed (``{seed, share}``, as Track asks it)
54
+ and reads that plan's first tracker view, and ``POST api/speculate`` ``{"gen",
55
+ "iso", "packs", "xbr", "randomizer", "character"}`` builds the page's settled
56
+ setup, each at the page's change number; Track, the tracker's first ask and
57
+ Build take them. ``api/install`` carries ``gen`` too.
58
+ ``POST api/spoiler`` ``{}`` gives the last randomized build's spoiler
59
+ (``{"text"}``), which the build leaves out (``sdk.image``'s ``spoiler``
60
+ false) and the engine makes from the plan it kept the first time it is
61
+ asked (``sdk.spoiler(plan, built=True)``), and ``{"out"}`` writes it there,
62
+ on the same terms. ``POST api/randomizer`` ``{"given"}`` gives what
63
+ the Randomizer tab shows (``sdk.randomizer_settings``).
64
+ ``POST api/settings_file`` ``{"out", "settings"}`` writes the Randomizer
65
+ tab's settings as a settings file (D144; ``out`` null: ``settings.toml``
66
+ in the Downloads folder), never over an existing file but one the save
67
+ dialog chose.
68
+ ``POST api/tracker`` ``{"given"}`` gives what the item tracker shows
69
+ (``sdk.tracker``; a shaped plan's Spoiler's openers are the engine's, as
70
+ its work ahead keeps them; with no plan the vanilla game's spots, never in
71
+ the slot a typed seed's first view is read ahead in); with Live on for its plan, it first merges what Live
72
+ read since the last ask (:mod:`.live`: the spots the game shows taken, the
73
+ element levels it holds) and gives Live's status and where it saw the
74
+ player (``live``). ``POST api/live`` ``{"on", "plan", "marks",
75
+ "endpoints"}`` turns Live on for a plan (reading the first of the
76
+ endpoints that answers as xemu: the fork's shared RAM, or QMP; Modkit's
77
+ grammar, empty for the defaults) or off. ``POST api/plan`` ``{"recipe"}`` plans a seed to track
78
+ (``sdk.plan``), and ``POST api/tracked`` ``{"tracker"}`` remembers the
79
+ plan last tracked and the marks of the last plans (``host.json``'s
80
+ ``tracker``: the randomizer's names and numbers, never the image's).
81
+ ``POST api/art`` gives the tracker's pictures that ship with the page
82
+ (:mod:`.art`: its maps' index and its icons' names, Step 4.3f);
83
+ ``GET art/<version>/<name>`` serves one.
84
+ Live (``api/live``'s ``gems``) reads the gem spots too while the tracker
85
+ shows them (R1.43).
86
+ ``POST api/vanilla`` ``{"iso"}`` reads the image's own values of the
87
+ sliders at its XBE's sites (``sdk.vanilla``), or, when the image is an
88
+ AzurKit build, ``{"opened": its recipe}`` (``sdk.open_build`` of the files
89
+ it changed).
90
+ ``POST api/preset`` ``{"recipe", "report"}`` makes a share link's fragment
91
+ (``sdk.preset_encode``) and ``POST api/decode`` ``{"text"}`` reads one
92
+ (``sdk.preset_decode``).
93
+ The Characters tab's calls (Step 4.3d): ``POST api/pickers`` ``{"given"}``
94
+ (``sdk.pickers``), ``api/preview`` and ``api/match`` ``{"recipe"}`` (the
95
+ pictures as base64 PNG), and Azurik-Custom-Content's ``api/content``
96
+ ``{"source"}`` (``sdk.content``) and ``api/install`` ``{"id", "source"}``
97
+ (``sdk.content_install``, into the content folder); ``api/community``
98
+ ``{}`` the community packs that folder holds (``sdk.community``); ``api/replace``
99
+ ``{"path", "file"}`` picks a computer file to replace one of the image's (``sdk.file_entry``, held for this session);
100
+ ``api/saves`` ``{"given"}`` the Tools tab's Saves (``sdk.saves``); ``api/content_source``
101
+ ``{"source"}`` remembers the tab's Content from (``null``: the default),
102
+ which ``host.json``'s ``content_source`` gives the next page, and
103
+ ``api/show_objects`` ``{"show"}`` its Show objects (D1034: ``false``, the
104
+ default, hides the objects), ``host.json``'s ``show_objects``.
105
+ The Data tab's calls (Step 4.7a): ``POST api/data`` ``{"given"}`` gives a
106
+ view of config.xbr's cells (``sdk.data``: the vanilla file AzurKit ships,
107
+ no image) and ``api/data_edit`` ``{"given"}`` checks a cell's edit into the
108
+ recipe's ``xbr`` operations (``sdk.data_edit``); the page keeps them.
109
+ The Tools tab's calls (Step 4.7c): ``POST api/info`` ``{"iso"}`` says what
110
+ the image is (``sdk.image_info``), ``api/verify_all`` ``{"iso"}`` checks
111
+ every file of it (``sdk.verify_all``: only what its kept check hasn't
112
+ hashed, its bar in ``api/progress``), ``api/files`` ``{"iso"}`` lists its
113
+ files (``sdk.files``) and ``api/extract`` ``{"iso", "path", "out"}``
114
+ writes one (``sdk.extract``); ``api/open`` ``{"iso"}`` reads an AzurKit
115
+ build's recipe (``sdk.open_build`` of the files it changed);
116
+ ``api/mods_file`` ``{"text"}`` reads a mods file and ``{"recipe" |
117
+ "preset", "out"}`` writes one (``sdk.mods_file``), ``api/plan_spoiler``
118
+ ``{"plan"}`` gives a plan file's spoiler (the engine's ``sdk.spoiler``) and
119
+ ``api/report`` ``{"text", "out", "name"}`` writes a text the tab shows (the
120
+ last build's report); each save goes to ``out``, else the Downloads folder,
121
+ never over an existing file but the one the save dialog chose, nor a
122
+ vanilla image or the image it reads (D36). ``api/storage`` ``{"clear"}``
123
+ lists what the app keeps on this computer, by name (:func:`kept`), and
124
+ clears those entries alone: never an image or the folder that may hold one
125
+ (SEC-10); asked as the tab opens, it has the engine build the randomizer's
126
+ logic meanwhile, for a plan file's spoiler.
127
+ ``POST api/choose`` ``{"kind": "iso" | "build" | "out" | ..., "path"}`` opens the window's
128
+ file dialog. A POST is JSON from the page's own origin. A refusal (the
129
+ sdk's) comes back as ``{"refused": message}``; one build or save runs at a
130
+ time. Closing the app cancels either (:meth:`Server.quit`, AZ-823) and
131
+ never waits on a build (the app's engine process: it ends it).
132
+
133
+ The app (:func:`main`) keeps the images it checked and the swap's answers
134
+ across launches in platformdirs' cache folder (:mod:`.swapcache`), so an
135
+ image picked again isn't hashed again and a body dressed once isn't
136
+ dressed again; ``serve`` alone (the tests') keeps nothing past its process.
137
+
138
+ The tests run with :data:`NO_WINDOW` set (``tests/conftest.py``): the owner
139
+ works on this screen (D839), so there :func:`main` refuses at once, before it
140
+ serves anything or opens a window or a browser tab. ``serve`` (``python -m
141
+ azurkit serve``, the frozen app's ``AzurKit serve``) opens neither in any
142
+ mode: it prints the page's address and serves until its input ends.
143
+ """
144
+
145
+ from __future__ import annotations
146
+
147
+ import contextlib
148
+ import glob
149
+ import hashlib
150
+ import hmac
151
+ import http.server
152
+ import json
153
+ import math
154
+ import os
155
+ import secrets
156
+ import sys
157
+ import threading
158
+ import time
159
+ import urllib.parse
160
+ import webbrowser
161
+ from collections.abc import Callable
162
+ from pathlib import Path
163
+
164
+ from azurkit import __version__, commit, sdk
165
+ from azurkit.core import gate
166
+ from azurkit.core.progress import record
167
+ from azurkit.edits import files as replaced
168
+ from azurkit.edits import xbr
169
+ from azurkit.tools import randomizer
170
+ from azurkit.tools.mods import community as communal
171
+ from azurkit.tools.randomizer import dbcache, tracking
172
+ from azurkit.tools.randomizer.live import link
173
+
174
+ from . import ahead, live, swapcache, warm
175
+ from . import art as pictures
176
+ from . import engine as engines
177
+ from .engine import Cancelled
178
+
179
+ SITE = "https://azurik-modding-hub.github.io/AzurKit/" # the site (D884), which the app's share links open
180
+
181
+ PAGE = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[3])) / "web" # the frozen app's, else the checkout's
182
+ NO_WINDOW = "AZURKIT_NO_WINDOW" # set: main() opens nothing (the tests' mode)
183
+ MAX_BODY = 1 << 20
184
+ KEEP = 1.0
185
+ """s a call may run before its answer's headers go, and between the spaces that follow them until the answer (JSON
186
+ reads past them): Firefox aborts every connection that carried no byte in the 5 s after the computer's network
187
+ changes (``network.http.network-changed.timeout``), and a call that sent nothing until its answer lost it, a plan's or
188
+ a spoiler's of seconds, tens on a slow runner ("NetworkError"; a Mac's IPv6 changed every 1-8 min here, Step 4.4ci36)."""
189
+ IMAGES = ("Disc images (*.iso;*.xiso)",) # the open dialog's filter (another name can be typed in)
190
+ SAVES = ("out", "spoiler", "settings", "extract", "mods", "report", "saves") # the kinds of save the save dialog chooses for
191
+ MODS = "AzurKit mods.json" # the Tools tab's mods file, by default (web/tools.mjs's)
192
+ NEVER_CLEARED = (".iso", ".xiso") # an image AzurKit's Clear never removes, wherever it is (SEC-10)
193
+ # text_select: pywebview otherwise injects a <style> to stop selection, which the page's CSP refuses
194
+ WINDOW = {"title": "AzurKit", "width": 1100, "height": 820, "text_select": True}
195
+ # The page's themes (D897: dark until the player picks light, which the app remembers) and each one's ground
196
+ # (web/app.css's --bg), which the window shows until the page draws
197
+ GROUNDS = {"dark": "#0d1117", "light": "#e8ecf1"}
198
+ THEMES = tuple(GROUNDS)
199
+ QUIT_WAIT = 1.5 # seconds a close waits, in all, for a cancelled save to remove its part file (AZ-823)
200
+ QUITTING = "AzurKit is quitting: nothing was written"
201
+ CONTENT_TYPES = {".css": "text/css", ".glb": "model/gltf-binary", ".gltf": "model/gltf+json", ".html": "text/html",
202
+ ".ico": "image/x-icon", ".js": "text/javascript", ".json": "application/json", ".md": "text/markdown",
203
+ ".mjs": "text/javascript", ".png": "image/png", ".wasm": "application/wasm", ".wav": "audio/x-wav",
204
+ ".zip": "application/zip"}
205
+ """What a browser is told of each kind of file the page, Pyodide's core and the content folder hold: this table, never
206
+ ``mimetypes``, whose guess on Windows is the registry's (``text/plain`` for a ``.mjs`` on many a machine, which a browser
207
+ refuses to run as a module, so the page never starts)."""
208
+ IDLE = {"stage": None, "label": "", "done": 0, "total": 0, "percent": None} # api/progress with nothing in flight
209
+
210
+
211
+ def content_type(name: str) -> str:
212
+ """The ``Content-Type`` of a file called ``name``: :data:`CONTENT_TYPES`'s, else plain bytes."""
213
+ return CONTENT_TYPES.get(Path(name).suffix.lower(), "application/octet-stream")
214
+
215
+
216
+ def _config() -> Path:
217
+ from platformdirs import user_config_path
218
+ return user_config_path("AzurKit", appauthor=False) / "app.json"
219
+
220
+
221
+ _REMEMBERING = threading.RLock()
222
+ """Held to read the config and to write it: a build's thread and the page's calls each remember a part of it, and
223
+ Windows refuses to replace a file another thread has open."""
224
+ WAITS = (0.05, 0.1, 0.2, 0.4, 0.8, 0)
225
+ """s between the tries of the config's read or replace that Windows refuses while another process (a virus scanner)
226
+ holds the file open for a moment; refused at the last, it fails."""
227
+
228
+
229
+ def _tried(attempt: Callable[[], object]) -> object:
230
+ """``attempt()``, tried again after each of :data:`WAITS` it raises PermissionError."""
231
+ for wait in WAITS:
232
+ try:
233
+ return attempt()
234
+ except PermissionError:
235
+ if not wait:
236
+ raise
237
+ time.sleep(wait)
238
+
239
+
240
+ def _read() -> dict:
241
+ """What the config holds, read past a moment it is held open (:func:`_tried`): ``{}`` when there is none;
242
+ ValueError for a file that holds no settings object, OSError for one it still can't read."""
243
+ with _REMEMBERING:
244
+ try:
245
+ kept = json.loads(_tried(lambda: _config().read_text(encoding="utf-8")))
246
+ except FileNotFoundError:
247
+ return {}
248
+ if not isinstance(kept, dict):
249
+ raise ValueError(f"a JSON {type(kept).__name__}, not an object")
250
+ return kept
251
+
252
+
253
+ def _logic_ahead() -> None:
254
+ """The randomizer's logic, read ahead from the server's start on D1008's
255
+ intent (Randomize on in the remembered setup, a tracked plan
256
+ remembered), off the launch's path, which checks the setup against the
257
+ shipped skip table alone: the Randomizer tab's first ask or the tracker's
258
+ view that follows finds it read, or waits for this read (Step 4.4bd).
259
+ Only the wheel's or the frozen app's shipped one, never a checkout's
260
+ check (about 4 s)."""
261
+ with contextlib.suppress(Exception): # a refusal: the page's own call names it
262
+ if dbcache.shipped_skips(randomizer.DATA_DIR) is not None:
263
+ randomizer.kept_logic()
264
+
265
+
266
+ def _kept() -> dict:
267
+ """What the app remembers (``{"iso", "theme", "setup"}``), else nothing. Read past a moment it is held open too: a
268
+ page handed no setup for one would remember the next change over it."""
269
+ try:
270
+ return _read()
271
+ except (OSError, ValueError):
272
+ return {}
273
+
274
+
275
+ def _start_image(kept: dict | None = None) -> str:
276
+ """The image the last build read (in ``kept``, else the config), else a release's own image, else ``""``."""
277
+ last = (_kept() if kept is None else kept).get("iso")
278
+ if isinstance(last, str) and Path(last).is_file():
279
+ return last
280
+ return str(next(filter(None, map(gate.find, gate.RELEASES)), ""))
281
+
282
+
283
+ def _theme(kept: dict | None = None) -> str:
284
+ """The theme the player last picked (in ``kept``, else the config), else dark (D897)."""
285
+ return theme if (theme := (_kept() if kept is None else kept).get("theme")) in THEMES else THEMES[0]
286
+
287
+
288
+ def _theme_js() -> bytes:
289
+ """The app's ``theme.js`` (the site's reads the browser's storage): it
290
+ sets the remembered theme before the page's first paint."""
291
+ return f'document.documentElement.dataset.theme = "{_theme()}";\n'.encode()
292
+
293
+
294
+ def _remember(**kept: object) -> None:
295
+ """Remember ``kept`` beside what the config holds (``None`` forgets a
296
+ key), written whole or not at all, past a moment another process (a
297
+ virus scanner, on Windows) holds the file open, to read it or to
298
+ replace it (:func:`_read`): a read refused past that writes nothing,
299
+ as what it couldn't read would be lost, and a file that holds no
300
+ settings is written over. Each says why on stderr, and no more: a build
301
+ doesn't fail for it."""
302
+ def say(text: str) -> None:
303
+ with contextlib.suppress(OSError, ValueError): # a stderr gone with its reader
304
+ print(f"azurkit: {text}", file=sys.stderr)
305
+
306
+ try:
307
+ with _REMEMBERING:
308
+ try:
309
+ was = _read()
310
+ except ValueError as exc:
311
+ say(f"the config held no settings ({exc}): remembering {', '.join(kept)} writes it over")
312
+ was = {}
313
+ _config().parent.mkdir(parents=True, exist_ok=True)
314
+ part = _config().with_suffix(".json.part")
315
+ part.write_text(json.dumps({k: v for k, v in (was | kept).items() if v is not None}) + "\n",
316
+ encoding="utf-8")
317
+ _tried(lambda: part.replace(_config()))
318
+ except OSError as exc:
319
+ say(f"can't remember {', '.join(kept)}: {exc}")
320
+
321
+
322
+ def _set_theme(body: dict) -> dict:
323
+ """``api/theme``: remember the theme the page's toggle picked."""
324
+ if (theme := body.get("theme")) not in THEMES:
325
+ raise ValueError(f"theme: {theme!r} is none of {', '.join(THEMES)}")
326
+ _remember(theme=theme)
327
+ return {}
328
+
329
+
330
+ def _set_setup(body: dict) -> dict:
331
+ """``api/setup``: remember the page's setup (D915), or forget it
332
+ (``null``): pack and setting names (identifiers, so never a path), their
333
+ numbers and config.xbr ``set`` operations only, so never a
334
+ ``replace_bytes``' bytes. A ``set``'s table, entity, prop and text
335
+ value are free strings (config.xbr's names hold spaces; text cells hold
336
+ text), checked by :func:`xbr.normalize` alone: the page fills them from
337
+ the catalog and never puts the image's path in one. The Randomizer
338
+ tab's (``randomizer``: on, the seed the player typed, the settings and
339
+ the obsidian cost) is checked as a build checks it
340
+ (``tools.randomizer.remembered``), and the Characters tab's
341
+ (``character``) as a share link carries one: names and numbers."""
342
+ if (setup := body.get("setup")) is not None:
343
+ if not (isinstance(setup, dict) and set(setup) <= set(sdk.SETUP)):
344
+ raise ValueError(f"setup: an object of {', '.join(sdk.SETUP)} only, or null")
345
+ ticked, values, ops = setup.get("ticked", []), setup.get("values", {}), setup.get("xbr", [])
346
+ if not (isinstance(ticked, list) and all(map(_named, ticked))):
347
+ raise ValueError("setup: ticked holds pack names only")
348
+ if not (isinstance(values, dict) and all(_named(pack) and _numbers(settings) for pack, settings in values.items())):
349
+ raise ValueError("setup: values holds {pack: {setting: number}} only")
350
+ if not (isinstance(ops, list) and all(isinstance(op, dict) and op.get("op") == "set" for op in ops)):
351
+ raise ValueError("setup: xbr holds config.xbr set operations only (Q174)")
352
+ xbr.normalize(ops) # each set with exactly its keys, else refused by name
353
+ if "randomizer" in setup:
354
+ try:
355
+ randomizer.remembered(setup["randomizer"])
356
+ except ValueError as exc:
357
+ raise ValueError(f"setup: randomizer: {exc}") from None
358
+ if "character" in setup and (lost := sdk.restore({"character": setup["character"]})["dropped"]):
359
+ raise ValueError(f"setup: {lost[0]}") # by name and numbers, never a recipe file's path
360
+ if "community" in setup:
361
+ communal.normalize(setup["community"]) # ids and pins only, else refused by name
362
+ _remember(setup=setup)
363
+ return {}
364
+
365
+
366
+ TRACKED = 20 # the plans whose marks the app keeps (the page's KEEP)
367
+
368
+
369
+ def _set_tracked(body: dict) -> dict:
370
+ """``api/tracked``: remember the tracker's plan last tracked (a plan
371
+ file's object: the randomizer's names and numbers) and the marks of
372
+ the last :data:`TRACKED` plans (``{sha256: marks}``), Live's endpoints,
373
+ Gems and Show collected, or forget them (``null``); marks the tracker
374
+ can't read are refused by name."""
375
+ if (kept := body.get("tracker")) is not None:
376
+ if not (isinstance(kept, dict) and set(kept) <= {"plan", "marks", "endpoints", "gems", "collected"}
377
+ and all(kept.get(k, False) in (True, False) for k in ("gems", "collected"))):
378
+ raise ValueError("tracker: an object of plan, marks, endpoints, gems and collected only, or null")
379
+ if not isinstance(endpoints := kept.get("endpoints", ""), str):
380
+ raise ValueError("tracker: endpoints is a list's text")
381
+ try:
382
+ link.parse(endpoints)
383
+ except ValueError as exc:
384
+ raise ValueError(f"tracker: endpoints: {exc}") from None
385
+ plan, marks = kept.get("plan"), kept.get("marks", {})
386
+ if not (plan is None or (isinstance(plan, dict) and isinstance(plan.get("sha256"), str))):
387
+ raise ValueError("tracker: plan is a plan file's object")
388
+ if not (isinstance(marks, dict) and len(marks) <= TRACKED and all(isinstance(k, str) for k in marks)):
389
+ raise ValueError(f"tracker: marks is {{plan sha256: marks}} of at most {TRACKED} plans")
390
+ for each in marks.values():
391
+ tracking.marks_of(each)
392
+ _remember(tracker=kept)
393
+ return {}
394
+
395
+
396
+ def _set_source(body: dict) -> dict:
397
+ """``api/content_source``: remember the Characters tab's Content from
398
+ (an http(s) URL or a folder's path, as ``sdk.content`` reads it), or
399
+ forget it (``null``: the default)."""
400
+ if (source := body.get("source")) is not None:
401
+ if not (isinstance(source, str) and len(source) <= 2048 and (
402
+ urllib.parse.urlsplit(source.strip()).scheme in ("http", "https")
403
+ or Path(source.strip()).expanduser().is_absolute())):
404
+ raise ValueError("content_source: an http(s) URL or a folder's full path")
405
+ _remember(content_source=source)
406
+ return {}
407
+
408
+
409
+ def _set_objects(body: dict) -> dict:
410
+ """``api/show_objects``: remember the Characters tab's Show objects
411
+ (``true``; ``false`` forgets it: the objects hidden, the default)."""
412
+ if not isinstance(show := body.get("show"), bool):
413
+ raise ValueError("show_objects: show is true or false")
414
+ _remember(show_objects=show or None)
415
+ return {}
416
+
417
+
418
+ def _downloads() -> Path:
419
+ """The Downloads folder (made where it isn't): where a save without a path goes."""
420
+ from platformdirs import user_downloads_path
421
+ folder = user_downloads_path()
422
+ folder.mkdir(parents=True, exist_ok=True)
423
+ return folder
424
+
425
+
426
+ def _written(out: Path, data: bytes) -> dict:
427
+ """``data`` at ``out``, whole or not at all (a part file renamed into place)."""
428
+ part = out.with_name(f"{out.name}.part")
429
+ try:
430
+ part.write_bytes(data)
431
+ part.replace(out)
432
+ finally:
433
+ part.unlink(missing_ok=True)
434
+ return {"out": str(out), "size": len(data)}
435
+
436
+
437
+ def kept() -> list[tuple[str, str, Path]]:
438
+ """What the app keeps on this computer (``api/storage``), each a named
439
+ entry of its own (SEC-10): ``(name, what, path)``."""
440
+ from platformdirs import user_data_path
441
+ return [("settings", "Your setup, theme, tracker marks, Content from and the last image's path", _config()),
442
+ ("stamps", "The images AzurKit checked (each one's path, size and times)", swapcache.root() / "stamps.json"),
443
+ ("swap", "The characters' dressed bodies, pictures and G4 matches", swapcache.root() / "swap"),
444
+ ("content", "Installed community content", user_data_path("AzurKit", appauthor=False) / "content")]
445
+
446
+
447
+ def _size(path: Path) -> int:
448
+ if not path.is_dir() or path.is_symlink():
449
+ return path.lstat().st_size
450
+ return sum((Path(root) / name).lstat().st_size for root, _, names in os.walk(path) for name in names)
451
+
452
+
453
+ def _clear(path: Path) -> bool:
454
+ """Remove ``path``, a :func:`kept` entry: a file, or a folder's files
455
+ and then the folders they leave empty; never an image
456
+ (:data:`NEVER_CLEARED`), nor a folder still holding one (SEC-10).
457
+ Whether it held anything."""
458
+ if not (path.exists() or path.is_symlink()):
459
+ return False
460
+ if path.is_symlink() or not path.is_dir():
461
+ if path.suffix.lower() in NEVER_CLEARED:
462
+ return False
463
+ path.unlink()
464
+ return True
465
+ for root, folders, names in os.walk(path, topdown=False):
466
+ for name in [*names, *(f for f in folders if (Path(root) / f).is_symlink())]:
467
+ if Path(name).suffix.lower() not in NEVER_CLEARED:
468
+ (Path(root) / name).unlink()
469
+ with contextlib.suppress(OSError): # it still holds an image
470
+ Path(root).rmdir()
471
+ return True
472
+
473
+
474
+ def _storage(body: dict) -> dict:
475
+ """``api/storage``: each :func:`kept` entry there is, its size in bytes;
476
+ with ``clear``, each removed first (:func:`_clear`) and named in
477
+ ``cleared``."""
478
+ cleared = [name for name, _, path in kept() if body.get("clear") and _clear(path)]
479
+ return {"entries": [{"name": name, "what": what, "size": _size(path)} for name, what, path in kept()
480
+ if path.exists() or path.is_symlink()]} | ({"cleared": cleared} if body.get("clear") else {})
481
+
482
+
483
+ def _named(name: object) -> bool:
484
+ """``name`` is a pack's or a setting's name (an identifier: never a path)."""
485
+ return isinstance(name, str) and name.isidentifier()
486
+
487
+
488
+ def _numbers(settings: object) -> bool:
489
+ """``settings`` is ``{setting: a finite number}``."""
490
+ return isinstance(settings, dict) and all(_named(name) and type(value) in (int, float) and math.isfinite(value)
491
+ for name, value in settings.items())
492
+
493
+
494
+ def _path(given: object, what: str) -> Path:
495
+ """``given`` (``~`` expanded) as an absolute path; ``ValueError`` otherwise."""
496
+ if not isinstance(given, str) or not given.strip():
497
+ raise ValueError(f"name the {what}'s path")
498
+ path = Path(given.strip()).expanduser()
499
+ if not path.is_absolute():
500
+ raise ValueError(f"{given}: give the {what}'s full path")
501
+ return path
502
+
503
+
504
+ class Server(http.server.ThreadingHTTPServer):
505
+ """The page's server and engine, on its own thread. :attr:`url` opens it."""
506
+
507
+ daemon_threads = True
508
+
509
+ def __init__(self, page: Path, engine: engines.Here | engines.Spawned | None = None):
510
+ super().__init__(("127.0.0.1", 0), _Handler)
511
+ self.engine = engine or engines.Here() # where builds, saves, plans and pictures run
512
+ self.page, self.token = page.resolve(), secrets.token_urlsafe(32)
513
+ self.host, self.cookie = f"127.0.0.1:{self.server_port}", f"azurkit-token-{self.server_port}"
514
+ self.url = f"http://{self.host}/?token={self.token}"
515
+ self.window = None # the pywebview window, once there is one: its file dialogs
516
+ self.started = time.monotonic() # host.json's age: how long this launch has run (the page's bar, D980)
517
+ self.busy = threading.Lock() # one build or save at a time
518
+ self.writing = threading.Lock() # held by a save, which quit() waits on (never a build)
519
+ self.out: Path | None = None # where the save in flight writes
520
+ self.calls: dict[object, tuple[str, dict]] = {} # the calls in flight: their kind and last record
521
+ self.following = threading.Lock() # self.calls
522
+ self.cancelled = threading.Event()
523
+ self.quitting = False # set by quit(), before it cancels: no build or save starts after it
524
+ self.deadline: float | None = None # quit()'s first call's, plus QUIT_WAIT
525
+ self.chosen: tuple[str, Path] | None = None # the save dialog's last answer and its kind (it asked first)
526
+ self.seeded: dict | None = None # the last randomized build's plan, whose spoiler api/spoiler gives
527
+ self.spoiler: str | None = None # and that spoiler, once the engine made it
528
+ self.source: Path | None = None # and the image it was built from, which it never replaces (D36)
529
+ self.tracking = threading.Lock() # the tracker the page's asks and Live's feed share
530
+ self.feed: live.Feed | None = None # Live, while it is on
531
+ self.drawing = pictures.Drawing() # the tracker's maps and icons, shipped with the page
532
+ self.picks: dict[str, Path] = {} # the files picked this session to replace the image's, by its path
533
+ self.slots = ahead.Ahead(kill=self._quiet) # the work ahead, each click's way to it (D1007)
534
+ if warm.intent((kept := _kept()).get("setup"), kept.get("tracker")):
535
+ threading.Thread(target=_logic_ahead, name="azurkit-logic", daemon=True).start()
536
+ threading.Thread(target=self.serve_forever, name="azurkit-page", daemon=True).start()
537
+
538
+ def quit(self) -> None:
539
+ """Cancel a build or a save in flight, within :data:`QUIT_WAIT`
540
+ (AZ-823): the window's closing hook (its close button, and Cmd-Q,
541
+ after which no ``finally`` runs) and :meth:`close`. A build writes
542
+ nothing, so this never waits on one: its answer is refused (the
543
+ app's engine process, which ends here, ends it). A save
544
+ is waited on until it has removed its part file (D35), as its thread
545
+ is a daemon, which the interpreter's exit would kill mid-write; if
546
+ it hasn't by then (a stalled disk), its part file is removed here.
547
+ A build or save that takes ``busy`` after ``quitting`` is set
548
+ refuses; a save that took it before sees ``cancelled``, set after
549
+ ``quitting``, before its part file is made and after each chunk."""
550
+ self.quitting = True
551
+ self.cancelled.set()
552
+ self.slots.close() # every job ahead stops, and a click on one is let go (D1012)
553
+ if (feed := self.feed) is not None:
554
+ feed.stop()
555
+ self.deadline = self.deadline or time.monotonic() + QUIT_WAIT
556
+ if saved := self.writing.acquire(timeout=max(0.0, self.deadline - time.monotonic())):
557
+ self.writing.release()
558
+ self.engine.close() # the app's engine process ends here, before a stalled save's part file goes
559
+ if not saved and (out := self.out) is not None:
560
+ _unpart(out)
561
+
562
+ def close(self) -> None:
563
+ self.quit()
564
+ self.shutdown()
565
+ self.server_close()
566
+
567
+ def cancel(self) -> dict:
568
+ """``api/cancel``: a save stops at its next chunk; a build answers
569
+ Cancelled at once, and its work runs on as work ahead through a
570
+ seed's fill, for the next Build of these settings (:mod:`.ahead`),
571
+ then stops at its next record."""
572
+ self.cancelled.set()
573
+ self.slots.cancel("build")
574
+ return {}
575
+
576
+ def _quiet(self, job: ahead.Job) -> None:
577
+ """``job``, stopped or cancelled, reached no record within
578
+ ``ahead.GRACE``: the engine's process ends and another takes its
579
+ place, but for a view (the server's own) and beside a save, which
580
+ that would write again from nothing: that job ends at its next
581
+ record."""
582
+ with self.following:
583
+ saving = any(kind == "save" for kind, _ in self.calls.values())
584
+ if job.kind != "view" and not saving:
585
+ self.engine.cancel()
586
+
587
+ @contextlib.contextmanager
588
+ def follow(self, kind: str, first: dict = IDLE):
589
+ """A call of ``kind`` in flight for ``api/progress``, from its record
590
+ ``first``: ``report(record)`` gives its next, until it ends."""
591
+ ident = object()
592
+ with self.following:
593
+ self.calls[ident] = (kind, first)
594
+
595
+ def report(step: dict) -> None:
596
+ with self.following:
597
+ if ident in self.calls:
598
+ self.calls[ident] = (kind, step)
599
+ try:
600
+ yield report
601
+ finally:
602
+ with self.following:
603
+ self.calls.pop(ident, None)
604
+
605
+ def progress(self) -> dict:
606
+ """``api/progress``: the newest call's record, and each kind's (``calls``)."""
607
+ with self.following:
608
+ calls = list(self.calls.values())
609
+ return {**(calls[-1][1] if calls else IDLE), "calls": dict(calls)}
610
+
611
+ def host_info(self) -> dict:
612
+ kept = _kept()
613
+ return {"engine": "app", "version": __version__, "commit": commit(), "catalog": sdk.catalog(), "iso": _start_image(kept),
614
+ "dialogs": self.window is not None, "site": SITE, "theme": _theme(kept),
615
+ "setup": kept.get("setup"), "tracker": kept.get("tracker"),
616
+ "content_source": kept.get("content_source"), "show_objects": kept.get("show_objects") is True,
617
+ "age": round(time.monotonic() - self.started, 3)}
618
+
619
+ def _recipe(self, body: dict) -> dict:
620
+ recipe = {"iso": str(_path(body.get("iso"), "image")), "packs": body.get("packs", {})}
621
+ return self._held(recipe | {key: body[key] for key in ("xbr", "randomizer", "character", "community", "files")
622
+ if body.get(key)})
623
+
624
+ def _held(self, recipe: object) -> object:
625
+ """``recipe`` with the files its ``files`` names that the player
626
+ picked this session (``api/replace``), by the server's own record,
627
+ and never a ``picked`` of its own, with ``files`` or without: no
628
+ recipe, and no page, names a computer path it reads (SEC-11)."""
629
+ if not isinstance(recipe, dict):
630
+ return recipe
631
+ held = {k: v for k, v in recipe.items() if k != "picked"}
632
+ if not isinstance(recipe.get("files"), list):
633
+ return held
634
+ named = [e["path"] for e in recipe["files"] if isinstance(e, dict) and isinstance(e.get("path"), str)]
635
+ return held | {"picked": {path: str(self.picks[path]) for path in named if path in self.picks}}
636
+
637
+ def saves(self, body: dict) -> dict:
638
+ """``api/saves`` (``sdk.saves``): the computer paths it reads through
639
+ ``edits.files.source`` (SEC-11), the BIOS files read for this ask
640
+ alone and refused by size before they are read; a slot saved to
641
+ ``out``, a new folder (:meth:`writable`), else under its id in the
642
+ Downloads folder; no zip: the site's."""
643
+ from azurkit.tools.saves import session
644
+
645
+ given = dict(body.get("given") or {})
646
+ if given.get("do") == "zip" or not {"flash", "mcpx"}.isdisjoint(given):
647
+ raise ValueError("saves: the app reads your files by path, and saves a folder")
648
+ for key in ("folder", "image"):
649
+ if key in given:
650
+ given[key] = str(_path(replaced.source(str(given[key])), "save"))
651
+ if given.get("do") == "sign":
652
+ given["iso"] = str(_path(given.get("iso"), "image"))
653
+ for key in ("flash", "mcpx"):
654
+ if not (where := given.pop(f"{key}_file", None)):
655
+ continue
656
+ file = _path(replaced.source(str(where)), key)
657
+ if file.stat().st_size > session.MAX_BIOS:
658
+ raise ValueError(f"saves: {file.name} is no BIOS file: past the {session.MAX_BIOS:,} B AzurKit reads as one")
659
+ given[key] = file.read_bytes()
660
+ if given.get("do") == "save":
661
+ name = str(given.get("id"))
662
+ given["out"] = str(self.writable(_path(given["out"], "slot's folder") if given.get("out")
663
+ else _downloads() / name, "saves"))
664
+ return sdk.saves(given)
665
+
666
+ def replace(self, body: dict) -> dict:
667
+ """``api/replace``: ``{"path", "file"}``, a computer file the player
668
+ picked to stand in for the image's ``path``: its entry
669
+ (``sdk.file_entry``), and the file held for this session's builds. A
670
+ network share or device path is refused before anything is opened
671
+ (``edits.files.source``)."""
672
+ path, file = body.get("path"), replaced.source(str(body.get("file") or ""))
673
+ given = _path(file, "replacement")
674
+ sdk.file_entry({"path": path, "sha256": "0" * 64, "size": given.stat().st_size}) # refused before it is read
675
+ with self.follow("replace", record("check", f"Reading {given.name}")), open(given, "rb") as f:
676
+ sha = hashlib.file_digest(f, "sha256").hexdigest()
677
+ entry = sdk.file_entry({"path": path, "sha256": sha, "size": f.tell()})
678
+ self.picks[path] = given
679
+ return entry
680
+
681
+ def _image(self, recipe: dict) -> Callable[[ahead.Job], dict]:
682
+ """A build of ``recipe`` in the engine, as a slot's job, its peak told to the slots (``ahead.room``)."""
683
+ def run(job: ahead.Job) -> dict:
684
+ built = self.engine.call("image", recipe, progress=job.progress, fore=job.fore)
685
+ self.slots.peaked(job.key, built["peak"])
686
+ return built
687
+ return run
688
+
689
+ def _planned(self, recipe: object) -> Callable[[ahead.Job], dict]:
690
+ """A seed's plan (``{seed, share}``, as Track asks it) in the engine, as a slot's job; meanwhile, here, what
691
+ its first tracker view compiles (:meth:`_view_ahead`)."""
692
+ def run(job: ahead.Job) -> dict:
693
+ threading.Thread(target=self._view_ahead, args=(recipe, job), name="azurkit-view-ahead",
694
+ daemon=True).start()
695
+ return self.engine.call("plan", recipe, progress=job.progress, fore=job.fore)
696
+ return run
697
+
698
+ def _view_ahead(self, recipe: object, job: ahead.Job) -> None:
699
+ """``randomizer.tracker_ahead`` of the plan ``job`` works out in the engine, on a thread of this process
700
+ (whose Logic the view reads) as low as the job runs (D1007), each compile waiting while a call other than
701
+ that plan's is in flight here or a build runs ahead (its answers pass this process's GIL, which a low thread
702
+ holds while the busy computer runs others: a Build then waits on it), until the job ends or stops (the view,
703
+ once the plan is in, compiles what is left)."""
704
+ low, over = ahead.Low(), lambda: job.done.is_set() or job.state == "stopped"
705
+
706
+ def stop() -> bool:
707
+ while not over() and (self._others("plan") or (built := self.slots.job("build")) is not None
708
+ and not built.done.is_set()):
709
+ time.sleep(0.05)
710
+ low(job.state != "taken")
711
+ return over()
712
+ low(job.state != "taken")
713
+ with contextlib.suppress(Exception): # a head start only: the view works it out
714
+ randomizer.tracker_ahead(recipe, stop)
715
+
716
+ def _others(self, kind: str) -> bool:
717
+ """Whether a call of another kind than ``kind`` is in flight (:meth:`follow`)."""
718
+ with self.following:
719
+ return any(other != kind for other, _ in self.calls.values())
720
+
721
+ def speculate(self, body: dict) -> dict:
722
+ """``api/speculate``: build the page's settled recipe ahead at its
723
+ change ``gen`` (D1007), so its Build takes that build; a seed's fill
724
+ is the plan's ahead, which the build waits on. No image yet, a save
725
+ or a dress in flight (the page offers again once it ends): nothing
726
+ ahead, and what was stops."""
727
+ gen = body.get("gen")
728
+ if not isinstance(gen, int) or self.quitting:
729
+ return {"ahead": False}
730
+ try:
731
+ recipe = self._recipe(body)
732
+ except ValueError:
733
+ recipe = None
734
+ with self.following:
735
+ dressing = any(kind in ("save", "preview", "match") for kind, _ in self.calls.values())
736
+ key = None if recipe is None or dressing else ahead.key(recipe)
737
+ return {"ahead": self.slots.offer("build", gen, key, self._image(recipe))}
738
+
739
+ def prefetch(self, body: dict) -> dict:
740
+ """``api/prefetch``: plan the page's typed seed ahead at its change
741
+ ``gen`` (``recipe``: ``{seed, share}``, as Track asks it; null:
742
+ nothing ahead), then that plan's first tracker view (``view``: the
743
+ tracker's ``spoiler`` and ``gems``, as its first ask sends them; none
744
+ while Live reads the tracker), so Track and its first view take them."""
745
+ gen, recipe, view = body.get("gen"), body.get("recipe"), body.get("view")
746
+ if not isinstance(gen, int) or self.quitting:
747
+ return {"ahead": False}
748
+ view = {k: view[k] for k in ("spoiler", "gems") if k in view} if isinstance(view, dict) else {}
749
+
750
+ def then(job: ahead.Job) -> None:
751
+ given = {"plan": job.value, "marks": {}, **view}
752
+ # a shaped plan's Spoiler's openers are the engine's work ahead already, which only a click may ask for
753
+ if self.feed is None and not (view.get("spoiler") and job.value["settings"].get("seed_difficulty")):
754
+ self.slots.offer("view", job.gen, ahead.key(given), lambda j: sdk.tracker(given, progress=j.progress))
755
+ key = ahead.key(recipe) if isinstance(recipe, dict) and recipe.get("seed") else None
756
+ return {"ahead": self.slots.offer("plan", gen, key, self._planned(recipe), then=then)}
757
+
758
+ def build(self, body: dict) -> dict:
759
+ recipe = self._recipe(body)
760
+ with self.busy, self.follow("build") as report:
761
+ if self.quitting:
762
+ raise ValueError(QUITTING)
763
+ try: # the job of this recipe, if there is one (its records from its last)
764
+ built = self.slots.take("build", ahead.key(recipe), self._image(recipe), report=report)
765
+ except Cancelled:
766
+ raise ValueError(QUITTING if self.quitting else "Cancelled: nothing was built") from None
767
+ except engines.Gone as exc:
768
+ raise ValueError(QUITTING if self.quitting else str(exc)) from None
769
+ if self.quitting: # closed while it built (AZ-823)
770
+ raise ValueError(QUITTING)
771
+ report = built["report"]
772
+ self.seeded, self.spoiler, self.source = report.get("plan"), None, Path(recipe["iso"])
773
+ _remember(iso=recipe["iso"])
774
+ from platformdirs import user_downloads_path
775
+ iso = Path(recipe["iso"])
776
+ stem = iso.stem if iso.suffix.lower() in (".iso", ".xiso") else iso.name # the site's download's name
777
+ recipe = {k: v for k, v in recipe.items() if k != "picked"} # the server holds it (_held)
778
+ return {"report": report, "size": built["size"], "recipe": recipe, "build": built["build"],
779
+ "out": str(user_downloads_path() / f"{stem} (AzurKit).iso")}
780
+
781
+ def writable(self, out: Path, kind: str) -> Path:
782
+ """``out``, where a save of ``kind`` (one of :data:`SAVES`: ``out``,
783
+ the image) may write (SEC-16): never over an existing file but the
784
+ one the save dialog chose for that kind (it asked first), nor the
785
+ image the last build read or a vanilla image (D36)."""
786
+ if out.exists() and self.chosen != (kind, out):
787
+ raise ValueError(f"{out} exists: choose another name, or delete it first")
788
+ if out.exists() and any(image and image.exists() and out.samefile(image)
789
+ for image in (self.source, *map(gate.find, gate.RELEASES))):
790
+ raise ValueError(f"{out}: it is a vanilla image, which AzurKit never writes over")
791
+ return out
792
+
793
+ def save(self, body: dict) -> dict:
794
+ recipe, out = self._held(body.get("recipe")), self.writable(_path(body.get("out"), "saved image"), "out")
795
+
796
+ with self.busy, self.writing, self.follow("save", record("write")) as report:
797
+ def progress(step: dict) -> None:
798
+ report(step)
799
+ if self.cancelled.is_set():
800
+ raise Cancelled
801
+ self.cancelled.clear() # before the check: a quit after it still cancels this save
802
+ if self.quitting:
803
+ raise ValueError(QUITTING)
804
+ self.out = out
805
+ try:
806
+ return {"saved": self.engine.call("save", recipe, str(out), body.get("build"), progress=progress)}
807
+ except Cancelled:
808
+ raise ValueError("Cancelled: nothing was written") from None
809
+ except engines.Gone as exc: # its process ended mid-write: its part file goes here
810
+ _unpart(out)
811
+ raise ValueError("Cancelled: nothing was written" if self.cancelled.is_set()
812
+ else f"{exc}: nothing was written") from None
813
+
814
+ def randomizer(self, body: dict) -> dict:
815
+ """``api/randomizer``: what the Randomizer tab shows; the engine
816
+ builds the randomizer's logic meanwhile, for a seed's build."""
817
+ self.engine.warm("randomizer")
818
+ with self.follow("randomizer", record("plan", "Reading the randomizer's logic")) as report:
819
+ return sdk.randomizer_settings(body.get("given"), progress=report)
820
+
821
+ def restore(self, body: dict) -> dict:
822
+ """``api/restore``: what of the setup still applies; with Randomize
823
+ on, the engine builds the randomizer's logic meanwhile."""
824
+ setup = body.get("setup")
825
+ if isinstance(setup, dict) and isinstance(setup.get("randomizer"), dict) and setup["randomizer"].get("on"):
826
+ self.engine.warm("randomizer")
827
+ with self.follow("restore", record("read", "Restoring your setup")):
828
+ return sdk.restore(setup)
829
+
830
+ def vanilla(self, body: dict) -> dict:
831
+ """``api/vanilla``: the image's own slider values, or an AzurKit
832
+ build's recipe; the engine reads a vanilla image meanwhile, for its
833
+ pictures."""
834
+ with self.follow("vanilla", record("check")):
835
+ read = _vanilla(body)
836
+ if "opened" not in read:
837
+ self.engine.warm("image", str(_path(body.get("iso"), "image")))
838
+ return read
839
+
840
+ def _openers(self, report: Callable[[dict], object]) -> Callable[[dict], list]:
841
+ """A click's tracker's ``openers``: a shaped plan's Spoiler's, from the engine, where its work ahead keeps
842
+ them, and never again in this process (5 s, with no record to end them at); ``report`` hears its count."""
843
+ return lambda plan: self.engine.call("openers", plan, progress=report)
844
+
845
+ def track(self, body: dict) -> dict:
846
+ """``api/tracker``: what the tracker shows (``sdk.tracker``), one ask
847
+ at a time; with Live on for its plan, Live's events since the last
848
+ ask merged into its marks, the reach holding the element levels the
849
+ game holds, and Live's status (``live``). As Modkit's: the marks keep
850
+ the saved slot the game last showed, the next session opens on their
851
+ journal (a lost link, Live off and on), and Reset reads the game
852
+ again."""
853
+ given = body.get("given")
854
+ with self.tracking, self.follow("tracker", record("plan", "Reading the seed's logic")) as report:
855
+ feed = self.feed
856
+ plan = given.get("plan") if isinstance(given, dict) else None
857
+ if isinstance(given, dict) and plan is None: # the maps browsed with no plan (Step 4.4u3): the work
858
+ return sdk.tracker(given, progress=report) # ahead's view of a typed seed goes on
859
+ if feed is None or not isinstance(plan, dict) or plan.get("sha256") != feed.sha256:
860
+ try: # a plan's first view, read ahead (api/prefetch) if it was
861
+ return self.slots.take("view", ahead.key(given), lambda job: sdk.tracker(
862
+ given, progress=job.progress, openers=self._openers(job.progress)), report=report)
863
+ except Cancelled:
864
+ raise ValueError(QUITTING) from None
865
+ if (ahead_view := self.slots.job("view")) is not None: # both read the plan's one tracker
866
+ ahead_view.done.wait()
867
+ events, seen = feed.take()
868
+ slot = seen.pop("slot")
869
+ out = tracking.view({**given, "kit": seen.pop("kit"), "held": seen.pop("held")}, events, report,
870
+ self._openers(report))
871
+ t = tracking.tracker_of(plan)
872
+ if slot is not None and slot != t.slot:
873
+ t.slot = slot
874
+ out["marks"] = t.state()
875
+ if given.get("reset"):
876
+ feed.resync()
877
+ feed.keep(t.journal())
878
+ return out | {"live": seen}
879
+
880
+ def live(self, body: dict) -> dict:
881
+ """``api/live``: Live on for ``plan`` (its marks the journal a
882
+ first settle checks the game against), reading the first of
883
+ ``endpoints`` that answers (empty: the defaults), or off."""
884
+ with self.tracking:
885
+ if self.feed is not None:
886
+ self.feed.stop()
887
+ self.feed = None
888
+ if body.get("on"):
889
+ if self.quitting:
890
+ raise ValueError(QUITTING)
891
+ endpoints = body.get("endpoints") or ""
892
+ if not isinstance(endpoints, str):
893
+ raise ValueError("live: endpoints is a list's text, as Endpoints… takes it")
894
+ try:
895
+ link.parse(endpoints)
896
+ except ValueError as exc:
897
+ raise ValueError(f"Endpoints: {exc}") from None
898
+ self.feed = live.Feed(body.get("plan"), body.get("marks"), endpoints, bool(body.get("gems"))).start()
899
+ return {}
900
+
901
+ def dressed(self, job: str, body: dict) -> dict:
902
+ """``api/preview`` and ``api/match``: the engine's, followed by its line (a variant's dress takes minutes); the
903
+ build ahead stops at its next record, as no dress runs beside it, and the page offers it again once this ends."""
904
+ recipe = body.get("recipe")
905
+ character = recipe.get("character") if isinstance(recipe, dict) else None
906
+ species = character.get("species") if isinstance(character, dict) else None
907
+ what = "draw it" if job == "preview" else "match garret4"
908
+ first = record("dress", f"Dressing {species} to {what}" if isinstance(species, str) else None)
909
+ with self.follow(job, first) as report, self.slots.turn(): # the swap's items counted on its line
910
+ self.slots.offer("build", self.slots.gen, None, None) # a dress beside its GiBs passes 6 (D1017): it stops
911
+ return self.engine.call(job, recipe, progress=lambda step: report(step | {"label": first["label"]}))
912
+
913
+ def plan(self, body: dict) -> dict:
914
+ """``api/plan``: a seed's plan to track (the engine's; planned
915
+ ahead, ``api/prefetch``, if its seed was)."""
916
+ recipe = body.get("recipe")
917
+ with self.follow("plan", record("plan")) as report:
918
+ try:
919
+ return {"plan": self.slots.take("plan", ahead.key(recipe), self._planned(recipe), report=report)}
920
+ except Cancelled:
921
+ raise ValueError(QUITTING) from None
922
+
923
+ def install(self, body: dict) -> dict:
924
+ """``api/install``: ``sdk.content_install``; then work ahead of an
925
+ older change than the page's ``gen`` stops (it read the content
926
+ before)."""
927
+ try:
928
+ return sdk.content_install({"id": body.get("id"), "source": body.get("source")})
929
+ finally:
930
+ if isinstance(gen := body.get("gen"), int):
931
+ self.slots.change(gen)
932
+
933
+ def art(self, body: dict) -> dict:
934
+ """``api/art``: the tracker's shipped pictures
935
+ (:meth:`.art.Drawing.status`)."""
936
+ return self.drawing.status()
937
+
938
+ def spoiled(self, body: dict) -> dict:
939
+ """``api/spoiler``: the last randomized build's spoiler (``{"text"}``,
940
+ made by the engine from the plan it kept, once), or with ``out`` that
941
+ spoiler written there, never over an existing file but the one the
942
+ save dialog chose, nor a vanilla image or the image it was built from
943
+ (D36); whole or not at all."""
944
+ if (plan := self.seeded) is None:
945
+ raise ValueError("No spoiler: build with Randomize ticked first")
946
+ out = self.writable(_path(body["out"], "spoiler"), "spoiler") if body.get("out") else None
947
+ if (text := self.spoiler) is None:
948
+ with self.follow("spoiler", record("spoiler")) as report:
949
+ text = self.engine.call("spoiler", plan, progress=report)["text"]
950
+ if self.seeded is plan: # no build since
951
+ self.spoiler = text
952
+ if out is None:
953
+ return {"text": text}
954
+ data = text.encode("utf-8")
955
+ part = out.with_name(f"{out.name}.part")
956
+ part.write_bytes(data)
957
+ part.replace(out)
958
+ return {"spoiler": {"out": str(out), "size": len(data)}}
959
+
960
+ def settings_file(self, body: dict) -> dict:
961
+ """``api/settings_file``: the Randomizer tab's ``settings`` (a
962
+ settings table, checked as a build checks it) written as a settings
963
+ file (Modkit's ``--save-settings`` file) to ``out``, never over an
964
+ existing file but the one the save dialog chose; whole or not at all."""
965
+ from platformdirs import user_downloads_path
966
+ out = self.writable(_path(body["out"], "settings file") if body.get("out")
967
+ else user_downloads_path() / "settings.toml", "settings")
968
+ data = randomizer.settings_file(body.get("settings")).encode("utf-8")
969
+ part = out.with_name(f"{out.name}.part")
970
+ part.write_bytes(data)
971
+ part.replace(out)
972
+ return {"saved": {"out": str(out), "size": len(data)}}
973
+
974
+ def info(self, body: dict) -> dict:
975
+ """``api/info``: what the image is (``sdk.image_info``)."""
976
+ with self.follow("info", record("check", "Reading your image")):
977
+ return sdk.image_info(str(_path(body.get("iso"), "image")))
978
+
979
+ def verify_all(self, body: dict) -> dict:
980
+ """``api/verify_all``: every file of the image checked, its bytes counted (``sdk.verify_all``)."""
981
+ with self.follow("verify_all", record("check", sdk.CHECK_ALL)) as report:
982
+ return sdk.verify_all(str(_path(body.get("iso"), "image")), progress=report)
983
+
984
+ def extract(self, body: dict) -> dict:
985
+ """``api/extract``: the image's file ``path`` written to ``out``,
986
+ else under its name in the Downloads folder (``sdk.extract``)."""
987
+ iso, member = _path(body.get("iso"), "image"), body.get("path")
988
+ if not (isinstance(member, str) and member.strip("/")):
989
+ raise ValueError("extract: name the image's file")
990
+ out = self.writable(_path(body["out"], "extracted file") if body.get("out")
991
+ else _downloads() / member.rstrip("/").rpartition("/")[2], "extract")
992
+ with self.follow("extract", record("write", f"Extracting {member}")) as report:
993
+ return sdk.extract(str(iso), member, str(out), progress=report)
994
+
995
+ def mods_file(self, body: dict) -> dict:
996
+ """``api/mods_file``: ``{"text"}`` read (``sdk.mods_file``'s preset);
997
+ with ``out``, its ``recipe`` or ``preset`` written there as a mods
998
+ file, else in the Downloads folder (:data:`MODS`)."""
999
+ given = {key: body[key] for key in ("text", "recipe", "preset") if key in body}
1000
+ if "out" not in body:
1001
+ return sdk.mods_file(given)
1002
+ text = sdk.mods_file(given)["text"]
1003
+ out = self.writable(_path(body["out"], "mods file") if body["out"] else _downloads() / MODS, "mods")
1004
+ return _written(out, text.encode("utf-8"))
1005
+
1006
+ def report(self, body: dict) -> dict:
1007
+ """``api/report``: a text the Tools tab shows (the last build's
1008
+ report, a plan's spoiler) written to ``out``, else under ``name`` in
1009
+ the Downloads folder; whole or not at all."""
1010
+ text, name = body.get("text"), body.get("name") or "AzurKit build report.txt"
1011
+ if not isinstance(text, str) or len(text) > MAX_BODY:
1012
+ raise ValueError("report: text is the report's text")
1013
+ if not (isinstance(name, str) and name not in (".", "..") and "/" not in name and "\\" not in name):
1014
+ raise ValueError("report: name is a file's name")
1015
+ out = self.writable(_path(body["out"], "report") if body.get("out") else _downloads() / name, "report")
1016
+ return _written(out, text.encode("utf-8"))
1017
+
1018
+ def storage(self, body: dict) -> dict:
1019
+ """``api/storage`` (:func:`_storage`), which the Tools tab asks as it
1020
+ opens: the engine builds the randomizer's logic meanwhile, for a plan
1021
+ file's spoiler."""
1022
+ if not body.get("clear"):
1023
+ self.engine.warm("randomizer")
1024
+ return _storage(body)
1025
+
1026
+ def plan_spoiler(self, body: dict) -> dict:
1027
+ """``api/plan_spoiler``: a plan file's spoiler (the engine's), its
1028
+ bar counting the randomizer's logic's checks while the engine still
1029
+ reads it (the tab's opening started it), then the spoiler's stage."""
1030
+ with self.follow("plan_spoiler", record("plan", "Reading the randomizer's logic")) as report:
1031
+ return self.engine.call("plan_spoiler", body.get("plan"), progress=report)
1032
+
1033
+ def choose(self, body: dict) -> dict:
1034
+ """The window's file dialog: an image to read (``iso``, an AzurKit
1035
+ ``build``), or where to save one (``out``) or another of
1036
+ :data:`SAVES` (the spoiler, the settings, an extracted file, a mods
1037
+ file, a report)."""
1038
+ import webview
1039
+ self.chosen = None # each dialog's answer is its own: a cancelled one vouches for nothing
1040
+ given = Path(body["path"]).expanduser() if isinstance(body.get("path"), str) and body["path"] else None
1041
+ folder = str(given.parent) if given else ""
1042
+ if body.get("kind") in SAVES:
1043
+ picked = self.window.create_file_dialog(webview.FileDialog.SAVE, directory=folder,
1044
+ save_filename=given.name if given else "")
1045
+ else:
1046
+ picked = self.window.create_file_dialog(
1047
+ webview.FileDialog.FOLDER if body.get("kind") == "folder" else webview.FileDialog.OPEN, directory=folder,
1048
+ **({} if body.get("kind") in ("replacement", "folder") else {"file_types": IMAGES}))
1049
+ if isinstance(picked, (list, tuple)):
1050
+ picked = picked[0] if picked else None
1051
+ if picked and body.get("kind") in SAVES:
1052
+ self.chosen = (body["kind"], Path(str(picked)))
1053
+ return {"path": str(picked) if picked else None}
1054
+
1055
+
1056
+ class _Handler(http.server.BaseHTTPRequestHandler):
1057
+ server: Server
1058
+
1059
+ def _url(self) -> urllib.parse.SplitResult | None:
1060
+ """The request's URL when it carries this launch's token and names
1061
+ this server as its host, else ``None``."""
1062
+ url = urllib.parse.urlsplit(self.path)
1063
+ given = urllib.parse.parse_qs(url.query).get("token", [""])[0]
1064
+ cookie = next((v for k, _, v in (c.strip().partition("=") for c in self.headers.get("Cookie", "").split(";"))
1065
+ if k == self.server.cookie), "")
1066
+ token = self.server.token.encode()
1067
+ if self.headers.get("Host") != self.server.host or not (
1068
+ hmac.compare_digest(given.encode(), token) or hmac.compare_digest(cookie.encode(), token)):
1069
+ return None
1070
+ return url
1071
+
1072
+ def _send(self, data: bytes, kind: str, cookie: bool = False) -> None:
1073
+ self.send_response(200)
1074
+ self.send_header("Content-Type", kind)
1075
+ self.send_header("Content-Length", str(len(data)))
1076
+ self.send_header("Cache-Control", "no-store")
1077
+ if cookie:
1078
+ self.send_header("Set-Cookie",
1079
+ f"{self.server.cookie}={self.server.token}; Path=/; HttpOnly; SameSite=Strict")
1080
+ self.end_headers()
1081
+ self.wfile.write(data)
1082
+
1083
+ def _json(self, value: dict) -> None:
1084
+ self._send(json.dumps(value).encode(), "application/json")
1085
+
1086
+ def do_GET(self) -> None:
1087
+ if (url := self._url()) is None:
1088
+ return self.send_error(403)
1089
+ if url.path == "/host.json":
1090
+ return self._json(self.server.host_info())
1091
+ if url.path == "/theme.js":
1092
+ return self._send(_theme_js(), "text/javascript")
1093
+ if url.path == "/api/progress":
1094
+ return self._json(self.server.progress())
1095
+ if url.path.startswith("/art/") and url.path != "/art/icons.css": # the tracker's pictures (that's a page file)
1096
+ version, _, rel = urllib.parse.unquote(url.path).removeprefix("/art/").partition("/")
1097
+ root = self.server.drawing.where
1098
+ file = (root / rel).resolve()
1099
+ ok = version == tracking.ART_VERSION and file.suffix in (".png", ".json") and file.is_relative_to(
1100
+ root.resolve()) and file.is_file()
1101
+ return self._send(file.read_bytes(), content_type(file.name)) if ok else self.send_error(404)
1102
+ try:
1103
+ file = (self.server.page / (urllib.parse.unquote(url.path).lstrip("/") or "index.html")).resolve()
1104
+ data = file.read_bytes() if file.is_relative_to(self.server.page) and file.is_file() else None
1105
+ except (OSError, ValueError): # a NUL in the path, say
1106
+ data = None
1107
+ if data is None:
1108
+ return self.send_error(404)
1109
+ self._send(data, content_type(file.name),
1110
+ cookie="token" in urllib.parse.parse_qs(url.query))
1111
+
1112
+ def do_POST(self) -> None:
1113
+ if (url := self._url()) is None or self.headers.get("Origin", f"http://{self.server.host}") != (
1114
+ f"http://{self.server.host}") or self.headers.get_content_type() != "application/json":
1115
+ return self.send_error(403)
1116
+ call = {"/api/build": self.server.build, "/api/save": self.server.save, "/api/vanilla": self.server.vanilla,
1117
+ "/api/preset": lambda body: {"fragment": sdk.preset_encode(body.get("recipe"), body.get("report"))},
1118
+ "/api/decode": lambda body: {"preset": sdk.preset_decode(body.get("text"))},
1119
+ "/api/theme": _set_theme, "/api/setup": _set_setup,
1120
+ "/api/restore": self.server.restore,
1121
+ "/api/randomizer": self.server.randomizer,
1122
+ "/api/tracker": self.server.track,
1123
+ "/api/plan": self.server.plan,
1124
+ "/api/tracked": _set_tracked, "/api/live": self.server.live, "/api/art": self.server.art,
1125
+ "/api/settings_file": self.server.settings_file,
1126
+ "/api/pickers": lambda body: sdk.pickers(body.get("given")),
1127
+ "/api/preview": lambda body: self.server.dressed("preview", body),
1128
+ "/api/match": lambda body: self.server.dressed("match", body),
1129
+ "/api/content": lambda body: sdk.content({"source": body.get("source")}),
1130
+ "/api/community": lambda body: sdk.community(), "/api/replace": self.server.replace, "/api/saves": self.server.saves,
1131
+ "/api/install": self.server.install,
1132
+ "/api/speculate": self.server.speculate, "/api/prefetch": self.server.prefetch,
1133
+ "/api/content_source": _set_source, "/api/show_objects": _set_objects,
1134
+ "/api/spoiler": self.server.spoiled,
1135
+ "/api/data": lambda body: sdk.data(body.get("given")),
1136
+ "/api/data_edit": lambda body: sdk.data_edit(body.get("given")),
1137
+ "/api/info": self.server.info, "/api/verify_all": self.server.verify_all,
1138
+ "/api/files": lambda body: sdk.files(str(_path(body.get("iso"), "image"))),
1139
+ "/api/extract": self.server.extract,
1140
+ "/api/open": lambda body: {"preset": sdk.open_build(str(_path(body.get("iso"), "build")), whole=False)},
1141
+ "/api/mods_file": self.server.mods_file, "/api/report": self.server.report,
1142
+ "/api/plan_spoiler": self.server.plan_spoiler, "/api/storage": self.server.storage,
1143
+ "/api/cancel": lambda body: self.server.cancel()}.get(url.path)
1144
+ if url.path == "/api/choose" and self.server.window is not None:
1145
+ call = self.server.choose
1146
+ size = int(self.headers.get("Content-Length") or 0)
1147
+ if call is None or not 0 < size <= MAX_BODY:
1148
+ return self.send_error(404 if call is None else 413)
1149
+ try:
1150
+ body = json.loads(self.rfile.read(size))
1151
+ except ValueError:
1152
+ return self.send_error(400)
1153
+ if not isinstance(body, dict):
1154
+ return self.send_error(400)
1155
+ with self._kept() as sent:
1156
+ try:
1157
+ reply = call(body)
1158
+ except (OSError, ValueError, engines.Gone) as exc: # the sdk's refusals name what and why, never game bytes
1159
+ reply = {"refused": str(exc)}
1160
+ if sent:
1161
+ self.wfile.write(json.dumps(reply).encode())
1162
+ else:
1163
+ self._json(reply)
1164
+
1165
+ @contextlib.contextmanager
1166
+ def _kept(self):
1167
+ """While a call runs, its connection never quiet (:data:`KEEP`): past ``KEEP`` s its answer's headers go (200,
1168
+ JSON, no length: the answer ends at the close) and a space each ``KEEP`` s after them. Yields a list that holds
1169
+ ``True`` once they went."""
1170
+ sent, done, lock = [], threading.Event(), threading.Lock()
1171
+
1172
+ def keep() -> None:
1173
+ while not done.wait(KEEP):
1174
+ with lock:
1175
+ if done.is_set():
1176
+ return
1177
+ try:
1178
+ if not sent:
1179
+ self.send_response(200)
1180
+ self.send_header("Content-Type", "application/json")
1181
+ self.send_header("Cache-Control", "no-store")
1182
+ self.end_headers()
1183
+ sent.append(True)
1184
+ self.wfile.write(b" ")
1185
+ except OSError: # the page went: the answer's own write says so
1186
+ return
1187
+ keeper = threading.Thread(target=keep, name="azurkit-keep", daemon=True)
1188
+ keeper.start()
1189
+ try:
1190
+ yield sent
1191
+ finally:
1192
+ with lock:
1193
+ done.set()
1194
+ keeper.join()
1195
+
1196
+ def log_message(self, *args) -> None: # the token is in the first request's line
1197
+ pass
1198
+
1199
+
1200
+ def _vanilla(body: dict) -> dict:
1201
+ """``api/vanilla``: the image's own slider values, or an AzurKit build's recipe."""
1202
+ iso = str(_path(body.get("iso"), "image"))
1203
+ try:
1204
+ return sdk.vanilla(iso)
1205
+ except gate.NotVanillaError as exc:
1206
+ try:
1207
+ return {"opened": sdk.open_build(iso, whole=False)}
1208
+ except (OSError, ValueError):
1209
+ raise exc from None
1210
+
1211
+
1212
+ def _unpart(out: Path) -> None:
1213
+ """Remove a save's part files beside ``out`` (``iso.pack``'s), its writer ended (D35)."""
1214
+ for part in out.parent.glob(f"{glob.escape(out.name)}.*.part"):
1215
+ with contextlib.suppress(OSError): # a disk that won't: nothing more to do here
1216
+ part.unlink(missing_ok=True)
1217
+
1218
+
1219
+ def serve(page: Path = PAGE, engine: engines.Here | engines.Spawned | None = None) -> Server:
1220
+ """Serve ``page`` (a folder holding ``index.html``) on 127.0.0.1 with a
1221
+ fresh token, on a thread, its calls run by ``engine`` (on the request's
1222
+ thread: :class:`.engine.Here`); :meth:`Server.close` stops both."""
1223
+ if not (page / "index.html").is_file():
1224
+ raise FileNotFoundError(f"{page}: no index.html (the page)")
1225
+ return Server(page, engine)
1226
+
1227
+
1228
+ def _window(server: Server) -> bool:
1229
+ """Show the page in a pywebview window until it closes; ``False`` when
1230
+ there is no webview here."""
1231
+ try:
1232
+ import webview
1233
+ from webview.errors import WebViewException
1234
+ except ImportError:
1235
+ return False
1236
+ try:
1237
+ server.window = webview.create_window(url=server.url, **WINDOW, background_color=GROUNDS[_theme()])
1238
+ server.window.events.closing += server.quit
1239
+ webview.start(private_mode=True)
1240
+ except WebViewException:
1241
+ server.window = None
1242
+ return False
1243
+ return True
1244
+
1245
+
1246
+ def _input_kept_from_children() -> None:
1247
+ """On Windows, keep this process's input from the processes it starts: each takes its parent's standard handles,
1248
+ and a read blocked on a pipe holds that pipe's lock, so a child's Python stops at its first look at its own stdin
1249
+ until the read ends. ``serve``'s read would hold the engine there for good: it never answers, and it dies at its
1250
+ start when the app is killed. Its standard input becomes NUL; ``sys.stdin`` still reads the pipe."""
1251
+ if os.name == "nt":
1252
+ import ctypes
1253
+ import msvcrt
1254
+ set_std_handle = ctypes.WinDLL("kernel32").SetStdHandle
1255
+ set_std_handle.argtypes = (ctypes.c_ulong, ctypes.c_void_p)
1256
+ set_std_handle(-10 & 0xFFFFFFFF, msvcrt.get_osfhandle(os.open(os.devnull, os.O_RDONLY))) # STD_INPUT_HANDLE
1257
+
1258
+
1259
+ def main(argv: list[str] | None = None) -> int:
1260
+ """Open the page: in a window, else in the default browser (served until
1261
+ Ctrl-C); exit 1 at once when :data:`NO_WINDOW` is set. ``["serve"]``
1262
+ opens neither: it prints the page's address and serves until its input
1263
+ ends (Ctrl-D) or Ctrl-C."""
1264
+ argv = list(argv or [])
1265
+ if argv not in ([], ["serve"]):
1266
+ print("usage: AzurKit [serve]", file=sys.stderr)
1267
+ return 2
1268
+ if not argv and os.environ.get(NO_WINDOW):
1269
+ print(f"azurkit: no window while {NO_WINDOW} is set (the tests' mode)", file=sys.stderr)
1270
+ return 1
1271
+ if not (PAGE / "index.html").is_file():
1272
+ print(f"azurkit: {PAGE}: no index.html (the page)", file=sys.stderr)
1273
+ return 1
1274
+ swapcache.install() # the images checked and the swap's answers, kept across launches (the engine's too)
1275
+ if argv:
1276
+ _input_kept_from_children() # before the engine's spawn
1277
+ server = serve(engine=engines.Spawned(stores=True))
1278
+ try:
1279
+ if argv:
1280
+ print(server.url, flush=True)
1281
+ sys.stdin.read()
1282
+ elif not _window(server):
1283
+ print(f"AzurKit: the page is at {server.url} (Ctrl-C quits)", flush=True)
1284
+ webbrowser.open(server.url)
1285
+ threading.Event().wait()
1286
+ except KeyboardInterrupt:
1287
+ pass
1288
+ finally:
1289
+ server.close()
1290
+ return 0