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
ff9mapkit/config.py ADDED
@@ -0,0 +1,360 @@
1
+ """Path resolution + the FF9/Memoria mod-folder layout.
2
+
3
+ This module is the single place that knows where things live on disk, replacing the
4
+ absolute paths hardcoded into a dozen of the original bespoke scripts. Game-path
5
+ resolution is, in priority order:
6
+
7
+ 1. an explicit path passed in code / via the CLI ``--game`` flag
8
+ 2. the ``FF9_GAME_PATH`` environment variable
9
+ 3. a ``game_path`` key in the user config file ``~/.ff9mapkit.toml``
10
+ 4. a small list of common Steam install locations (best-effort fallback)
11
+
12
+ Nothing here touches the game install; these are pure path computations. A ``ModLayout``
13
+ wraps a *mod root* (e.g. ``<game>/FF9CustomMap``) and yields the canonical sub-paths the
14
+ builder writes to. The same layout works whether the mod root is the live game folder or
15
+ a scratch/staging directory — that decoupling is what lets the builder be validated
16
+ offline (build into a temp dir, diff against the deployed assets).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+
25
+ try:
26
+ import tomllib # Python 3.11+
27
+ except ModuleNotFoundError: # pragma: no cover - we require 3.11 but fail soft
28
+ tomllib = None # type: ignore[assignment]
29
+
30
+ # The seven shipped language folders. Field event scripts (.eb) and dialogue (.mes) are
31
+ # stored per-language; in practice the bytecode is identical across all seven and only the
32
+ # text differs, but we always write all seven so no locale loses the field.
33
+ LANGS: tuple[str, ...] = ("us", "uk", "fr", "gr", "it", "es", "jp")
34
+
35
+ # The mod folder Memoria reads first (highest override priority) on this project's install.
36
+ DEFAULT_MOD_FOLDER = "FF9CustomMap"
37
+
38
+ # User config file (TOML) — optional convenience so users set their game path once.
39
+ USER_CONFIG = Path.home() / ".ff9mapkit.toml"
40
+
41
+ # Best-effort fallbacks for a Steam install (Windows). Only used if nothing else resolves.
42
+ _COMMON_STEAM_PATHS = (
43
+ r"C:\Program Files (x86)\Steam\steamapps\common\FINAL FANTASY IX",
44
+ r"C:\Program Files\Steam\steamapps\common\FINAL FANTASY IX",
45
+ r"D:\SteamLibrary\steamapps\common\FINAL FANTASY IX",
46
+ )
47
+
48
+
49
+ class ConfigError(RuntimeError):
50
+ """Raised when a required path cannot be resolved or does not exist."""
51
+
52
+
53
+ def _read_user_config() -> dict:
54
+ if tomllib is None or not USER_CONFIG.is_file():
55
+ return {}
56
+ try:
57
+ with USER_CONFIG.open("rb") as fh:
58
+ return tomllib.load(fh)
59
+ except (OSError, ValueError):
60
+ return {}
61
+
62
+
63
+ def find_game_path(explicit: str | os.PathLike | None = None) -> Path:
64
+ """Resolve the Final Fantasy IX install folder.
65
+
66
+ Order: explicit arg > $FF9_GAME_PATH > ~/.ff9mapkit.toml(game_path) > common Steam dirs.
67
+ Raises ConfigError with actionable guidance if none of them point at a real folder.
68
+ """
69
+ candidates: list[Path] = []
70
+ if explicit:
71
+ candidates.append(Path(explicit))
72
+ env = os.environ.get("FF9_GAME_PATH")
73
+ if env:
74
+ candidates.append(Path(env))
75
+ cfg = _read_user_config().get("game_path")
76
+ if cfg:
77
+ candidates.append(Path(cfg))
78
+ candidates.extend(Path(p) for p in _COMMON_STEAM_PATHS)
79
+
80
+ for c in candidates:
81
+ if c.is_dir():
82
+ return c.resolve()
83
+
84
+ raise ConfigError(
85
+ "Could not locate the Final Fantasy IX install folder.\n"
86
+ "Set it one of these ways:\n"
87
+ " - pass --game \"<path>\" on the command line\n"
88
+ " - export FF9_GAME_PATH=\"<path>\"\n"
89
+ f" - add game_path = \"<path>\" to {USER_CONFIG}\n"
90
+ "The folder should contain FF9_Launcher.exe and a StreamingAssets directory."
91
+ )
92
+
93
+
94
+ def find_mod_root(game_path: Path, mod_folder: str = DEFAULT_MOD_FOLDER) -> Path:
95
+ """The mod root inside the game install (created by the builder if absent)."""
96
+ return (game_path / mod_folder).resolve()
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class ModLayout:
101
+ """Canonical sub-paths within a Memoria mod root.
102
+
103
+ `root` may be the live ``<game>/FF9CustomMap`` or any staging directory. All methods are
104
+ pure path joins (they neither read nor create anything) except ``ensure_dirs``.
105
+ """
106
+
107
+ root: Path
108
+
109
+ # --- top-level registration files Memoria reads from the mod root ---
110
+ @property
111
+ def dictionary_patch(self) -> Path:
112
+ return self.root / "DictionaryPatch.txt"
113
+
114
+ @property
115
+ def battle_patch(self) -> Path:
116
+ return self.root / "BattlePatch.txt"
117
+
118
+ @property
119
+ def text_patch(self) -> Path:
120
+ """Item/ability/card NAME + DESCRIPTION overrides (``TextPatch.txt``, a ``>DATABASE`` find/replace
121
+ patch -- ``Memoria.TextPatcher``). A per-mod-folder drop-in like the dictionary/battle patches,
122
+ read once at ``DataPatchers.Initialize`` -> a text change needs a RELAUNCH (not F6 Reload)."""
123
+ return self.root / "TextPatch.txt"
124
+
125
+ @property
126
+ def mod_description(self) -> Path:
127
+ return self.root / "ModDescription.xml"
128
+
129
+ # --- field background scene (camera/overlays + walkmesh + PNGs) ---
130
+ @property
131
+ def fieldmaps_dir(self) -> Path:
132
+ return self.root / "StreamingAssets" / "assets" / "resources" / "FieldMaps"
133
+
134
+ def fieldmap_dir(self, fbg_name: str) -> Path:
135
+ """Folder holding ``<fbg>.bgx``, ``<fbg>.bgi.bytes`` and the overlay PNGs."""
136
+ return self.fieldmaps_dir / fbg_name
137
+
138
+ # --- battle background (BBG): a loose FBX + image#.png the engine loads instead of the bundle ---
139
+ @property
140
+ def battlemap_all_dir(self) -> Path:
141
+ # NOTE: the capitalized "Assets/Resources/BattleMap" segments are VERBATIM -- this exact casing
142
+ # round-tripped in-game (2026-06-09); do NOT lowercase it to match fieldmaps_dir above.
143
+ return (self.root / "StreamingAssets" / "Assets" / "Resources"
144
+ / "BattleMap" / "BattleModel" / "battleMap_all")
145
+
146
+ def battlemap_dir(self, bbg: str) -> Path:
147
+ """Folder holding ``<bbg>.fbx`` + its ``image#.png`` textures (the loose-FBX override slot)."""
148
+ return self.battlemap_all_dir / bbg
149
+
150
+ # --- minted battle SCENE assets (tier c). Paths VERBATIM-proven in-game (C1/C2, 2026-06-09);
151
+ # casing matches battlemap_all_dir above (capitalized BattleMap), not the lowercase field tree. ---
152
+ @property
153
+ def _battle_resources(self) -> Path:
154
+ return self.root / "StreamingAssets" / "Assets" / "Resources"
155
+
156
+ def battle_scene_dir(self, scene_name: str) -> Path:
157
+ """``…/BattleMap/BattleScene/EVT_BATTLE_<NAME>`` — holds ``dbfile0000.raw16.bytes`` (gameplay) +
158
+ ``<scene_id>.raw17.bytes`` (btlseq + camera)."""
159
+ return self._battle_resources / "BattleMap" / "BattleScene" / f"EVT_BATTLE_{scene_name}"
160
+
161
+ @property
162
+ def battle_info_dir(self) -> Path:
163
+ """``…/BattleMap/BattleInfo`` — holds ``INB_B<N>.inb.bytes`` (BBGINFO: bbgnumber + anim flags)."""
164
+ return self._battle_resources / "BattleMap" / "BattleInfo"
165
+
166
+ def battle_eb_path(self, lang: str, scene_name: str) -> Path:
167
+ """``…/CommonAsset/EventEngine/EventBinary/Battle/<lang>/EVT_BATTLE_<NAME>.eb.bytes`` (battle AI)."""
168
+ return (self._battle_resources / "CommonAsset" / "EventEngine" / "EventBinary"
169
+ / "Battle" / lang / f"EVT_BATTLE_{scene_name}.eb.bytes")
170
+
171
+ def battle_text_dir(self, lang: str) -> Path:
172
+ """``<root>/FF9_Data/embeddedasset/text/<lang>/battle`` — holds ``<scene_id>.mes`` (battle text)."""
173
+ return self.root / "FF9_Data" / "embeddedasset" / "text" / lang / "battle"
174
+
175
+ # --- field event scripts (.eb), one folder per language ---
176
+ @property
177
+ def eventbinary_field_dir(self) -> Path:
178
+ return (
179
+ self.root
180
+ / "StreamingAssets" / "assets" / "resources" / "commonasset"
181
+ / "eventengine" / "eventbinary" / "field"
182
+ )
183
+
184
+ def eb_path(self, lang: str, evt_name: str) -> Path:
185
+ """``<root>/.../eventbinary/field/<lang>/<evt_name>`` (evt_name includes .eb.bytes)."""
186
+ return self.eventbinary_field_dir / lang / evt_name
187
+
188
+ def mapconfig_path(self, evt_name: str) -> Path:
189
+ """``<root>/.../commonasset/mapconfigdata/<evt_name>.bytes`` -- the field's 3D-model LIGHTING config
190
+ (per-floor lights + shadows + per-object colors), loaded at field setup by the SAME event name as
191
+ the ``.eb`` (``MapConfiguration.LoadMapConfigData`` / ``fldmcf.cs``). Not per-language."""
192
+ return (self.root / "StreamingAssets" / "assets" / "resources" / "commonasset"
193
+ / "mapconfigdata" / f"{evt_name}.bytes")
194
+
195
+ # --- dialogue text (.mes), one folder per language ---
196
+ def text_field_dir(self, lang: str) -> Path:
197
+ return self.root / "FF9_Data" / "embeddedasset" / "text" / lang / "field"
198
+
199
+ def mes_path(self, lang: str, mes_id: int) -> Path:
200
+ return self.text_field_dir(lang) / f"{mes_id}.mes"
201
+
202
+ # --- item / character DATA CSVs (mod-global; the engine merges/overrides them across FolderNames at
203
+ # new-game). Written at the mod-write stage from the entry field's [start_inventory]/[equipment]. ---
204
+ @property
205
+ def initial_items_csv(self) -> Path:
206
+ """The new-game starting bag (``Data/Items/InitialItems.csv``). HIGHEST-priority-wins -> a mod must
207
+ write the FULL bag, and a stacked folder SHADOWS it (lint)."""
208
+ return self.root / "StreamingAssets" / "Data" / "Items" / "InitialItems.csv"
209
+
210
+ @property
211
+ def default_equipment_csv(self) -> Path:
212
+ """Per-character starting equipment (``Data/Characters/DefaultEquipment.csv``). MERGED low->high by
213
+ the engine -> a partial delta (only the characters you change) works."""
214
+ return self.root / "StreamingAssets" / "Data" / "Characters" / "DefaultEquipment.csv"
215
+
216
+ @property
217
+ def shop_items_csv(self) -> Path:
218
+ """Custom shop inventories (``Data/Items/ShopItems.csv``). MERGED by id low->high by the engine -> a
219
+ partial delta (only the custom shops, ids >= 32) works; the base supplies shops 0-31."""
220
+ return self.root / "StreamingAssets" / "Data" / "Items" / "ShopItems.csv"
221
+
222
+ @property
223
+ def synthesis_csv(self) -> Path:
224
+ """Custom synthesis recipes (``Data/Items/Synthesis.csv`` = FF9MIX_DATA: Shops/Price/Result/Ingredients).
225
+ MERGED by id low->high (whole-row, ff9mix.LoadSynthesis) -> a partial delta works; the kit MINTS recipe
226
+ ids above the base max so it only ADDS recipes. A shop id opens as Synthesis iff it is absent from
227
+ ShopItems.csv (ff9buy.FF9Buy_GetType); a recipe shows at every shop id in its ``Shops`` list."""
228
+ return self.root / "StreamingAssets" / "Data" / "Items" / "Synthesis.csv"
229
+
230
+ @property
231
+ def weapons_csv(self) -> Path:
232
+ """Weapon combat data (``Data/Items/Weapons.csv``: Power/Elements/Category...). MERGED by id low->high
233
+ (WHOLE-ROW replace) -> a partial delta (the base header + only the rows you tune) works."""
234
+ return self.root / "StreamingAssets" / "Data" / "Items" / "Weapons.csv"
235
+
236
+ @property
237
+ def armors_csv(self) -> Path:
238
+ """Armor defence data (``Data/Items/Armors.csv``: P.Def/P.Eva/M.Def/M.Eva). MERGED by id low->high
239
+ (whole-row replace) -> a partial delta works."""
240
+ return self.root / "StreamingAssets" / "Data" / "Items" / "Armors.csv"
241
+
242
+ @property
243
+ def items_csv(self) -> Path:
244
+ """Item info (``Data/Items/Items.csv``: Price/SellingPrice/equip...). MERGED by id low->high (whole-row
245
+ replace) -> a partial delta works. (NOT InitialItems.csv -- that's the new-game bag, highest-wins.)"""
246
+ return self.root / "StreamingAssets" / "Data" / "Items" / "Items.csv"
247
+
248
+ @property
249
+ def stats_csv(self) -> Path:
250
+ """Equip stat bonuses + elemental affinity (``Data/Items/Stats.csv`` = ItemStats, keyed by BonusId).
251
+ MERGED by id low->high (whole-row replace, ff9equip.cs:26) -> a partial delta works; new minted bonus
252
+ rows just add entries. The input the level-up stat-growth accumulator reads (ff9play.cs:302-305)."""
253
+ return self.root / "StreamingAssets" / "Data" / "Items" / "Stats.csv"
254
+
255
+ @property
256
+ def item_effects_csv(self) -> Path:
257
+ """Consumable use-effects (``Data/Items/ItemEffects.csv`` = ItemEffect, keyed by EffectId). MERGED by id
258
+ low->high (whole-row replace, ff9item.LoadItemEffects) -> a partial delta works; EffectId is 1:1 with a
259
+ usable item (no shared Empty row), so a row is edited in place. Power/Rate/Element/Status/Dead are the
260
+ gameplay knobs; the ScriptId (the behaviour) stays."""
261
+ return self.root / "StreamingAssets" / "Data" / "Items" / "ItemEffects.csv"
262
+
263
+ @property
264
+ def actions_csv(self) -> Path:
265
+ """Shared player abilities (``Data/Battle/Actions.csv``). MERGED by id low->high (whole-row replace) ->
266
+ a partial delta (only the abilities you change) works; the base supplies the other 192 rows."""
267
+ return self.root / "StreamingAssets" / "Data" / "Battle" / "Actions.csv"
268
+
269
+ @property
270
+ def status_data_csv(self) -> Path:
271
+ """Status definitions (``Data/Battle/StatusData.csv``). MERGED by id low->high (whole-row replace) ->
272
+ a partial delta (only the statuses you change) works; the base supplies the other 33 rows."""
273
+ return self.root / "StreamingAssets" / "Data" / "Battle" / "StatusData.csv"
274
+
275
+ @property
276
+ def status_sets_csv(self) -> Path:
277
+ """Named multi-status BUNDLES (``Data/Battle/StatusSets.csv``) an action's ``status_index`` points at.
278
+ MERGED by id low->high -> a partial works (ids 0-38 are the base sets; use >=39 for a custom one)."""
279
+ return self.root / "StreamingAssets" / "Data" / "Battle" / "StatusSets.csv"
280
+
281
+ @property
282
+ def magic_sword_sets_csv(self) -> Path:
283
+ """Combo-unlock sets (``Data/Battle/MagicSwordSets.csv``): a Supporter's abilities unlock a Beneficiary's
284
+ (Vivi -> Steiner's Magic Sword). MERGED by id low->high -> a partial (only the author's sets) works."""
285
+ return self.root / "StreamingAssets" / "Data" / "Battle" / "MagicSwordSets.csv"
286
+
287
+ @property
288
+ def base_stats_csv(self) -> Path:
289
+ """Per-character base combat stats (``Data/Characters/BaseStats.csv``). MERGED by CharacterId low->high
290
+ -> a partial delta (only the characters you change) works; the base supplies the other 11."""
291
+ return self.root / "StreamingAssets" / "Data" / "Characters" / "BaseStats.csv"
292
+
293
+ @property
294
+ def character_parameters_csv(self) -> Path:
295
+ """Per-character identity (``Data/Characters/CharacterParameters.csv``): row / category / menu-preset /
296
+ equip-set. MERGED by CharacterId low->high -> a partial delta works (the base supplies the rest)."""
297
+ return self.root / "StreamingAssets" / "Data" / "Characters" / "CharacterParameters.csv"
298
+
299
+ @property
300
+ def command_sets_csv(self) -> Path:
301
+ """Per-character battle-menu command LAYOUTS (``Data/Characters/CommandSets.csv``), keyed by preset 0-19.
302
+ MERGED low->high -> a partial delta (only the presets you re-point) works."""
303
+ return self.root / "StreamingAssets" / "Data" / "Characters" / "CommandSets.csv"
304
+
305
+ @property
306
+ def leveling_csv(self) -> Path:
307
+ """The 99-row growth curve (``Data/Characters/Leveling.csv``). HIGHEST-priority-wins (WHOLE-FILE, gated at
308
+ >=99 rows) -> a mod must emit the FULL 99-row file, and a stacked folder SHADOWS it (lint)."""
309
+ return self.root / "StreamingAssets" / "Data" / "Characters" / "Leveling.csv"
310
+
311
+ @property
312
+ def ability_gems_csv(self) -> Path:
313
+ """Support-ability gem COSTS (``Data/Characters/Abilities/AbilityGems.csv``). MERGED per-SupportAbility
314
+ low->high -> a partial delta (only the abilities you re-cost) works; the base supplies the other 63."""
315
+ return self.root / "StreamingAssets" / "Data" / "Characters" / "Abilities" / "AbilityGems.csv"
316
+
317
+ def abilities_csv(self, preset_name: str) -> Path:
318
+ """A per-preset learn list (``Data/Characters/Abilities/<CharacterPresetId>.csv``, e.g. ``Vivi.csv``).
319
+ WHOLE-FILE highest-priority-wins -> the kit re-emits the complete list. A METHOD (not a property): the
320
+ Abilities learn lists are a FILE SET (one per preset), like ``battle_eb_path(lang, scene)``."""
321
+ return self.root / "StreamingAssets" / "Data" / "Characters" / "Abilities" / f"{preset_name}.csv"
322
+
323
+ @property
324
+ def ability_features_txt(self) -> Path:
325
+ """The ability-EFFECT DSL (``Data/Characters/Abilities/AbilityFeatures.txt``). MERGED per-ability
326
+ low->high (accumulator) -> a partial file (only the abilities you change) works; the base supplies the
327
+ rest. Authored from ``[[ability_feature]]`` (:mod:`battle.abilityfeatures`)."""
328
+ return self.root / "StreamingAssets" / "Data" / "Characters" / "Abilities" / "AbilityFeatures.txt"
329
+
330
+ def ensure_dirs(self, fbg_name: str | None = None, *, bbg: str | None = None,
331
+ langs: tuple[str, ...] = LANGS) -> None:
332
+ """Create the directory skeleton a field (and/or battle-map) write needs."""
333
+ self.root.mkdir(parents=True, exist_ok=True)
334
+ if fbg_name:
335
+ self.fieldmap_dir(fbg_name).mkdir(parents=True, exist_ok=True)
336
+ if bbg:
337
+ self.battlemap_dir(bbg).mkdir(parents=True, exist_ok=True)
338
+ for lang in langs:
339
+ (self.eventbinary_field_dir / lang).mkdir(parents=True, exist_ok=True)
340
+ self.text_field_dir(lang).mkdir(parents=True, exist_ok=True)
341
+
342
+
343
+ # ---- FBG (background scene) naming -------------------------------------------------------
344
+
345
+ MIN_AREA = 10 # the area id must be >= 10: the FieldScene parser builds "FBG_N"+area with no
346
+ # zero-padding and the asset loader reads exactly 2 chars, so 00-09 black-screen.
347
+
348
+
349
+ def fbg_name(area: int, name: str) -> str:
350
+ """Build the canonical background-scene folder/key name, e.g. (11, 'HUT_EXT') -> 'FBG_N11_HUT_EXT'.
351
+
352
+ Raises ConfigError for single-digit areas (the zero-padding gotcha from the field plumbing).
353
+ """
354
+ if area < MIN_AREA:
355
+ raise ConfigError(
356
+ f"area id must be >= {MIN_AREA} (got {area}). The FieldScene directive builds the "
357
+ f"background name as 'FBG_N{{area}}' with no zero-padding and the loader reads exactly "
358
+ f"two characters, so single-digit areas (00-09) fail to resolve and black-screen."
359
+ )
360
+ return f"FBG_N{area}_{name}"
@@ -0,0 +1,13 @@
1
+ """Generalized field-script content injectors (operate on .eb bytes via the eb library).
2
+
3
+ npc - inject an NPC (model/anims/dialogue) + move the player spawn
4
+ gateway - inject a field-exit region trigger (warp to another field)
5
+ encounter - add random battles
6
+ reinit - add the after-battle handler (+ fast fade-in) custom fields need
7
+ music - field BGM on entry and after battle
8
+ text - author dialogue .mes entries (high-TXID, non-colliding)
9
+ """
10
+
11
+ from . import encounter, gateway, music, npc, reinit, text
12
+
13
+ __all__ = ["npc", "gateway", "encounter", "reinit", "music", "text"]
@@ -0,0 +1,36 @@
1
+ """Suppress a BG-borrowed field's inherited AREA-TITLE overlays -- the big "Ice Cavern" / "Mognet Central"
2
+ card.
3
+
4
+ The title is a range of scene OVERLAYS (indices from :mod:`ff9mapkit.areatitle`). In the real game the
5
+ DONOR field's own ``.eb`` owns the title's whole lifecycle: ``Main_Init`` hides the overlays at load, and a
6
+ *scenario-gated* block later shows + fades them (and, on a transition field, warps onward). A ``--verbatim``
7
+ fork carries that script byte-for-byte, so a forked field replays the stock show+fade on its own when the
8
+ journey seeds the trigger scenario -- the kit does NOT script the title for forks (doing so would double
9
+ the card and re-fire the donor's warp).
10
+
11
+ The gap this module fills is the OTHER case: a SYNTHESIZED field that BG-borrows an area-title room (the
12
+ World Hub borrows Mognet Central's room) inherits those overlays Active-by-default, with no donor ``.eb`` to
13
+ retire them -- so the title sits there statically claiming to be that place. :func:`hide` prepends
14
+ ``ShowTile(i, 0)`` for the title overlays to ``Main_Init`` (entry-0 tag-0) so it never shows. A tag-0
15
+ prepend (``rel_off == 0``) is shift-safe even on jump-table donors; the injection is language-identical
16
+ and no-ops when the field has no area title. Mirrors :mod:`ff9mapkit.content.entry_settle`.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from ..eb import edit, opcodes
22
+
23
+ SHOWTILE = 0x5B # ShowTile / BGLACTIVE: ShowTile(overlayIdx, active) -- active 0 = hide, 1 = show
24
+
25
+
26
+ def hide(eb_bytes, start, end) -> bytes:
27
+ """Prepend ``ShowTile(i, 0)`` for every overlay ``i`` in ``[start, end]`` to Main_Init (entry-0 tag-0)
28
+ so the area-title overlays are suppressed from the first frame. Returns the input unchanged when the
29
+ field has no title range (``start``/``end`` is ``None``). ``.eb``-language-identical (call once)."""
30
+ if start is None or end is None:
31
+ return eb_bytes
32
+ ovr = list(range(int(start), int(end) + 1))
33
+ if not ovr:
34
+ return eb_bytes
35
+ body = b"".join(opcodes.encode(SHOWTILE, i, 0) for i in ovr)
36
+ return edit.insert_in_function(eb_bytes, 0, 0, 0, body)
@@ -0,0 +1,118 @@
1
+ """``[[ate]]`` -- synthesize an Active Time Event (FF9's optional "Press SELECT" cutscene mechanism).
2
+
3
+ An ATE has three engine-visible parts (see ``docs/ATE_SYSTEM.md``); this module emits all of them onto
4
+ a custom field, byte-for-byte as the real Lindblum Main-St hub (field 552, the "Small-Town Knight" ATE):
5
+
6
+ Main_Init wiring (prepended):
7
+ ATE(mode) # 0xD7 -> EIcon.SetAIcon -> the blinking "Active Time Event" HUD prompt
8
+ <avail flag> = 1 # the author's own GLOB availability flag
9
+ InitCode(<menu slot>) # activate the menu code-entry (its tag-1 func then loops each frame)
10
+
11
+ the menu code-entry (appended):
12
+ func0 (tag 0): RETURN # trivial init
13
+ func1 (tag 1): the per-frame LOOP --
14
+ if ( usercontrol==1 AND <avail>==1 AND B_KEYON(SELECT) ) { # the real gate, field 552 [11667]
15
+ DisableMove ; EnableDialogChoices ; WindowSync(win, 64=winATE, prompt) ; # the "Select event" menu
16
+ <branch on GetChoose -> the picked row's body> ; EnableMove
17
+ }
18
+ RETURN
19
+
20
+ Engine facts grounding every byte (all verified): ``AICON=0xD7`` (the blink HUD), ``winATE=64`` ->
21
+ ``CaptionType.ActiveTimeEvent``, ``GetChoose`` = sysvar 9, ``B_KEYON=0x4F`` (press-edge), ``Select=1u``,
22
+ sysvar 2 = ``usercontrol``. The menu + per-row branch reuse :mod:`ff9mapkit.content.choice` (the same
23
+ ``GetChoose`` machinery) with ``flags=64`` so the window renders with the ATE caption; each row's body is the
24
+ ordinary event/choice action vocabulary (a narration line, a story-flag set, or a ``warp`` to the cutscene
25
+ field -- the real hub->destination pattern, e.g. Small-Town Knight -> ``Field(555)``).
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import struct
30
+
31
+ from ..eb import EbScript, edit, opcodes
32
+ from . import choice as _choice, region as _region
33
+
34
+ # Availability-flag band: GLOB bools, clear of events (8000), cutscene (8100), choice (8200). Each [[ate]]
35
+ # on a field claims ATE_FLAG_BASE + i so multiple ATEs don't collide.
36
+ ATE_FLAG_BASE = 8300
37
+
38
+ # ATE(mode) presets -- the arg is a 3-bit FLAG WORD, not an enum (see opcodes.ate / EIcon.cs:416-454 /
39
+ # docs/ATE_SYSTEM.md): >0 enable (0=off); &3==2 Gray (else Blue); &4 force-show (draw without user control).
40
+ MODE_BLUE = 1 # Blue, no force -- the optional Press-SELECT prompt (shows only with control). The [ate] HUB default.
41
+ MODE_GRAY = 2 # Gray, no force -- NEVER used in the real game (can't show during a no-control beat; use 6 for grey).
42
+ MODE_FORCE = 5 # Blue + force -- field 206's lone real site. AVOID for authoring: re-flashes the SELECT glyph (use 6).
43
+
44
+ WIN_ATE = 64 # ETb.winATE -> Dialog.CaptionType.ActiveTimeEvent
45
+
46
+
47
+ def _build_entry(funcs, etype: int = 0) -> bytes:
48
+ """Assemble an entry body from ``funcs = [(tag, code_bytes), ...]``: ``[type][func_count]`` + the
49
+ func table (``tag:u16, fpos:u16`` each; ``fpos`` is relative to entryStart+2) + the concatenated code.
50
+ Mirrors the layout :class:`ff9mapkit.eb.model.EbScript` parses (so it round-trips)."""
51
+ fc = len(funcs)
52
+ fpos = fc * 4 # code starts right after the func table
53
+ table = bytearray()
54
+ code = bytearray()
55
+ for tag, c in funcs:
56
+ table += struct.pack("<HH", tag, fpos)
57
+ code += c
58
+ fpos += len(c)
59
+ return bytes([etype, fc]) + bytes(table) + bytes(code)
60
+
61
+
62
+ JMP_OP = 0x01 # the undocumented UNCONDITIONAL jump (op_01): target = instr.end + signed-i16 offset
63
+
64
+
65
+ def menu_loop_body(prompt_txid: int, option_bodies, *, avail_idx: int, window: int = 1,
66
+ setup: bytes = b"") -> bytes:
67
+ """The menu entry's tag-1 LOOP body. It must POLL each frame, exactly as the real ATE menu does
68
+ (field 552 entry-7 tag-1)::
69
+
70
+ loop: if ( usercontrol AND avail AND B_KEYON(SELECT) ) { winATE menu }
71
+ Wait(1) # yield a frame -> one poll per frame (not a busy-spin)
72
+ JMP loop # op_01 unconditional jump back to the top; NO RETURN
73
+
74
+ The poller lives as long as the field is loaded (no terminate). ``setup`` is the optional
75
+ ``EnableDialogChoices`` pre-choose opcode (default / cancel / hidden rows), from
76
+ :func:`ff9mapkit.content.choice.pre_choose`.
77
+
78
+ NB the earlier ``if(gate){menu} + RETURN`` form ran ONCE right after ``InitCode`` and never polled
79
+ again -- so SELECT did nothing. The ``Wait(1)`` + ``op_01`` jump-back is what makes it a real loop."""
80
+ menu = _choice.region_body(prompt_txid, option_bodies, window=window, flags=WIN_ATE, setup=setup)
81
+ gate = _region.cond_ate_select(_region.GLOB_BOOL, avail_idx)
82
+ body = _region.if_block(gate, menu) + opcodes.wait(1)
83
+ back = -(len(body) + 3) # op_01 is 3 bytes; jump back to the loop top (offset 0)
84
+ return body + bytes([JMP_OP]) + struct.pack("<h", back)
85
+
86
+
87
+ def menu_entry(prompt_txid: int, option_bodies, *, avail_idx: int, window: int = 1,
88
+ setup: bytes = b"") -> bytes:
89
+ """The complete ATE menu CODE-ENTRY body (ready for :func:`ff9mapkit.eb.edit.append_entry`): a trivial
90
+ tag-0 init (RETURN) + the tag-1 polling loop (:func:`menu_loop_body`). InitCode'd from Main_Init."""
91
+ return _build_entry([(0, opcodes.RETURN),
92
+ (1, menu_loop_body(prompt_txid, option_bodies, avail_idx=avail_idx,
93
+ window=window, setup=setup))])
94
+
95
+
96
+ def main_init_inject(*, avail_idx: int, menu_slot: int, mode: int = MODE_BLUE) -> bytes:
97
+ """The Main_Init wiring, prepended to entry-0 tag-0: arm the blinking prompt, set the availability
98
+ flag, and activate the menu entry. ``ATE(mode) ; <avail>=1 ; InitCode(menu_slot)``."""
99
+ return (opcodes.ate(mode)
100
+ + _region.set_var(_region.GLOB_BOOL, avail_idx, 1)
101
+ + opcodes.init_code(menu_slot))
102
+
103
+
104
+ def inject_ate(data, prompt_txid: int, option_bodies, *, avail_idx: int = ATE_FLAG_BASE,
105
+ mode: int = MODE_BLUE, setup: bytes = b"") -> bytes:
106
+ """Synthesize a complete ATE onto ``data`` (a field ``.eb``): append the SELECT-polling menu entry
107
+ (:func:`menu_entry`) into a free slot, then PREPEND the Main_Init wiring (:func:`main_init_inject` --
108
+ ``ATE(mode)`` + set the avail flag + ``InitCode`` the menu slot) to entry-0 tag-0. Returns new ``.eb``
109
+ bytes. The prepend goes through :func:`ff9mapkit.eb.edit.insert_in_function`, which is boundary-safe even
110
+ on a Main_Init with a ``0x06`` scenario jump-table. No-op-safe: pass real ``option_bodies`` (one per row).
111
+ Default ``mode`` is Blue (mode 1, shows while the player has control -- the HUB convention). For a forced
112
+ auto-play use mode 6 (Gray+force) via ``[cutscene] ate``, not ``MODE_FORCE`` (5, Blue+force) -- a force-shown
113
+ Blue icon re-flashes the SELECT glyph during auto-play (mode arg = a 3-bit flag word; see EIcon.cs:416-454)."""
114
+ out = data if isinstance(data, (bytes, bytearray)) else data.to_bytes()
115
+ entry = menu_entry(prompt_txid, option_bodies, avail_idx=avail_idx, setup=setup)
116
+ slot = EbScript.from_bytes(out).first_free_slot()
117
+ out = edit.append_entry(out, slot, entry)
118
+ return edit.insert_in_function(out, 0, 0, 0, main_init_inject(avail_idx=avail_idx, menu_slot=slot, mode=mode))
@@ -0,0 +1,123 @@
1
+ """Camera-services / scrolling control (the ``BGCACTIVE`` opcode).
2
+
3
+ Larger-than-screen fields scroll the view to follow the player. Memoria's 3D scroll
4
+ (``FieldMap.SceneService3DScroll``) runs automatically for a walkable field, but only when the
5
+ field's ``Active`` flag is set — and that flag is set by the field-script opcode
6
+ ``EnableCameraServices`` (``BGCACTIVE`` = 0x71, args ``isActive, frameCount, sinusOrLinear``).
7
+
8
+ A field cloned from a static (non-scrolling) base never calls it, so :func:`enable_camera_services`
9
+ injects ``EnableCameraServices(1, 0, 0)`` at the start of Main_Init. Proven in-game on the 768x448
10
+ scroll spike (field 4003): with the wide ``Range`` + scroll ``Viewport`` (see
11
+ :func:`ff9mapkit.scene.cam.scroll_bounds`) the view then pans + clamps cleanly.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import struct
17
+
18
+ from ..eb import EbScript, edit, opcodes
19
+ from . import region as _region
20
+
21
+ BGCACTIVE_OP = 0x71 # EnableCameraServices
22
+
23
+ # The camera-state flag. VAR_GlobUInt8_24 is exactly what the real field (Gargan Roo/Passage) uses;
24
+ # our init entry resets it to 0 on every field load, so it never collides across loads.
25
+ DEFAULT_FLAG = (_region.GLOB_UINT8, 24)
26
+
27
+
28
+ def enable_camera_services(eb_bytes, *, frame_count: int = 0, scroll_type: int = 0) -> bytes:
29
+ """Insert ``EnableCameraServices(1, frame_count, scroll_type)`` at the start of Main_Init.
30
+
31
+ ``frame_count`` = duration (frames) of the camera's reposition-to-player when it activates
32
+ (0 = instant; -1 defaults to 30 in the engine). ``scroll_type`` = 8 for sinusoidal, else linear.
33
+ Uses :func:`edit.insert_bytes` (relocates jumps/fpos), so it is safe alongside other injectors.
34
+ """
35
+ eb = EbScript.from_bytes(eb_bytes)
36
+ f = eb.entry(0).func_by_tag(0)
37
+ if f is None:
38
+ raise ValueError("entry 0 has no Main_Init (tag 0) to enable camera services in")
39
+ code = opcodes.encode(BGCACTIVE_OP, 1, int(frame_count), int(scroll_type))
40
+ return edit.insert_bytes(eb_bytes, f.abs_start, code)
41
+
42
+
43
+ # --------------------------------------------------------------------------- multi-camera switch
44
+ # Generalizes the real-field convention (decoded byte-for-byte from Gargan Roo/Passage,
45
+ # evt_gargan_gr_lef_0) to N cameras via an AREA model: a state flag holds the CURRENT camera index,
46
+ # and each zone owns the floor area where its camera should be active. Entering a zone for camera K
47
+ # while flag != K switches to camera K, stores K in the flag, and re-tunes movement
48
+ # (SetControlDirection for K's yaw). The flag guard stops re-firing while you stand in a zone;
49
+ # NON-OVERLAPPING zones can't flap. An init code-entry resets the flag to 0 + arms every zone on
50
+ # field load (state is consistent on entry); after a battle (Main_Init doesn't run) the tag-10
51
+ # restore (:func:`add_camera_restore`) re-applies the stored camera + movement.
52
+
53
+ REINIT_TAG = 10
54
+
55
+
56
+ def _zone_body(to_camera: int, control_value: int, flag) -> bytes:
57
+ """A camera zone's Range body: movement-gate, then `if (flag != to_camera) { SetFieldCamera;
58
+ set flag = to_camera; SetControlDirection }`."""
59
+ flag_class, flag_idx = flag
60
+ actions = (opcodes.set_field_camera(to_camera)
61
+ + _region.set_var(flag_class, flag_idx, to_camera)
62
+ + opcodes.set_control_direction(control_value, control_value))
63
+ return (_region.MOVEMENT_GATE
64
+ + _region.if_not_block(_region.cond_eq(flag_class, flag_idx, to_camera), actions)
65
+ + opcodes.RETURN)
66
+
67
+
68
+ def inject_camera_zones(data, zones, control_values, *, flag=DEFAULT_FLAG, spawn_wait_n: int = 2,
69
+ spawn_wait_occurrence: int = 0) -> bytes:
70
+ """Inject N camera-switch zones (the area model). Returns new .eb bytes.
71
+
72
+ ``zones`` = list of ``(to_camera, [4 (x, z) corners])``; ``control_values[k]`` = the
73
+ SetControlDirection (TWIST) value for camera ``k`` (derive from its yaw). Each zone owns the floor
74
+ area where its camera is active; standing in it sets that camera. Zones SHOULD NOT overlap
75
+ (overlapping zones flap). Needs ``len(zones) + 1`` free entry slots (the zones + one load-time
76
+ init/arm entry that resets the flag to 0 and arms them all)."""
77
+ flag_class, flag_idx = flag
78
+ zones = list(zones)
79
+ eb = EbScript.from_bytes(data)
80
+ if len(eb.free_slots()) < len(zones) + 1:
81
+ raise ValueError(f"need {len(zones) + 1} free entry slots for {len(zones)} camera zones, "
82
+ f"have {len(eb.free_slots())}")
83
+ out = data
84
+ slots = []
85
+ for to_camera, corners in zones:
86
+ body = _zone_body(int(to_camera), int(control_values[int(to_camera)]), flag)
87
+ out, slot = _region.inject_region(out, [tuple(p) for p in corners], body, activate=False)
88
+ slots.append(slot)
89
+ # init/arm entry: reset flag = 0 (camera 0 at load) + arm every zone.
90
+ init_body = _region.set_var(flag_class, flag_idx, 0)
91
+ for s in slots:
92
+ init_body += opcodes.init_region(s, 0)
93
+ init_body += opcodes.RETURN
94
+ init_entry = bytes([0x00, 0x01]) + struct.pack("<HH", 0, 4) + init_body
95
+ init_slot = EbScript.from_bytes(out).first_free_slot()
96
+ out = edit.append_entry(out, init_slot, init_entry)
97
+ out = edit.activate(out, opcodes.init_code(init_slot, 0), spawn_wait_n=spawn_wait_n,
98
+ spawn_wait_occurrence=spawn_wait_occurrence)
99
+ return out
100
+
101
+
102
+ def add_camera_restore(data, cameras_used, control_values, *, flag=DEFAULT_FLAG) -> bytes:
103
+ """Add an after-battle camera restore to Main_Reinit (tag 10). Returns new .eb bytes.
104
+
105
+ For each non-zero camera ``K`` in ``cameras_used``: ``if (flag == K) { SetFieldCamera(K);
106
+ SetControlDirection(K) }``. After a battle the field runs tag-10 (NOT Main_Init, so the flag isn't
107
+ reset) -- this re-applies the camera + movement the player was on. Requires an existing tag-10
108
+ (``content.reinit.add_reinit`` / an encounter); a no-op if no non-zero camera is used."""
109
+ flag_class, flag_idx = flag
110
+ eb = EbScript.from_bytes(data)
111
+ f = eb.entry(0).func_by_tag(REINIT_TAG)
112
+ if f is None:
113
+ raise ValueError("entry 0 has no tag-10 handler (run content.reinit.add_reinit first)")
114
+ restore = b""
115
+ for k in sorted({int(c) for c in cameras_used}):
116
+ if k == 0:
117
+ continue
118
+ actions = opcodes.set_field_camera(k) + opcodes.set_control_direction(
119
+ int(control_values[k]), int(control_values[k]))
120
+ restore += _region.if_block(_region.cond_eq(flag_class, flag_idx, k), actions)
121
+ if not restore:
122
+ return data if isinstance(data, (bytes, bytearray)) else data.to_bytes()
123
+ return edit.insert_bytes(data, f.abs_start, restore)