ff9mapkit 1.0.0b3__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 (193) hide show
  1. ff9mapkit/__init__.py +18 -0
  2. ff9mapkit/__main__.py +36 -0
  3. ff9mapkit/_animdb.py +2994 -0
  4. ff9mapkit/_animdb_all.py +14125 -0
  5. ff9mapkit/_fieldtable.py +1516 -0
  6. ff9mapkit/_fieldtext.py +845 -0
  7. ff9mapkit/_held_poses.py +44 -0
  8. ff9mapkit/_itemdb.py +65 -0
  9. ff9mapkit/_modeldb.py +725 -0
  10. ff9mapkit/_narrowmap_data.py +10 -0
  11. ff9mapkit/_npcparams.py +634 -0
  12. ff9mapkit/_regen_animdb.py +72 -0
  13. ff9mapkit/_regen_animdb_all.py +66 -0
  14. ff9mapkit/_regen_fieldtable.py +95 -0
  15. ff9mapkit/_regen_fieldtext.py +66 -0
  16. ff9mapkit/_regen_modeldb.py +67 -0
  17. ff9mapkit/_regen_npcparams.py +123 -0
  18. ff9mapkit/_regen_scenedb.py +57 -0
  19. ff9mapkit/_scenedb.py +869 -0
  20. ff9mapkit/abilities.py +225 -0
  21. ff9mapkit/animations.py +120 -0
  22. ff9mapkit/archetypes.py +218 -0
  23. ff9mapkit/areatitle.py +76 -0
  24. ff9mapkit/battle/__init__.py +21 -0
  25. ff9mapkit/battle/abilityfeatures.py +294 -0
  26. ff9mapkit/battle/actiondelta.py +441 -0
  27. ff9mapkit/battle/aiauthor.py +305 -0
  28. ff9mapkit/battle/ailint.py +140 -0
  29. ff9mapkit/battle/aipatch.py +175 -0
  30. ff9mapkit/battle/battleai.py +148 -0
  31. ff9mapkit/battle/battlecsv.py +390 -0
  32. ff9mapkit/battle/battlepatch.py +395 -0
  33. ff9mapkit/battle/build.py +558 -0
  34. ff9mapkit/battle/camera_codec.py +332 -0
  35. ff9mapkit/battle/camera_data.py +128 -0
  36. ff9mapkit/battle/characterdelta.py +789 -0
  37. ff9mapkit/battle/event_data.py +72 -0
  38. ff9mapkit/battle/extract.py +540 -0
  39. ff9mapkit/battle/fbx.py +223 -0
  40. ff9mapkit/battle/reskin.py +149 -0
  41. ff9mapkit/battle/scene_codec.py +314 -0
  42. ff9mapkit/battle/scene_data.py +369 -0
  43. ff9mapkit/battle/scenelint.py +125 -0
  44. ff9mapkit/battle/seqasm.py +131 -0
  45. ff9mapkit/battle/seqauthor.py +220 -0
  46. ff9mapkit/battle/seqcodec.py +300 -0
  47. ff9mapkit/battle/seqdis.py +106 -0
  48. ff9mapkit/battle/seqpatch.py +137 -0
  49. ff9mapkit/battle_bgm.py +133 -0
  50. ff9mapkit/binutils.py +60 -0
  51. ff9mapkit/build.py +5445 -0
  52. ff9mapkit/campaign.py +1276 -0
  53. ff9mapkit/catalog.py +316 -0
  54. ff9mapkit/chain.py +358 -0
  55. ff9mapkit/cli.py +3114 -0
  56. ff9mapkit/config.py +360 -0
  57. ff9mapkit/content/__init__.py +13 -0
  58. ff9mapkit/content/areatitle.py +36 -0
  59. ff9mapkit/content/ate.py +118 -0
  60. ff9mapkit/content/camera.py +123 -0
  61. ff9mapkit/content/chest.py +186 -0
  62. ff9mapkit/content/choice.py +163 -0
  63. ff9mapkit/content/conductor.py +217 -0
  64. ff9mapkit/content/cutscene.py +290 -0
  65. ff9mapkit/content/encounter.py +41 -0
  66. ff9mapkit/content/entry_settle.py +50 -0
  67. ff9mapkit/content/equipment.py +93 -0
  68. ff9mapkit/content/event.py +191 -0
  69. ff9mapkit/content/gateway.py +101 -0
  70. ff9mapkit/content/inventory.py +59 -0
  71. ff9mapkit/content/itemdata.py +644 -0
  72. ff9mapkit/content/itemtext.py +168 -0
  73. ff9mapkit/content/jump.py +114 -0
  74. ff9mapkit/content/ladder.py +633 -0
  75. ff9mapkit/content/movement.py +53 -0
  76. ff9mapkit/content/music.py +97 -0
  77. ff9mapkit/content/npc.py +348 -0
  78. ff9mapkit/content/object.py +340 -0
  79. ff9mapkit/content/onentry.py +135 -0
  80. ff9mapkit/content/party.py +111 -0
  81. ff9mapkit/content/pathfind.py +138 -0
  82. ff9mapkit/content/platform.py +314 -0
  83. ff9mapkit/content/player.py +168 -0
  84. ff9mapkit/content/prop.py +75 -0
  85. ff9mapkit/content/region.py +340 -0
  86. ff9mapkit/content/reinit.py +59 -0
  87. ff9mapkit/content/savepoint.py +90 -0
  88. ff9mapkit/content/shop.py +178 -0
  89. ff9mapkit/content/sps_trigger.py +66 -0
  90. ff9mapkit/content/startup.py +71 -0
  91. ff9mapkit/content/synthesis.py +106 -0
  92. ff9mapkit/content/text.py +183 -0
  93. ff9mapkit/content/textcarry.py +290 -0
  94. ff9mapkit/content/verbatim.py +86 -0
  95. ff9mapkit/content/walkmesh_hotfix.py +38 -0
  96. ff9mapkit/data/__init__.py +48 -0
  97. ff9mapkit/data/_regen_provenance.py +142 -0
  98. ff9mapkit/data/provenance/blank.es.patch +1 -0
  99. ff9mapkit/data/provenance/blank.fr.patch +1 -0
  100. ff9mapkit/data/provenance/blank.gr.patch +1 -0
  101. ff9mapkit/data/provenance/blank.it.patch +1 -0
  102. ff9mapkit/data/provenance/blank.jp.patch +1 -0
  103. ff9mapkit/data/provenance/blank.uk.patch +1 -0
  104. ff9mapkit/data/provenance/blank.us.patch +1 -0
  105. ff9mapkit/data/provenance/manifest.json +65 -0
  106. ff9mapkit/data/provenance/region_template.patch +1 -0
  107. ff9mapkit/data/reference_arcs.toml +89 -0
  108. ff9mapkit/data/region_catalog.toml +593 -0
  109. ff9mapkit/deploystack.py +358 -0
  110. ff9mapkit/dialogue.py +803 -0
  111. ff9mapkit/eb/__init__.py +12 -0
  112. ff9mapkit/eb/_exprtable.py +59 -0
  113. ff9mapkit/eb/_membertable.py +38 -0
  114. ff9mapkit/eb/_optables.py +537 -0
  115. ff9mapkit/eb/_regen_optables.py +76 -0
  116. ff9mapkit/eb/cmdasm.py +323 -0
  117. ff9mapkit/eb/disasm.py +332 -0
  118. ff9mapkit/eb/edit.py +439 -0
  119. ff9mapkit/eb/exprasm.py +158 -0
  120. ff9mapkit/eb/model.py +178 -0
  121. ff9mapkit/eb/opcodes.py +463 -0
  122. ff9mapkit/eblint.py +177 -0
  123. ff9mapkit/editor/__init__.py +20 -0
  124. ff9mapkit/editor/app.py +950 -0
  125. ff9mapkit/editor/battle_forms.py +240 -0
  126. ff9mapkit/editor/breadcrumb.py +89 -0
  127. ff9mapkit/editor/dialogs.py +116 -0
  128. ff9mapkit/editor/feedback.py +208 -0
  129. ff9mapkit/editor/forms.py +632 -0
  130. ff9mapkit/editor/graphview.py +350 -0
  131. ff9mapkit/editor/jobs.py +342 -0
  132. ff9mapkit/editor/model.py +243 -0
  133. ff9mapkit/editor/picker.py +120 -0
  134. ff9mapkit/editor/theme.py +212 -0
  135. ff9mapkit/eventscan.py +1441 -0
  136. ff9mapkit/extract.py +2279 -0
  137. ff9mapkit/flags.py +693 -0
  138. ff9mapkit/forkreport.py +1383 -0
  139. ff9mapkit/hub.py +477 -0
  140. ff9mapkit/idgated.py +101 -0
  141. ff9mapkit/infohub.py +580 -0
  142. ff9mapkit/items.py +63 -0
  143. ff9mapkit/itemstats.py +346 -0
  144. ff9mapkit/journey.py +1902 -0
  145. ff9mapkit/keyitems.py +93 -0
  146. ff9mapkit/logic_add.py +632 -0
  147. ff9mapkit/logic_edit.py +728 -0
  148. ff9mapkit/logic_map.py +526 -0
  149. ff9mapkit/pack.py +175 -0
  150. ff9mapkit/playerswap.py +231 -0
  151. ff9mapkit/prop_archetypes.py +228 -0
  152. ff9mapkit/provision.py +282 -0
  153. ff9mapkit/refarc.py +825 -0
  154. ff9mapkit/save.py +337 -0
  155. ff9mapkit/save_items.py +1673 -0
  156. ff9mapkit/scene/__init__.py +11 -0
  157. ff9mapkit/scene/arena.py +63 -0
  158. ff9mapkit/scene/bgart.py +140 -0
  159. ff9mapkit/scene/bgi.py +732 -0
  160. ff9mapkit/scene/bgs.py +174 -0
  161. ff9mapkit/scene/bgx.py +185 -0
  162. ff9mapkit/scene/cam.py +345 -0
  163. ff9mapkit/scene/guide.py +311 -0
  164. ff9mapkit/scene/paint.py +506 -0
  165. ff9mapkit/scene/placeholder.py +107 -0
  166. ff9mapkit/sjbinary.py +285 -0
  167. ff9mapkit/sps/__init__.py +17 -0
  168. ff9mapkit/sps/author.py +294 -0
  169. ff9mapkit/sps/catalog.py +88 -0
  170. ff9mapkit/sps/codec.py +264 -0
  171. ff9mapkit/sps/edit.py +184 -0
  172. ff9mapkit/sps/lint.py +58 -0
  173. ff9mapkit/sps/render.py +116 -0
  174. ff9mapkit/sps/templates.py +47 -0
  175. ff9mapkit/sps/texture.py +131 -0
  176. ff9mapkit/walkmesh_hotfixes.py +163 -0
  177. ff9mapkit/workspace/__init__.py +18 -0
  178. ff9mapkit/workspace/battledoc.py +985 -0
  179. ff9mapkit/workspace/builddoc.py +607 -0
  180. ff9mapkit/workspace/forms_qt.py +586 -0
  181. ff9mapkit/workspace/importdoc.py +665 -0
  182. ff9mapkit/workspace/mapview.py +131 -0
  183. ff9mapkit/workspace/palette.py +85 -0
  184. ff9mapkit/workspace/savedoc.py +664 -0
  185. ff9mapkit/workspace/shell.py +6907 -0
  186. ff9mapkit/workspace/style.py +105 -0
  187. ff9mapkit/workspace/tuningdialog.py +223 -0
  188. ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
  189. ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
  190. ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
  191. ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
  192. ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
  193. ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,228 @@
1
+ """Named PROP archetypes -- the Info Hub's "place a common set piece with one word".
2
+
3
+ A friendly name (``"chest"``, ``"tent"``, ``"save_book"``) maps to a GEO prop model + its canonical
4
+ resting **pose** -- the ``SetStandAnimation`` id shipping fields settle that model to, harvested by
5
+ ``tools/extract_prop_poses.py``. A prop's true pose usually ISN'T a named model animation (the save
6
+ book rests at clip 1872 = its 'b'+1), so the pose is a curated number here, not a name-join lookup.
7
+
8
+ Use as ``[[prop]] prop = "chest"``. For anything not curated, place it directly with
9
+ ``[[prop]] model = "GEO_ACC_F0_XXX"`` + an optional ``pose`` (an action name or a raw clip id).
10
+
11
+ Provenance-clean: only GEO model IDENTIFIERS + animation IDs (numbers), never game bytes. The set is
12
+ intentionally small + high-confidence and grows as ACC props are identified in-game (a wrong name/pose
13
+ is worse than none).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from . import catalog as _catalog
18
+
19
+ # friendly name -> {model: GEO name, pose: canonical SetStandAnimation id (from extract_prop_poses)}.
20
+ PROP_ARCHETYPES: dict = {
21
+ "chest": {"model": "GEO_ACC_F0_TBX", "pose": 7339}, # TBX -- a closed treasure chest ('close')
22
+ "treasure_chest": {"model": "GEO_ACC_F0_TBX", "pose": 7339}, # alias of chest
23
+ "tent": {"model": "GEO_ACC_F0_TNT", "pose": 7667}, # TNT -- a world-map camping tent ('camp_sleep')
24
+ "save_book": {"model": "GEO_ACC_F0_MGR", "pose": 1872}, # MGR -- the moogle's save book (raw pose 1872)
25
+ "feather": {"model": "GEO_ACC_F0_MGP", "pose": 1874}, # MGP -- the save-point feather / quill (raw 1874)
26
+ # -- identified via the in-game prop gallery (token -> what it is, JP/decode in the note) --
27
+ "balloon": {"model": "GEO_ACC_F0_BLL", "pose": 3349}, # BLL -- "BaLLoon": the moogle save-point marker
28
+ "save_marker": {"model": "GEO_ACC_F0_BLL", "pose": 3349}, # alias of balloon
29
+ "letter": {"model": "GEO_ACC_F0_LTT", "pose": 2479}, # LTT -- a Mognet "LeTTer"
30
+ "cactus": {"model": "GEO_ACC_F0_GAS", "pose": 8186}, # GAS -- a Gargan cactus (JP "GArgantua" + "Saboten" = cactus)
31
+ "save_the_queen": {"model": "GEO_ACC_F0_STQ", "pose": 1894}, # STQ -- "Save The Queen" (Beatrix's sword) as a prop
32
+ "sword": {"model": "GEO_ACC_F0_SWD", "pose": 4470}, # SWD -- a "SWorD" (the theatrical replica from "I Want to Be Your Canary")
33
+ "cask": {"model": "GEO_ACC_F0_CSK", "pose": 1904}, # CSK -- a "CaSK" / barrel (Dali storage, Lindblum alleys)
34
+ "barrel": {"model": "GEO_ACC_F0_CSK", "pose": 1904}, # alias of cask
35
+ "crate": {"model": "GEO_ACC_F0_CSK", "pose": 1904}, # alias of cask -- FF9's storage container is the barrel, so the word "crate" resolves to it
36
+ "great_leaf": {"model": "GEO_ACC_F0_ELE", "pose": 1894}, # ELE -- Cleyra's Great Leaf / the leaf elevator pad (Iifa roots, Cleyra climbs)
37
+ "fish": {"model": "GEO_ACC_F0_FS1", "pose": 10751}, # FS1 -- "FiSh 1", the orange fish kitchen prop (Madain Sari, Memoria)
38
+ "hand_bell": {"model": "GEO_ACC_F0_HDB", "pose": 2471}, # HDB -- "HanD Bell": the small Burmecian hand bell (not Gizamaluke's giant one)
39
+ "lever": {"model": "GEO_ACC_F0_KOM", "pose": 301}, # KOM -- a small switch/toggle lever (JP Komon/Komadori; Fossil Roo track switches)
40
+ "switch_lever": {"model": "GEO_ACC_F0_KOM", "pose": 301}, # alias of lever
41
+ "ladder": {"model": "GEO_ACC_F0_LDD", "pose": 758}, # LDD -- a "LaDDer" (Vivi's, the Alexandria rooftops)
42
+ "book": {"model": "GEO_ACC_F0_OPB", "pose": 1892}, # OPB -- a library book (default closed; "OPen Book" -- opens via animation)
43
+ "pickaxe": {"model": "GEO_ACC_F0_TUR", "pose": 10643}, # TUR -- a mining pickaxe (Fossil Roo mining site)
44
+ "vat": {"model": "GEO_ACC_F0_BBT", "pose": 62}, # BBT -- "Big Barrel Tank": a huge storage vat (Dali underground production)
45
+ "tank": {"model": "GEO_ACC_F0_BBT", "pose": 62}, # alias of vat
46
+ "aircab": {"model": "GEO_ACC_F0_V10", "pose": 1608}, # V10 -- "Vehicle 10": the generic/station Lindblum aircab car (flies, has doors); cf. `cab_carriage` (TRK) = the high-res rideable carriage
47
+ "aircab_car": {"model": "GEO_ACC_F0_V10", "pose": 1608}, # alias of aircab
48
+ "trap": {"model": "GEO_ACC_F0_ISB", "pose": 10689}, # ISB -- likely the Gargan Roo TRACK/RAIL the Gargant (GRG) rides (in-game: GRG connects to ISB paths), not a trap; also Ipsen's/Pinnacle/Earth Shrine. TENTATIVE
49
+ "scale": {"model": "GEO_ACC_F0_TNB", "pose": 12884}, # TNB -- the Desert Palace balance scale (JP "tenbin" 天秤). The four weights are `wood_weight`/`clay_weight`/`stone_weight`/`iron_weight` (WT0-3); flag-gated in the puzzle, but render fine static. The full at-rest set piece (scale + weights) is the `scale_set` composite.
50
+ "balance_scale": {"model": "GEO_ACC_F0_TNB", "pose": 12884}, # alias of scale
51
+ # -- set dressing identified via the prop gallery (token -> what it is) --
52
+ "orange_fish": {"model": "GEO_ACC_F0_FS1", "pose": 10751}, # FS1 -- the orange fish (alias of `fish`); Madain Sari kitchen
53
+ "blue_fish": {"model": "GEO_ACC_F0_FS2", "pose": 10749}, # FS2 -- a blue fish (Madain Sari kitchen, Chocobo's Lagoon)
54
+ "green_fish": {"model": "GEO_ACC_F0_FS3", "pose": 10747}, # FS3 -- a green fish (Madain Sari kitchen)
55
+ "gargant": {"model": "GEO_ACC_F0_GRG", "pose": 1138}, # GRG -- the Gargant, the giant beetle ridden through Gargan Roo; it rides the ISB track, so placed ALONE it has collision/alignment quirks
56
+ "gondola": {"model": "GEO_ACC_F0_V11", "pose": 8004}, # V11 "Vehicle 11" -- the Alexandria lake boat / gondola
57
+ "extraction_ring": {"model": "GEO_ACC_F0_CER", "pose": 10727}, # CER "CERemony" -- the glowing eidolon-extraction ring (the Zorn/Thorn ritual; A. Castle altar, Gulug extraction site)
58
+ "shelf": {"model": "GEO_ACC_F0_BBX", "pose": 6962}, # BBX -- a Dali underground production shelf / box ("Black Mage Box"?); tentative
59
+ "stone_dial": {"model": "GEO_ACC_F0_FEL", "pose": 792}, # FEL -- a stone dial lever (Pandemonium control room / elevators)
60
+ "fishing_rod": {"model": "GEO_ACC_F0_FIS", "pose": 2226}, # FIS -- a fishing rod with a long line (Quan's Dwelling fishing area, Madain Sari kitchen)
61
+ "altar_stone": {"model": "GEO_ACC_F0_HSK", "pose": 13720}, # HSK -- the triangular "Hogo Seki" protective altar stone (保護石 = protective stone/seal; Palace Sanctum, Oeilvert tombstone, Esto Gaza)
62
+ "teleport_pad": {"model": "GEO_ACC_F0_IFE", "pose": 1896}, # IFE -- the Iifa field emblem / teleport pad (Iifa Tree roots)
63
+ "scroll": {"model": "GEO_ACC_F0_MAP", "pose": 1882}, # MAP -- a rolled map scroll (the Prima Vista map tables; Evil Forest exit, Lindblum walls)
64
+ "map": {"model": "GEO_ACC_F0_MAP", "pose": 1882}, # alias of scroll
65
+ "pot": {"model": "GEO_ACC_F0_SUP", "pose": 1896}, # SUP -- Eiko's soup pot (Madain Sari kitchen)
66
+ "soup_pot": {"model": "GEO_ACC_F0_SUP", "pose": 1896}, # alias of pot
67
+ # -- set dressing, batch 3 (some are HUGE structural assets) --
68
+ "cab_carriage": {"model": "GEO_ACC_F0_TRK", "pose": 7380}, # TRK -- the rideable Air Cab CARRIAGE itself (rides the Lindblum Castle transit tracks); cf. `aircab` (V10) = the generic/station car
69
+ "ship_model": {"model": "GEO_ACC_F0_TSM", "pose": 1105}, # TSM -- the Tantalus thieves' miniature toy model of the Cargo Ship (Mountain shack, Lindblum hideout, Ending)
70
+ "skiff": {"model": "GEO_ACC_F0_BOT", "pose": 1890}, # BOT "BOaT" -- the Madain Sari fishing skiff (the Cove)
71
+ "boat": {"model": "GEO_ACC_F0_BOT", "pose": 1890}, # alias of skiff (cf. `gondola` = the Alexandria V11)
72
+ "gear_wall": {"model": "GEO_ACC_F0_CBH", "pose": 3933}, # CBH "Cargo Belt Housing" -- the HUGE Dali subterranean gear/conveyor/lift wall engine (dwarfs the floor placed alone)
73
+ "dagger": {"model": "GEO_ACC_F0_DAG", "pose": 216}, # DAG -- Garnet's royal dagger (her namesake; Ice Cavern, A. Castle tomb)
74
+ "wind_mirror": {"model": "GEO_ACC_F0_HKG", "pose": 7378}, # HKG -- the Wind Shrine mirror / seal medallion slotted into the altar; the Ipsen's Castle mural object (保護鏡源 "protective mirror source")
75
+ "seal_medallion": {"model": "GEO_ACC_F0_HKG", "pose": 7378}, # alias of wind_mirror
76
+ # -- set dressing, batch 4 --
77
+ "weight_lift": {"model": "GEO_ACC_F0_IRF", "pose": 13156}, # IRF "Ipsen's Room Floor" -- the chandelier weight-lift puzzle platform (Zidane's weight hoists the treasure chandelier up)
78
+ "hatchery": {"model": "GEO_ACC_F0_KGG", "pose": 71}, # KGG -- the Dali Black Mage egg incubator / hatchery (孵化器; Production Area)
79
+ "incubator": {"model": "GEO_ACC_F0_KGG", "pose": 71}, # alias of hatchery
80
+ "trapdoor": {"model": "GEO_ACC_F0_KOR", "pose": 297}, # KOR -- a floor altar trapdoor / pit hole (Fossil Roo cavern, Earth Shrine passage)
81
+ "pit": {"model": "GEO_ACC_F0_KOR", "pose": 297}, # alias of trapdoor
82
+ "neptune_statue": {"model": "GEO_ACC_F0_NEP", "pose": 7146}, # NEP -- the Alexandria "Neptune" guardian statue (A. Castle/Neptune)
83
+ "neptune": {"model": "GEO_ACC_F0_NEP", "pose": 7146}, # alias of neptune_statue
84
+ "ribbon": {"model": "GEO_ACC_F0_RBN", "pose": 13725}, # RBN -- a ribbon, the Madain Sari eidolon-wall offering (Secret Room; also Gulug)
85
+ "rope": {"model": "GEO_ACC_F0_ROP", "pose": 964}, # ROP -- a rope: both the children's jump rope (Alexandria Square) and the steeple bell rope
86
+ # -- set dressing, batch 5 --
87
+ "frog_cart": {"model": "GEO_ACC_F0_V02", "pose": 1460}, # V02 "Vehicle 02" -- Regent Cid's motorized frog-cart (Lindblum Theater Ave.)
88
+ "cargo_ship": {"model": "GEO_ACC_F0_BLK", "pose": 7382}, # BLK -- the full-size Dali Black Mage cargo airship (the vessel hijacked through South Gate); cf. `ship_model` (TSM) = the toy model of it
89
+ "cargo_airship": {"model": "GEO_ACC_F0_BLK", "pose": 7382}, # alias of cargo_ship
90
+ # the four Desert Palace balance-scale WEIGHTS (the `scale`/TNB puzzle); render fine static; material mapping TENTATIVE (per user: Wood/Clay/Stone/Iron in WT0-3 order)
91
+ "wood_weight": {"model": "GEO_ACC_F0_WT0", "pose": 12888}, # WT0 -- scale weight (tentative: Wood)
92
+ "clay_weight": {"model": "GEO_ACC_F0_WT1", "pose": 13132}, # WT1 -- scale weight (tentative: Clay)
93
+ "stone_weight": {"model": "GEO_ACC_F0_WT2", "pose": 13128}, # WT2 -- scale weight (tentative: Stone)
94
+ "iron_weight": {"model": "GEO_ACC_F0_WT3", "pose": 13124}, # WT3 -- scale weight (tentative: Iron)
95
+ # -- set dressing, batch 6 (each in a single field -- rare/specific) --
96
+ "bookcase": {"model": "GEO_ACC_F0_BTN", "pose": 3962}, # BTN "Bookcase Trigger Node" -- the Desert Palace secret-library bookcase
97
+ "windmill_crank": {"model": "GEO_ACC_F0_CRS", "pose": 5959}, # CRS -- the Dali windmill brake crank + grain hopper mechanism (Windmill 2F)
98
+ "round_pillar": {"model": "GEO_ACC_F0_DLB", "pose": 13049}, # DLB -- the Daguerreo lift column B, a cylindrical pillar (Right Hall)
99
+ "square_pillar": {"model": "GEO_ACC_F0_DLF", "pose": 7144}, # DLF -- the Daguerreo lift column F, a square pillar (Left Hall)
100
+ "mage_egg": {"model": "GEO_ACC_F0_EGG", "pose": 71}, # EGG -- the unhatched Black Mage pod/egg (the one Vivi finds under Dali; the "Lindblum Residence" field is a warp overlap); cf. `hatchery` (KGG)
101
+ "egg": {"model": "GEO_ACC_F0_EGG", "pose": 71}, # alias of mage_egg
102
+ "elevator": {"model": "GEO_ACC_F0_ELV", "pose": 5346}, # ELV -- the Prima Vista cargo-hold lift platform (theater-ship internal; hauls props/actors between decks)
103
+ "cargo_lift": {"model": "GEO_ACC_F0_ELV", "pose": 5346}, # alias of elevator
104
+ # -- set dressing, batch 7 (GNT + KOS have offset origins -> render as a tiny dot in an empty viewport) --
105
+ "surveillance_eye": {"model": "GEO_ACC_F0_EYE", "pose": 13175}, # EYE -- the Pandemonium surveillance eye (security laser/camera tracking Zidane at the Exit)
106
+ "eye": {"model": "GEO_ACC_F0_EYE", "pose": 13175}, # alias of surveillance_eye
107
+ "floor_tile": {"model": "GEO_ACC_F0_FLR", "pose": 1386}, # FLR -- a Desert Palace dungeon puzzle floor grid tile (the path-lighting puzzle; glow toggles per step)
108
+ "grid_tile": {"model": "GEO_ACC_F0_FLR", "pose": 1386}, # alias of floor_tile
109
+ "goddess_statue": {"model": "GEO_ACC_F0_GNT", "pose": 4747}, # GNT "GiaNT" -- the colossal Summoner Goddess statue (A. Castle Tomb); origin anchored in its base -> renders as a tiny dot on a flat grid
110
+ "giant_statue": {"model": "GEO_ACC_F0_GNT", "pose": 4747}, # alias of goddess_statue
111
+ "mage_robe": {"model": "GEO_ACC_F0_HOD", "pose": 2477}, # HOD "HOoD" -- Garnet's white-mage robe disguise, discarded in the Prima Vista cabins after her escape
112
+ "hood": {"model": "GEO_ACC_F0_HOD", "pose": 2477}, # alias of mage_robe
113
+ "collapsing_floor": {"model": "GEO_ACC_F0_KOS", "pose": 1894}, # KOS "Koseki" -- the Earth Shrine collapsing-floor trap anchor; hidden until triggered -> default mesh collapses to (0,0,0), renders as a dot
114
+ "trap_anchor": {"model": "GEO_ACC_F0_KOS", "pose": 1894}, # alias of collapsing_floor
115
+ "pull_chain": {"model": "GEO_ACC_F0_LEV", "pose": 6962}, # LEV "LEVer" -- the Gargan Roo ceiling pull-chain track switch (redirects the Gargant); cf. `lever` (KOM) = the small Fossil Roo toggle
116
+ "track_switch": {"model": "GEO_ACC_F0_LEV", "pose": 6962}, # alias of pull_chain
117
+ # -- set dressing, batch 8 --
118
+ "planks": {"model": "GEO_ACC_F0_LG2", "pose": 12940}, # LG2 "Log 2" -- the Alexandria rooftop tied planks (manual-labor prop; cf. `log`/`timber` = LG1)
119
+ "roof_planks": {"model": "GEO_ACC_F0_LG2", "pose": 12940}, # alias of planks
120
+ "hologram_projector": {"model": "GEO_ACC_F0_LIF", "pose": 6960}, # LIF "Life" -- the Oeilvert Terran holographic-history projector (narrates Terra's history)
121
+ "projector": {"model": "GEO_ACC_F0_LIF", "pose": 6960}, # alias of hologram_projector
122
+ "campfire": {"model": "GEO_ACC_F0_MAK", "pose": 6963}, # MAK "Maki" 薪 -- the Evil Forest campfire / firewood bundle (the cozy rest before the forest petrifies)
123
+ "firewood": {"model": "GEO_ACC_F0_MAK", "pose": 6963}, # alias of campfire
124
+ "tiki_torch": {"model": "GEO_ACC_F0_TKE", "pose": 4684}, # TKE -- a torch (Daguerreo Left Hall); TENTATIVE ("Tee-Key" ~ tiki torch)
125
+ "torch": {"model": "GEO_ACC_F0_TKE", "pose": 4684}, # alias of tiki_torch
126
+ # -- set dressing, batch 9 (final GEO_ACC set-dressing) --
127
+ "altar": {"model": "GEO_ACC_F0_ORD", "pose": 8002}, # ORD "Ordeal" -- the central altar / Ordeal pedestal of Ipsen's Castle (Sword Room)
128
+ "pedestal": {"model": "GEO_ACC_F0_ORD", "pose": 8002}, # alias of altar
129
+ "parade_float": {"model": "GEO_ACC_F0_V01", "pose": 1888}, # V01 "Vehicle 01" -- a Lindblum theater parade float / street prop cart (holiday + summit set-dressing; L. Castle Event)
130
+ "float": {"model": "GEO_ACC_F0_V01", "pose": 1888}, # alias of parade_float
131
+ "luxury_cab": {"model": "GEO_ACC_F0_V03", "pose": 1507}, # V03 "Vehicle 03" -- Cid's private luxury air-cab (the Hilda Garde prototype shuttle); cf. `aircab` V10, `cab_carriage` TRK
132
+ "cid_shuttle": {"model": "GEO_ACC_F0_V03", "pose": 1507}, # alias of luxury_cab
133
+ "tunnel_beam": {"model": "GEO_ACC_F0_YIB", "pose": 8099}, # YIB "Y-Intersection Beam" -- a Fossil Roo tunnel support timber / Gargant track-switcher rail (aligns with the pull animations)
134
+ "support_beam": {"model": "GEO_ACC_F0_YIB", "pose": 8099}, # alias of tunnel_beam
135
+ "spear": {"model": "GEO_ACC_F0_YRI", "pose": 12739}, # YRI "Yari" 槍 -- the Burmecian Mythril Spear (Freya salvages it from the armory ruins)
136
+ "mythril_spear": {"model": "GEO_ACC_F0_YRI", "pose": 12739}, # alias of spear
137
+ # -- common HELD items (place static via [[prop]], or via [[npc]] holds = "cup" -> auto held pose) --
138
+ "cup": {"model": "GEO_ACC_F0_CUP", "pose": 1894}, # CUP -- a cup / tankard (held by drinkers)
139
+ "glass": {"model": "GEO_ACC_F0_GRS", "pose": 8239}, # GRS -- a drinking glass (bartender / pub)
140
+ "ticket": {"model": "GEO_ACC_F0_TKT", "pose": 10359}, # TKT -- a play / theater ticket
141
+ "bottle": {"model": "GEO_ACC_F0_BON", "pose": 813}, # BON -- a bottle (the Doom Pub); tentative
142
+ # -- held items identified via the held-item gallery (carrier holds it; pose auto-resolves) --
143
+ "log": {"model": "GEO_ACC_F0_LG1", "pose": 4346}, # LG1 "Log 1" -- a timber beam a worker hauls (maybe a ladder shaft)
144
+ "timber": {"model": "GEO_ACC_F0_LG1", "pose": 4346}, # alias of log
145
+ "axe": {"model": "GEO_ACC_F0_LNW", "pose": 1894}, # LNW "LaNi's Weapon" -- Lani's battle axe (rests on her back at idle)
146
+ "sack": {"model": "GEO_ACC_F0_ZBR", "pose": 13160}, # ZBR "ZuBoRa" (ずぼら = sloppy/lazy/casual) -- a loosely-modeled sack template, carried in the story
147
+ "wreath": {"model": "GEO_ACC_F0_WRE", "pose": 8006}, # WRE "WREath" -- a wreath (Doctor Tot holds one)
148
+ "dagger_doll": {"model": "GEO_ACC_F0_DGR", "pose": 1027}, # DGR "DaGgeR" -- a Dagger (Garnet) puppet; the Tantalus puppet show (Baku holds it)
149
+ "brahne_doll": {"model": "GEO_ACC_F0_DBR", "pose": 1038}, # DBR "Debu BRahne" (デブ = fat) -- a Fat Brahne puppet (Baku holds it)
150
+ "vial": {"model": "GEO_ACC_F0_BIN", "pose": 1880}, # BIN "Bin" (瓶 = bottle/vial) -- Blank's medicine vial, the Evil Forest spore antidote
151
+ }
152
+
153
+
154
+ # Composite props -- a multi-part set piece: several objects placed at the prop's (x, z), each with an
155
+ # optional (dx, dz) offset. Found via tools/find_composite_props.py + dump_field_objects.py. Most parts
156
+ # CO-LOCATE at one (x, z), y=0 (field 300's save point -- the floating feather/letter are baked into the
157
+ # MODELS, not script offsets); a few sit BESIDE the anchor (field 2203's scale -- the wood weight is
158
+ # offset from the scale body). Each part is (GEO model name, pose id) OR (GEO, pose, dx, dz).
159
+ #
160
+ # WIRED -- build.py expands `prop = "save_point"` to one inject_prop per part at the prop's (x, z). The
161
+ # save set {MOG, MGR, MGP, LTT} co-locates in 47 shipping fields (the most common composite); only MOG
162
+ # (moogle) + MGR (book) are placed STATIC -- MGP (feather)/LTT (letter) show ONLY during the save
163
+ # animation (their tag-37 does SetObjectFlags(7) = show bit; at rest flags(14) hides them -- field 300 e5).
164
+ # In-game: VERIFIED (2026-06-09) -- the moogle sits ON the book, co-located + facing down toward the
165
+ # player (the default facing is correct, no `face` needed); renders clean next to a normal single prop.
166
+ PROP_COMPOSITES: dict = {
167
+ "save_point": [ # the iconic moogle save point (Ice Cavern field 300, +46 more)
168
+ ("GEO_NPC_F0_MOG", 2904), # the moogle (an NPC model, placed static -- sits on the book)
169
+ ("GEO_ACC_F0_MGR", 1872), # the save book
170
+ # NB: the real save point ALSO co-locates the feather (MGP, pose 1874) + Mognet letter (LTT, 2479)
171
+ # here, but both are HIDDEN at rest -- their Init sets SetObjectFlags(14) (no "show model" bit) and
172
+ # their resting pose is tucked away. They only animate into view during the SAVE interaction (the
173
+ # feather's tag-37 func does RunAnimation(4652) + SetObjectFlags(7)=show -- the moogle writing). So
174
+ # they add nothing to a STATIC set piece -- omitted here. (In-game-verified: only moogle+book show.)
175
+ ],
176
+ "scale_set": [ # the Desert Palace balance scale LOADED at rest (static set piece; field 2203 "Rack")
177
+ ("GEO_ACC_F0_TNB", 2561), # scale -- field 2203's loaded/tilted pose (the `scale` archetype's even 12884 is the EMPTY pose)
178
+ ("GEO_ACC_F0_WT1", 6263), # clay weight -- lifted onto a pan (field 2203's on-pan pose)
179
+ ("GEO_ACC_F0_WT2", 6267), # stone weight -- on a pan
180
+ ("GEO_ACC_F0_WT3", 6271), # iron weight -- on a pan
181
+ ("GEO_ACC_F0_WT0", 12888, 188, -102), # wood weight -- on the ground BESIDE the scale (offset from field 2203)
182
+ # These are field 2203's AT-REST poses, NOT the weights' canonical archetype poses (13132/13128/13124 =
183
+ # their off-scale state -- co-located with the scale BODY those sit low + hidden inside it; 6263/6267/6271
184
+ # lift them onto the pans). Field 2204 ("Odyssey") spreads the weights out = the live PUZZLE, not a set piece.
185
+ # In-game: VERIFIED (2026-06-09) -- 3 weights on the (loaded, tilted) scale + the wood weight beside it.
186
+ ],
187
+ }
188
+
189
+
190
+ def names() -> list:
191
+ """Every prop-archetype name, sorted."""
192
+ return sorted(PROP_ARCHETYPES)
193
+
194
+
195
+ def is_prop_archetype(name) -> bool:
196
+ """True if ``name`` is a known prop archetype (case-insensitive)."""
197
+ return str(name).strip().lower() in PROP_ARCHETYPES
198
+
199
+
200
+ def resolve(name):
201
+ """``(model_id, pose_id)`` for a prop-archetype name. Raises ValueError (listing the known names) on
202
+ an unknown one."""
203
+ key = str(name).strip().lower()
204
+ if key not in PROP_ARCHETYPES:
205
+ raise ValueError(f"unknown prop archetype {name!r}. Known: {', '.join(names())}. "
206
+ f"Or place a model directly with model = \"GEO_ACC_F0_...\" (see `ff9mapkit models`).")
207
+ spec = PROP_ARCHETYPES[key]
208
+ return _catalog.resolve_model(spec["model"]), int(spec["pose"])
209
+
210
+
211
+ def is_composite(name) -> bool:
212
+ """True if ``name`` is a known **composite** prop (a multi-part set piece, e.g. ``save_point``)."""
213
+ return str(name).strip().lower() in PROP_COMPOSITES
214
+
215
+
216
+ def resolve_composite(name):
217
+ """``[(model_id, pose_id, dx, dz), ...]`` -- the parts of a composite prop, each placed at the prop's
218
+ ``pos`` plus its (dx, dz) offset. Most parts co-locate (dx=dz=0, the way a save point stacks); a few
219
+ sit beside the anchor (the scale's side weight). Raises ValueError on an unknown composite."""
220
+ key = str(name).strip().lower()
221
+ if key not in PROP_COMPOSITES:
222
+ raise ValueError(f"unknown composite prop {name!r}. Known: {', '.join(sorted(PROP_COMPOSITES))}.")
223
+ parts = []
224
+ for part in PROP_COMPOSITES[key]:
225
+ geo, pose = part[0], part[1]
226
+ dx, dz = (int(part[2]), int(part[3])) if len(part) >= 4 else (0, 0)
227
+ parts.append((_catalog.resolve_model(geo), int(pose), dx, dz))
228
+ return parts
ff9mapkit/provision.py ADDED
@@ -0,0 +1,282 @@
1
+ """Bring-your-own-install provisioning -- so the public repo ships ZERO Square Enix game data.
2
+
3
+ ``ff9mapkit`` is an authoring *tool*, not a game distribution. A few base assets it needs are DERIVED
4
+ from FF9's own field data:
5
+
6
+ * the **blank field** (956 B/language) -- the minimal playable field every built field starts from;
7
+ it is a *cleaned* clone of a base game field (popups removed, movement fixed, an after-battle
8
+ reinit added),
9
+ * the **exit-region template** (272 B) -- the standard field-exit entry the gateway injector patches,
10
+ * a handful of **test fixtures** (a real field script / camera / walkmesh) used by the offline suite.
11
+
12
+ Rather than bundle those copyrighted bytes, the repo ships only **our** part:
13
+
14
+ * tiny copy/insert **patches** -- the edits we made to a base field, i.e. our own bytes plus
15
+ *copy-from-offset* directives (never the game's bytes), exactly like an IPS/BPS ROM-hack patch, and
16
+ * a **manifest** naming which base fields to read and the SHA-256 of every regenerated blob.
17
+
18
+ ``ff9mapkit extract-templates`` reads the user's OWN, legally-owned FF9 install, applies the patches,
19
+ and writes the assets into a local cache (gitignored). This mirrors how emulators / ROM-hack tools
20
+ handle copyrighted assets: you must own the game. No FF9 bytes are ever redistributed by this project.
21
+
22
+ The patch *apply* path is pure-stdlib; only the extraction step imports UnityPy (lazily, via
23
+ ``extract``) and needs the game install.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import hashlib
28
+ import json
29
+ import os
30
+ from pathlib import Path
31
+
32
+ from .config import LANGS
33
+
34
+ # ---- where the regenerated (gitignored) assets live -----------------------------------------------
35
+ # Default: the package's own ``data/`` dir (works for the documented editable/clone install -- the
36
+ # files land in the working tree, gitignored). Override with $FF9MAPKIT_DATA for a read-only wheel
37
+ # install or a shared cache.
38
+ _PKG_DATA = Path(__file__).resolve().parent / "data"
39
+ PROVENANCE = _PKG_DATA / "provenance" # tracked: ships our patches + manifest (no game bytes)
40
+ MANIFEST = PROVENANCE / "manifest.json"
41
+
42
+
43
+ def data_dir() -> Path:
44
+ env = os.environ.get("FF9MAPKIT_DATA")
45
+ return Path(env) if env else _PKG_DATA
46
+
47
+
48
+ def blank_dir() -> Path:
49
+ return data_dir() / "blank_field"
50
+
51
+
52
+ def region_template_path() -> Path:
53
+ return data_dir() / "region_template.bin"
54
+
55
+
56
+ # ---- workspace cache for game-derived EXTRACTS (cameras / walkmeshes pulled from the user's install) ----
57
+ # Distinct from data_dir() (the kit's BASE templates): this is where `extract-field` / `gen-hub
58
+ # --extract-camera` drop a real field's camera.bgx + walkmesh.bgi so BG-borrow tomls reference ONE central,
59
+ # gitignored copy instead of a .bgx sprinkled next to every project. Provenance unchanged: the repo ships
60
+ # intent, you supply the bytes -- the whole cache is gitignored (.ff9mapkit-cache/), never committed.
61
+ def cache_dir() -> Path:
62
+ """The workspace extract-cache root (gitignored). ``$FF9MAPKIT_DATA`` overrides (a writable dir for a
63
+ read-only wheel install / a shared cache); else the kit-root ``.ff9mapkit-cache/`` (reserved + ignored)."""
64
+ env = os.environ.get("FF9MAPKIT_DATA")
65
+ if env:
66
+ return Path(env)
67
+ return Path(__file__).resolve().parent.parent / ".ff9mapkit-cache"
68
+
69
+
70
+ def field_cache_dir(field_id) -> Path:
71
+ """The cache subdir holding a real field's extracted assets (``camera.bgx`` / ``walkmesh.bgi``)."""
72
+ return cache_dir() / "fields" / str(field_id)
73
+
74
+
75
+ # ---- copy/insert patch format ---------------------------------------------------------------------
76
+ # A patch transforms a base field's bytes into one of our derived blobs. It is a list of ops:
77
+ # ["c", off, length] copy ``length`` bytes from the SOURCE at ``off`` (references, not bytes)
78
+ # ["i", "<hex>"] insert these literal bytes (OUR edits -- the only game-independent content)
79
+ # Stored as JSON with the source's SHA-256 (so we can verify the user extracted the right base) and
80
+ # the expected output length. Apply is exact + pure-stdlib.
81
+
82
+ def sha256(b: bytes) -> str:
83
+ return hashlib.sha256(b).hexdigest()
84
+
85
+
86
+ _MIN_COPY = 4 # any run >= this that exists in the source is referenced (copied), never shipped
87
+
88
+
89
+ def _decompose_insert(src: bytes, b: bytes) -> list:
90
+ """Turn a would-be insert ``b`` into ops that ship only bytes NOT present in ``src``: any run of
91
+ >= _MIN_COPY bytes found in the source becomes a copy (a reference, not data); the rest are true
92
+ inserts. This is the airtight guarantee -- the patch can't ship a meaningful game-byte run, even
93
+ one difflib failed to align (e.g. the per-language field name)."""
94
+ out: list = []
95
+ i, n = 0, len(b)
96
+ novel = bytearray()
97
+
98
+ def flush():
99
+ if novel:
100
+ out.append(["i", bytes(novel).hex()])
101
+ novel.clear()
102
+
103
+ while i < n:
104
+ j = i + 1 # longest prefix b[i:j] that occurs in src
105
+ while j <= n and src.find(b[i:j]) >= 0:
106
+ j += 1
107
+ run = j - 1 - i
108
+ if run >= _MIN_COPY:
109
+ flush()
110
+ out.append(["c", src.find(b[i:i + run]), run])
111
+ i += run
112
+ else:
113
+ novel.append(b[i])
114
+ i += 1
115
+ flush()
116
+ return out
117
+
118
+
119
+ def make_patch(src: bytes, dst: bytes) -> dict:
120
+ """Build a copy/insert patch turning ``src`` -> ``dst`` (maintainer side). difflib maximizes
121
+ copies; then every insert is decomposed so no run of >= _MIN_COPY bytes present in the source is
122
+ ever shipped (only our genuinely-novel edits remain as inserts)."""
123
+ import difflib # noqa: PLC0415 - only the maintainer regen path needs it
124
+ ops: list = []
125
+ for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(a=src, b=dst, autojunk=False).get_opcodes():
126
+ if tag == "equal":
127
+ ops.append(["c", i1, i2 - i1])
128
+ elif tag in ("replace", "insert"):
129
+ ops.extend(_decompose_insert(src, dst[j1:j2]))
130
+ # 'delete' -> drop (copy nothing)
131
+ inserted = sum(len(bytes.fromhex(op[1])) for op in ops if op[0] == "i")
132
+ return {"src_sha256": sha256(src), "out_len": len(dst), "out_sha256": sha256(dst),
133
+ "insert_bytes": inserted, "ops": ops}
134
+
135
+
136
+ def patch_game_runs(src: bytes, patch: dict, min_run: int = _MIN_COPY) -> list:
137
+ """The airtight audit: any INSERT run of >= ``min_run`` bytes that also occurs in ``src`` (i.e. a
138
+ game-byte run the patch would ship). Should always be empty -- :func:`make_patch` decomposes those
139
+ into copies. Returned as a list of (hex, src_offset) so a violation is inspectable."""
140
+ bad = []
141
+ for op in patch["ops"]:
142
+ if op[0] == "i":
143
+ b = bytes.fromhex(op[1])
144
+ if len(b) >= min_run and src.find(b) >= 0:
145
+ bad.append((b.hex(), src.find(b)))
146
+ return bad
147
+
148
+
149
+ def apply_patch(src: bytes, patch: dict) -> bytes:
150
+ """Apply a copy/insert patch to a freshly-extracted base field -> the derived blob (runtime side).
151
+
152
+ Verifies the source matches the patch's recorded hash (clear error if the user's base field
153
+ differs -- e.g. a non-vanilla install) and that the result matches the expected output hash."""
154
+ if patch.get("src_sha256") and sha256(src) != patch["src_sha256"]:
155
+ raise ValueError(
156
+ "source field bytes don't match the expected base (a modified/non-vanilla install?). "
157
+ "Re-run against an unmodified FF9 install, or report this with your Memoria version.")
158
+ out = bytearray()
159
+ for op in patch["ops"]:
160
+ if op[0] == "c":
161
+ _, off, length = op
162
+ out += src[off:off + length]
163
+ elif op[0] == "i":
164
+ out += bytes.fromhex(op[1])
165
+ else:
166
+ raise ValueError(f"unknown patch op {op[0]!r}")
167
+ res = bytes(out)
168
+ if len(res) != patch["out_len"] or ("out_sha256" in patch and sha256(res) != patch["out_sha256"]):
169
+ raise ValueError("patched output didn't match the expected hash -- patch/source mismatch.")
170
+ return res
171
+
172
+
173
+ # ---- manifest -------------------------------------------------------------------------------------
174
+ def load_manifest() -> dict:
175
+ if not MANIFEST.is_file():
176
+ raise FileNotFoundError(f"missing provenance manifest at {MANIFEST}")
177
+ return json.loads(MANIFEST.read_text(encoding="utf-8"))
178
+
179
+
180
+ def templates_present() -> bool:
181
+ """True if the load-bearing base assets (blank field + region template) have been extracted."""
182
+ bd = blank_dir()
183
+ return region_template_path().is_file() and all((bd / f"{l}.eb.bytes").is_file() for l in LANGS)
184
+
185
+
186
+ MISSING_MSG = (
187
+ "FF9 base templates not found. ff9mapkit ships no game data -- it regenerates the few base assets\n"
188
+ "it needs from YOUR FF9 install. Run:\n\n"
189
+ " ff9mapkit extract-templates\n\n"
190
+ "(needs UnityPy + your FF9 install path; see docs/PROVENANCE.md). This is a one-time step."
191
+ )
192
+
193
+
194
+ # ---- extraction orchestration (needs the install; UnityPy lazy via `extract`) ---------------------
195
+ def _write(path: Path, b: bytes) -> None:
196
+ path.parent.mkdir(parents=True, exist_ok=True)
197
+ path.write_bytes(b)
198
+
199
+
200
+ def extract_templates(game=None, *, fixtures: bool = True, verbose: bool = True) -> dict:
201
+ """Regenerate the kit's base assets from the user's FF9 install per the manifest. Writes the blank
202
+ field + region template into the data cache, and (if ``fixtures`` and a repo checkout is present)
203
+ the test fixtures into ``tests/fixtures``. Verifies every output against its manifest SHA-256.
204
+
205
+ Returns a report dict. Raises with guidance if the install / a base field can't be read."""
206
+ from . import extract # noqa: PLC0415 - lazy: only this path needs UnityPy + the install
207
+ man = load_manifest()
208
+ report = {"written": [], "verified": [], "skipped": []}
209
+
210
+ def _event(fbg, lang):
211
+ b = extract.extract_event_script(fbg, game=game, lang=lang)
212
+ if not b:
213
+ raise FileNotFoundError(
214
+ f"couldn't read event script for {fbg} ({lang}) from the install -- is the game path "
215
+ f"correct and the field present? (ff9mapkit doctor)")
216
+ return b
217
+
218
+ # 1) blank field: per-language patch from the base field's event script
219
+ blk = man["blank"]
220
+ for lang in LANGS:
221
+ src = _event(blk["source_fbg"], lang)
222
+ patch = json.loads((PROVENANCE / blk["patch"].format(lang=lang)).read_text(encoding="utf-8"))
223
+ out = apply_patch(src, patch)
224
+ if sha256(out) != blk["sha256"][lang]:
225
+ raise ValueError(f"blank {lang}: regenerated bytes don't match the manifest hash")
226
+ _write(blank_dir() / f"{lang}.eb.bytes", out)
227
+ report["written"].append(f"blank_field/{lang}.eb.bytes")
228
+ report["verified"].append(f"blank {lang}")
229
+ if verbose:
230
+ print(f" blank_field/{lang}.eb.bytes ({len(out)} B) OK")
231
+
232
+ # 2) region template: single patch from the base field's exit region
233
+ reg = man["region_template"]
234
+ src = _event(reg["source_fbg"], reg["lang"])
235
+ patch = json.loads((PROVENANCE / reg["patch"]).read_text(encoding="utf-8"))
236
+ out = apply_patch(src, patch)
237
+ if sha256(out) != reg["sha256"]:
238
+ raise ValueError("region_template: regenerated bytes don't match the manifest hash")
239
+ _write(region_template_path(), out)
240
+ report["written"].append("region_template.bin")
241
+ report["verified"].append("region_template")
242
+ if verbose:
243
+ print(f" region_template.bin ({len(out)} B) OK")
244
+
245
+ # 3) test fixtures (only when run from a repo checkout that has tests/)
246
+ fixtures_dir = _PKG_DATA.parent.parent / "tests" / "fixtures"
247
+ if fixtures and fixtures_dir.is_dir():
248
+ for name, spec in man.get("fixtures", {}).items():
249
+ out = _extract_fixture(extract, spec, game)
250
+ if sha256(out) != spec["sha256"]:
251
+ raise ValueError(f"fixture {name}: regenerated bytes don't match the manifest hash")
252
+ _write(fixtures_dir / name, out)
253
+ report["written"].append(f"tests/fixtures/{name}")
254
+ report["verified"].append(name)
255
+ if verbose:
256
+ print(f" tests/fixtures/{name} ({len(out)} B) OK")
257
+ elif fixtures and verbose:
258
+ print(" (no tests/ checkout -- skipping test fixtures)")
259
+ report["skipped"].append("fixtures")
260
+
261
+ return report
262
+
263
+
264
+ def _extract_fixture(extract, spec: dict, game) -> bytes:
265
+ """Regenerate one test fixture from the install per its manifest ``kind``."""
266
+ kind = spec["kind"]
267
+ if kind in ("event_verbatim", "event_with_gateway"):
268
+ b = extract.extract_event_script(spec["source_fbg"], game=game, lang=spec.get("lang", "us"))
269
+ if not b:
270
+ raise FileNotFoundError(f"couldn't read {spec['source_fbg']} from the install")
271
+ if kind == "event_with_gateway": # vanilla field + the kit's own door (no third-party mod bytes)
272
+ from .content import gateway as _gw # noqa: PLC0415
273
+ g = spec["gateway"]
274
+ b = _gw.inject_gateway(b, g["target"], entrance=g["entrance"], zone=_gw.quad_zone(g["zone"]))
275
+ return b
276
+ if kind in ("walkmesh_verbatim", "camera_bgx"):
277
+ import tempfile # noqa: PLC0415
278
+ td = Path(tempfile.mkdtemp())
279
+ extract.extract_field(spec["source_fbg"], td, game=game)
280
+ f = td / ("walkmesh.bgi" if kind == "walkmesh_verbatim" else "camera.bgx")
281
+ return f.read_bytes()
282
+ raise ValueError(f"unknown fixture kind {kind!r}")