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,985 @@
1
+ """The Battle document for the Workspace -- author a battle.toml ENCOUNTER-FIRST.
2
+
3
+ Open a battle.toml and tune it as an encounter: its ``[battlemap]`` identity, its ``[scene]`` FORMATION, and
4
+ each ``[[scene.enemy]]`` slot, edited as forms (the :mod:`ff9mapkit.editor.battle_forms` specs over the shared
5
+ ``forms_qt`` builder -- the same machinery the field editor uses). ``Check`` runs ``validate_battle`` into the
6
+ Problems dock; deploying is the existing **Build & Deploy** battle path (open the same battle.toml there).
7
+
8
+ Modeled on :class:`~ff9mapkit.workspace.savedoc.ItemEquipDoc`: a self-contained document with a left NODE list
9
+ (Map / Formation / one per enemy slot) + a right form, over tk-free backends. A battle.toml is read with
10
+ ``tomllib`` and written back with :func:`ff9mapkit.editor.model.dumps` (round-trip-safe for the battle schema).
11
+ Creating a battle.toml is the ``ff9mapkit battle-import`` CLI's job (like forking a field is the Import tab's);
12
+ this document TUNES one.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ import tomllib
18
+ from pathlib import Path
19
+
20
+ from PySide6.QtCore import Qt
21
+ from PySide6.QtGui import QKeySequence, QShortcut
22
+ from PySide6.QtWidgets import (
23
+ QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QFrame, QGridLayout, QGroupBox,
24
+ QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMessageBox, QPushButton, QScrollArea,
25
+ QSplitter, QVBoxLayout, QWidget,
26
+ )
27
+
28
+ from ..editor import battle_forms as bf
29
+ from ..editor import feedback as fb
30
+ from ..editor import forms
31
+ from ..editor import model as _model
32
+ from .forms_qt import build_form, read
33
+
34
+ _MAP, _SCENE, _ENEMY, _AIPHASE = "battlemap", "scene", "enemy", "ai_phase"
35
+ _AIPATCH, _SEQPATCH = "ai_patch", "seq_patch"
36
+
37
+ # MonParm attribute -> compact label, for the read-only DONOR BASELINE shown above an enemy form: the forked
38
+ # enemy's CURRENT scalar stats, so an override reads against what it's changing FROM. Scalars only (the element
39
+ # / status masks need decoding -- a later pass); the keys line up with ENEMY_SPEC so the panel sits next to the
40
+ # matching form rows.
41
+ _BASELINE_FIELDS = [
42
+ ("hp", "HP"), ("mp", "MP"), ("strength", "Str"), ("magic", "Mag"), ("speed", "Spd"), ("spirit", "Spr"),
43
+ ("level", "Lv"), ("phys_def", "P.def"), ("phys_evade", "P.eva"), ("mag_def", "M.def"),
44
+ ("mag_evade", "M.eva"), ("hit_rate", "Hit"), ("category", "Cat"), ("blue_magic", "Blue"),
45
+ ("gil", "Gil"), ("exp", "EXP"), ("win_card", "Card"),
46
+ ]
47
+
48
+
49
+ def donor_baseline(raw16: bytes, enemy: dict):
50
+ """``(type_no, [(label, value)...])`` for an enemy slot's TYPE from a forked scene's raw16, or None when the
51
+ type can't be resolved / the bytes don't parse. PURE (no I/O) so it unit-tests without Qt; the document
52
+ wraps it with the file read. The type is the slot's explicit ``type``, else pattern-0's put at that slot."""
53
+ try:
54
+ from ..battle import scene_codec as _sc
55
+ scene = _sc.parse_scene(raw16)
56
+ except Exception: # noqa: BLE001 -- a truncated / non-scene raw16
57
+ return None
58
+ t = enemy.get("type")
59
+ if t is None:
60
+ slot = enemy.get("slot")
61
+ if scene.patterns and isinstance(slot, int) and 0 <= slot < 4:
62
+ t = scene.patterns[0].puts[slot].type_no
63
+ if not isinstance(t, int) or not (0 <= t < len(scene.monsters)):
64
+ return None
65
+ mon = scene.monsters[t]
66
+ return t, [(label, getattr(mon, attr)) for attr, label in _BASELINE_FIELDS]
67
+
68
+
69
+ import re as _re
70
+
71
+ _STRT_RE = _re.compile(r"^\[STRT=[^\]]*\]")
72
+
73
+
74
+ def _mes_strings(mes_bytes: bytes):
75
+ """The battle ``.mes`` strings in order (index = the AA_DATA name id), the ``[STRT=..]`` prefix stripped."""
76
+ out = []
77
+ for chunk in mes_bytes.decode("utf-8", "replace").split("[ENDN]"):
78
+ if chunk.strip():
79
+ out.append(_STRT_RE.sub("", chunk).strip())
80
+ return out
81
+
82
+
83
+ def donor_ai_facts(eb_bytes: bytes, raw16_bytes: bytes = None, mes_bytes: bytes = None):
84
+ """``(attacks, ai_funcs)`` for the forked scene -- the indices/entries the AI-phase form needs, or None if the
85
+ ``.eb`` doesn't parse. ``attacks`` = ``[(index, name)]`` (the ``then``/``else`` values; names resolved from the
86
+ ``.mes`` when given). ``ai_funcs`` = ``[(entry, type, tag, role, n_attacks)]`` per enemy-AI function -- a function
87
+ with EXACTLY ONE ``Attack`` is the ``ai_phase``-able target. Pure (no I/O) so it unit-tests without Qt."""
88
+ from ..battle.battleai import _decode_func_pretty, _tag_role
89
+ from ..eb.model import EbScript
90
+ try:
91
+ eb = EbScript.from_bytes(eb_bytes)
92
+ except Exception: # noqa: BLE001 -- a truncated / non-battle eb
93
+ return None
94
+ ai_funcs = []
95
+ for e in eb.entries:
96
+ if e.empty or e.index == 0: # entry 0 = Main_Init (spawn binding, not an enrage target)
97
+ continue
98
+ for f in e.funcs:
99
+ try:
100
+ n_atk = sum(1 for _o, mn, _ops in _decode_func_pretty(eb.data, f.abs_start, min(f.abs_end, len(eb.data)))
101
+ if mn == "Attack")
102
+ except Exception: # noqa: BLE001 -- malformed bytecode in one func
103
+ n_atk = -1
104
+ ai_funcs.append((e.index, e.index - 1, f.tag, _tag_role(f.tag), n_atk))
105
+ attacks = []
106
+ if raw16_bytes:
107
+ try:
108
+ from ..battle import scene_codec as _sc
109
+ scene = _sc.parse_scene(raw16_bytes)
110
+ # the battle .mes lists the `typ_count` enemy-TYPE names first, then the `atk_count` ATTACK names
111
+ # (AA_DATA.name is 0/unused for the display) -> attack i is string[typ_count + i].
112
+ strings = _mes_strings(mes_bytes) if mes_bytes else []
113
+ base = scene.typ_count
114
+ for i in range(len(scene.attacks)):
115
+ attacks.append((i, strings[base + i] if 0 <= base + i < len(strings) else "?"))
116
+ except Exception: # noqa: BLE001
117
+ attacks = []
118
+ return attacks, ai_funcs
119
+
120
+
121
+ def ai_patch_sites(eb_bytes: bytes):
122
+ """``[(offset, value, where, lo, hi)]`` for every patchable AI constant in a forked scene's ``.eb`` (the
123
+ sites an ``[[scene.ai_patch]]`` cites), or None if the bytes don't parse. ``lo``/``hi`` bound a same-length
124
+ ``new``. Pure (no I/O) so it unit-tests without Qt; the document wraps it with the file read + the picker."""
125
+ from ..battle import aipatch as _ap
126
+ try:
127
+ sites = _ap.constant_sites(eb_bytes)
128
+ except _ap.AiPatchError:
129
+ return None
130
+ return [(s.offset, s.value, s.where, 0, s.vmax) for s in sites]
131
+
132
+
133
+ def seq_patch_sites(raw17_bytes: bytes):
134
+ """``[(offset, value, where, lo, hi, seq)]`` for every patchable raw17 sequence operand (the sites a
135
+ ``[[scene.seq_patch]]`` cites), or None if the bytes don't parse. ``seq`` = the canonical owning attack/sub.
136
+ Pure (no I/O) so it unit-tests without Qt."""
137
+ from ..battle import seqpatch as _sp
138
+ try:
139
+ sites = _sp.constant_sites(raw17_bytes)
140
+ except _sp.SeqPatchError:
141
+ return None
142
+ return [(s.offset, s.value, s.where, s.vmin, s.vmax, s.sub_no) for s in sites]
143
+
144
+
145
+ def donor_scene_facts(raw16: bytes):
146
+ """[(label, value)...] of the forked scene's CURRENT encounter rules (flags decoded to names) + its
147
+ pattern/type/attack counts, for a read-only hint above the Formation form. None if the bytes don't parse.
148
+ Pure (no I/O). The decoded flag names match the `[scene] flags` vocabulary so the user can edit against them."""
149
+ try:
150
+ from ..battle import scene_codec as _sc
151
+ scene = _sc.parse_scene(raw16)
152
+ except Exception: # noqa: BLE001
153
+ return None
154
+ on = [name for name, active in (("back_attack", scene.back_attack), ("preemptive", scene.preemptive),
155
+ ("no_escape", not scene.can_escape), ("no_exp", scene.no_exp)) if active]
156
+ return [("Current flags", ", ".join(on) or "(none)"), ("Patterns", scene.pat_count),
157
+ ("Enemy types", scene.typ_count), ("Attacks", scene.atk_count)]
158
+
159
+
160
+ class BattleDoc(QWidget):
161
+ """Author a battle.toml. ``output`` streams text to the bottom Output dock; ``problems`` posts the Check
162
+ verdict + rows to the Problems dock (the same callbacks :class:`BuildDoc` takes)."""
163
+
164
+ def __init__(self, palette, *, output=None, problems=None, run=None, kit_root=None, on_open=None):
165
+ super().__init__()
166
+ self.pal = palette
167
+ self._output = output
168
+ self._problems = problems
169
+ self._run = run # shell.run_job: streams a CLI job (battle-import) to the Output dock
170
+ self.kit = Path(kit_root) if kit_root else None # `-m ff9mapkit` cwd (so the local pkg shadows)
171
+ self._on_open = on_open # called with the battle.toml path on open/fork -> shell pre-aims Build & Deploy
172
+ self.path = None # Path of the open battle.toml
173
+ self.data = {} # the loaded dict (battlemap / scene / scene.enemy[])
174
+ self._nodes = [] # [(kind, idx)] parallel to the node-list rows
175
+ self._ctx = None # {kind, idx, spec, getters} for the mounted form's Save
176
+ self._install_lists = {} # cache: install-gated BBG / scene lists (read p0data once per session)
177
+ self._build_ui()
178
+
179
+ # ------------------------------------------------------------------ UI
180
+ def _build_ui(self):
181
+ outer = QVBoxLayout(self)
182
+ outer.setContentsMargins(0, 0, 0, 0)
183
+ top = QHBoxLayout()
184
+ self.open_btn = QPushButton("Open battle.toml…")
185
+ self.open_btn.clicked.connect(self.browse)
186
+ top.addWidget(self.open_btn)
187
+ self.fork_btn = QPushButton("Fork battle…")
188
+ self.fork_btn.setToolTip("Fork a real FF9 battle background into a new editable battle.toml, then open it")
189
+ self.fork_btn.clicked.connect(self._fork_dialog)
190
+ self.fork_btn.setEnabled(self._run is not None and self.kit is not None)
191
+ top.addWidget(self.fork_btn)
192
+ self.path_lbl = QLabel("No battle map open — Open a battle.toml, or Fork one from a real FF9 "
193
+ "battle background.")
194
+ self.path_lbl.setStyleSheet(f"color:{self.pal['muted']};")
195
+ top.addWidget(self.path_lbl, 1)
196
+ outer.addLayout(top)
197
+
198
+ split = QSplitter()
199
+ left = QWidget()
200
+ lv = QVBoxLayout(left)
201
+ lv.setContentsMargins(0, 0, 0, 0)
202
+ self.nodes = QListWidget()
203
+ self.nodes.currentRowChanged.connect(self._on_node)
204
+ lv.addWidget(self.nodes, 1)
205
+ self.del_btn = QPushButton("Remove selected")
206
+ self.del_btn.setToolTip("Remove the selected enemy slot / AI phase / patch / party-mod row "
207
+ "(the [battlemap] and [scene] tables can't be removed; applied on Save)")
208
+ self.del_btn.clicked.connect(self._delete_selected)
209
+ self.del_btn.setEnabled(False)
210
+ lv.addWidget(self.del_btn)
211
+ del_sc = QShortcut(QKeySequence(Qt.Key.Key_Delete), self.nodes, activated=self._delete_selected)
212
+ del_sc.setContext(Qt.ShortcutContext.WidgetShortcut) # Delete only when the node list has focus
213
+ self.add_enemy_btn = QPushButton("Add enemy slot")
214
+ self.add_enemy_btn.clicked.connect(self._add_enemy)
215
+ self.add_enemy_btn.setEnabled(False)
216
+ lv.addWidget(self.add_enemy_btn)
217
+ self.add_aiphase_btn = QPushButton("Add AI phase")
218
+ self.add_aiphase_btn.setToolTip("Add a boss-enrage AI branch: switch the enemy's attack when a stat "
219
+ "drops below a fraction (mint-only)")
220
+ self.add_aiphase_btn.clicked.connect(self._add_ai_phase)
221
+ self.add_aiphase_btn.setEnabled(False)
222
+ lv.addWidget(self.add_aiphase_btn)
223
+ self.add_patch_btn = QPushButton("Add AI / sequence patch…")
224
+ self.add_patch_btn.setToolTip("Add a SAME-LENGTH constant patch: rewrite one AI literal (an HP threshold "
225
+ "/ attack index) or one choreography operand (a Wait/Anim/Camera value) in "
226
+ "place — cite an offset with 'Browse sites…' (mint-only)")
227
+ self.add_patch_btn.clicked.connect(self._add_patch)
228
+ self.add_patch_btn.setEnabled(False)
229
+ lv.addWidget(self.add_patch_btn)
230
+ self.add_player_btn = QPushButton("Add party/ability tuning…")
231
+ self.add_player_btn.setToolTip("Tune a PLAYER-side table (stats / abilities / status / leveling) — "
232
+ "mod-global, deployed with this battle")
233
+ self.add_player_btn.clicked.connect(self._add_player)
234
+ self.add_player_btn.setEnabled(False)
235
+ lv.addWidget(self.add_player_btn)
236
+ split.addWidget(left)
237
+
238
+ right = QWidget()
239
+ rv = QVBoxLayout(right)
240
+ self.host_scroll = QScrollArea()
241
+ self.host_scroll.setWidgetResizable(True)
242
+ self.host_scroll.setFrameShape(QFrame.Shape.NoFrame)
243
+ self.host = QWidget()
244
+ self.host_lay = QVBoxLayout(self.host)
245
+ self.host_scroll.setWidget(self.host)
246
+ rv.addWidget(self.host_scroll, 1)
247
+ btns = QHBoxLayout()
248
+ self.save_btn = QPushButton("Save")
249
+ self.save_btn.clicked.connect(self._save)
250
+ self.save_btn.setEnabled(False)
251
+ self.check_btn = QPushButton("Check")
252
+ self.check_btn.clicked.connect(self._check)
253
+ self.check_btn.setEnabled(False)
254
+ btns.addWidget(self.save_btn)
255
+ btns.addWidget(self.check_btn)
256
+ btns.addStretch(1)
257
+ rv.addLayout(btns)
258
+ hint = QLabel("→ deploy on the Build & Deploy tab (open this same battle.toml there).")
259
+ hint.setStyleSheet(f"color:{self.pal['muted']};")
260
+ rv.addWidget(hint)
261
+ split.addWidget(right)
262
+ split.setSizes([200, 520])
263
+ outer.addWidget(split, 1)
264
+ self._placeholder("Open a battle.toml to tune its encounter.")
265
+
266
+ def _clear(self):
267
+ while self.host_lay.count():
268
+ it = self.host_lay.takeAt(0)
269
+ w = it.widget()
270
+ if w is not None:
271
+ w.deleteLater()
272
+
273
+ def _placeholder(self, text):
274
+ self._clear()
275
+ lbl = QLabel(text)
276
+ lbl.setStyleSheet(f"color:{self.pal['muted']};")
277
+ lbl.setWordWrap(True)
278
+ self.host_lay.addWidget(lbl)
279
+ self.host_lay.addStretch(1)
280
+
281
+ def open_scene_id(self):
282
+ """The minted battle-scene id of the open battle.toml (``[battlemap] scene_id``), or None. Lets the
283
+ shell answer 'is the field's encounter scene the one open here?' for the encounter->Battle jump."""
284
+ return ((self.data or {}).get("battlemap") or {}).get("scene_id")
285
+
286
+ def crumb_label(self):
287
+ """A short 'you are editing X' label for the breadcrumb when the Battle tab is active."""
288
+ if self.path is None:
289
+ return "no battle map open"
290
+ sid = self.open_scene_id()
291
+ return Path(self.path).stem + (f" — scene {sid}" if sid is not None else "")
292
+
293
+ # ------------------------------------------------------------------ load
294
+ def browse(self):
295
+ f, _ = QFileDialog.getOpenFileName(self, "Open a battle.toml", "", "TOML (*.toml)")
296
+ if f:
297
+ self.load(f)
298
+
299
+ def load(self, path) -> bool:
300
+ try:
301
+ with open(path, "rb") as fh:
302
+ data = tomllib.load(fh)
303
+ except Exception as e: # noqa: BLE001
304
+ QMessageBox.warning(self, "Couldn't open", f"{Path(path).name}: {e}")
305
+ return False
306
+ if "battlemap" not in data:
307
+ QMessageBox.warning(self, "Not a battle map", f"{Path(path).name} has no [battlemap] table.")
308
+ return False
309
+ shape = self._shape_problem(data) # a hand-corrupted list section would crash node mounting
310
+ if shape:
311
+ QMessageBox.warning(self, "Can't open this battle.toml", f"{Path(path).name}: {shape}")
312
+ return False
313
+ self.path = Path(path)
314
+ self.data = data
315
+ bm = data.get("battlemap", {})
316
+ is_mint = bm.get("scene_id") is not None and bool(bm.get("scene_name"))
317
+ mode = (f"minted scene {bm.get('scene_id')} — [scene] tuning applies" if is_mint
318
+ else "MAP-ONLY override — [scene] tuning (stats/camera/flags) needs a Fork scene to apply")
319
+ self.path_lbl.setText(f"{self.path} · {mode}")
320
+ self.path_lbl.setStyleSheet(f"color:{self.pal['muted' if is_mint else 'warn']};")
321
+ self._ctx = None
322
+ self._rebuild_nodes()
323
+ self.add_enemy_btn.setEnabled(True)
324
+ self.add_aiphase_btn.setEnabled(True)
325
+ self.add_patch_btn.setEnabled(True)
326
+ self.add_player_btn.setEnabled(True)
327
+ self.check_btn.setEnabled(True)
328
+ if self.nodes.count():
329
+ self.nodes.setCurrentRow(0)
330
+ if self._on_open:
331
+ self._on_open(self.path) # pre-aim Build & Deploy at this battle.toml
332
+ return True
333
+
334
+ @staticmethod
335
+ def _shape_problem(data):
336
+ """A message if a known list section isn't a list of tables (so a hand-corrupted battle.toml is rejected
337
+ cleanly instead of crashing _rebuild_nodes / form mounting), else None. battle-import output is well-formed;
338
+ this only catches hand edits. The accessors below then trust the shape (they return the REAL list to mutate)."""
339
+ scene = data.get("scene")
340
+ if scene is not None and not isinstance(scene, dict):
341
+ return f"[scene] must be a table (got {type(scene).__name__})"
342
+ scene = scene or {}
343
+ pairs = [(f"scene.{k}", scene.get(k)) for k in ("enemy", "ai_phase", "ai_patch", "seq_patch")]
344
+ pairs += [(k, data.get(k)) for k in bf.PLAYER_SPECS]
345
+ for name, v in pairs:
346
+ if v is not None and (not isinstance(v, list) or not all(isinstance(e, dict) for e in v)):
347
+ return f"[[{name}]] must be a list of tables (got {type(v).__name__})"
348
+ return None
349
+
350
+ def _enemies(self):
351
+ return (self.data.get("scene") or {}).get("enemy", []) or []
352
+
353
+ def _ai_phases(self):
354
+ return (self.data.get("scene") or {}).get("ai_phase", []) or []
355
+
356
+ def _ai_patches(self):
357
+ return (self.data.get("scene") or {}).get("ai_patch", []) or []
358
+
359
+ def _seq_patches(self):
360
+ return (self.data.get("scene") or {}).get("seq_patch", []) or []
361
+
362
+ def _add_header(self, text):
363
+ """A non-selectable separator row in the node list (a tree-section header)."""
364
+ item = QListWidgetItem(text)
365
+ item.setFlags(Qt.ItemFlag.NoItemFlags)
366
+ self.nodes.addItem(item)
367
+ self._nodes.append((None, None)) # keep _nodes parallel to the list rows
368
+
369
+ def _player_rows(self):
370
+ """[(table_key, index, entry)] for every player/ability tuning entry the battle.toml carries."""
371
+ return [(key, i, e) for key in bf.PLAYER_SPECS for i, e in enumerate(self.data.get(key) or [])]
372
+
373
+ def _rebuild_nodes(self):
374
+ self.nodes.blockSignals(True)
375
+ self.nodes.clear()
376
+ self._nodes = []
377
+ self.nodes.addItem("Map · [battlemap]")
378
+ self._nodes.append((_MAP, None))
379
+ self.nodes.addItem("Formation · [scene]")
380
+ self._nodes.append((_SCENE, None))
381
+ for i, e in enumerate(self._enemies()):
382
+ self.nodes.addItem(f"Enemy slot {e.get('slot', i)}")
383
+ self._nodes.append((_ENEMY, i))
384
+ phases = self._ai_phases()
385
+ if phases: # boss-enrage AI branches (per-scene, mint-only)
386
+ self._add_header("— AI phases (boss enrage) —")
387
+ for i, p in enumerate(phases):
388
+ self.nodes.addItem(f"AI phase · entry {p.get('entry', '?')} "
389
+ f"{p.get('stat', 'hp')}<{p.get('below', 0.5)}")
390
+ self._nodes.append((_AIPHASE, i))
391
+ ai_patches = self._ai_patches()
392
+ if ai_patches: # same-length AI constant patches (cite-an-offset)
393
+ self._add_header("— AI constant patches —")
394
+ for i, p in enumerate(ai_patches):
395
+ self.nodes.addItem(f"AI patch · @{p.get('at', '?')} {p.get('old', '?')}→{p.get('new', '?')}")
396
+ self._nodes.append((_AIPATCH, i))
397
+ seq_patches = self._seq_patches()
398
+ if seq_patches: # same-length raw17 choreography operand patches
399
+ self._add_header("— Sequence patches (choreography) —")
400
+ for i, p in enumerate(seq_patches):
401
+ self.nodes.addItem(f"Seq patch · @{p.get('at', '?')} {p.get('old', '?')}→{p.get('new', '?')}")
402
+ self._nodes.append((_SEQPATCH, i))
403
+ player = self._player_rows()
404
+ if player: # the mod-global PLAYER side, under its own header
405
+ self._add_header("— Party & abilities (mod-global) —")
406
+ for key, i, e in player:
407
+ self.nodes.addItem(f"{bf.PLAYER_LABEL[key]} · {e.get(bf.PLAYER_SELECTOR[key], i)}")
408
+ self._nodes.append((key, i))
409
+ self.nodes.blockSignals(False)
410
+
411
+ # ------------------------------------------------------------------ node -> form
412
+ @staticmethod
413
+ def _deletable(kind):
414
+ """A list-row node (enemy / ai_phase / ai_patch / seq_patch / a player-table row) can be removed; the
415
+ [battlemap] / [scene] singletons + section headers cannot."""
416
+ return kind in (_ENEMY, _AIPHASE, _AIPATCH, _SEQPATCH) or kind in bf.PLAYER_SPECS
417
+
418
+ def _on_node(self, row):
419
+ if not (0 <= row < len(self._nodes)):
420
+ self.del_btn.setEnabled(False)
421
+ return
422
+ self._commit_active() # fold any pending edit before switching
423
+ kind, idx = self._nodes[row]
424
+ self.del_btn.setEnabled(self._deletable(kind)) # Remove targets list rows, not Map/Formation/headers
425
+ if kind == _MAP:
426
+ self._mount(_MAP, None, bf.BATTLEMAP_SPEC, self.data.setdefault("battlemap", {}))
427
+ elif kind == _SCENE:
428
+ self._mount(_SCENE, None, bf.SCENE_SPEC, self.data.setdefault("scene", {}))
429
+ elif kind == _ENEMY:
430
+ if 0 <= idx < len(self._enemies()):
431
+ self._mount(_ENEMY, idx, bf.ENEMY_SPEC, self._enemies()[idx])
432
+ elif kind == _AIPHASE:
433
+ if 0 <= idx < len(self._ai_phases()):
434
+ self._mount(_AIPHASE, idx, bf.AI_PHASE_SPEC, self._ai_phases()[idx])
435
+ elif kind == _AIPATCH:
436
+ if 0 <= idx < len(self._ai_patches()):
437
+ self._mount(_AIPATCH, idx, bf.AI_PATCH_SPEC, self._ai_patches()[idx])
438
+ elif kind == _SEQPATCH:
439
+ if 0 <= idx < len(self._seq_patches()):
440
+ self._mount(_SEQPATCH, idx, bf.SEQ_PATCH_SPEC, self._seq_patches()[idx])
441
+ elif kind in bf.PLAYER_SPECS: # a player/ability tuning row
442
+ lst = self.data.get(kind) or []
443
+ if 0 <= idx < len(lst):
444
+ self._mount(kind, idx, bf.PLAYER_SPECS[kind], lst[idx])
445
+ # kind is None -> a separator header: nothing to mount
446
+
447
+ def _mount(self, kind, idx, spec, entity):
448
+ self._clear()
449
+ if kind == _ENEMY:
450
+ base = self._donor_baseline(entity) # read-only "what you're tuning from" panel
451
+ if base is not None:
452
+ self.host_lay.addWidget(self._baseline_panel(*base))
453
+ elif kind == _SCENE:
454
+ facts = self._donor_scene_facts() # the donor's current rules + counts
455
+ if facts is not None:
456
+ self.host_lay.addWidget(self._facts_panel("Donor scene (the fork you're tuning)", facts))
457
+ elif kind == _AIPHASE:
458
+ ai = self._donor_ai_facts() # the entry/tag + attack indices the form needs
459
+ if ai is not None:
460
+ self.host_lay.addWidget(self._ai_facts_panel(*ai))
461
+ elif kind in (_AIPATCH, _SEQPATCH): # a "Browse sites…" picker fills the offset + guard
462
+ self.host_lay.addWidget(self._sites_panel(kind))
463
+ form, getters = build_form(spec, forms.entity_to_values(spec, entity), self.pal)
464
+ self.host_lay.addWidget(form)
465
+ self.host_lay.addStretch(1)
466
+ self._ctx = {"kind": kind, "idx": idx, "spec": spec, "getters": getters}
467
+ self.save_btn.setEnabled(True)
468
+
469
+ # ------------------------------------------------------------------ donor baseline (read-only)
470
+ def _donor_scene_path(self):
471
+ """The forked scene's raw16 (the donor enemy stats), or None for an override/repoint battle.toml that
472
+ has no forked ``scene/`` dir. Mirrors ``BattleProject.scene_dir`` = the toml's folder / ``scene``."""
473
+ if not self.path:
474
+ return None
475
+ return self.path.parent / "scene" / "dbfile0000.raw16.bytes"
476
+
477
+ def _donor_baseline(self, enemy):
478
+ p = self._donor_scene_path()
479
+ if not p or not p.is_file():
480
+ return None
481
+ try:
482
+ return donor_baseline(p.read_bytes(), enemy)
483
+ except OSError:
484
+ return None
485
+
486
+ def _donor_scene_facts(self):
487
+ p = self._donor_scene_path()
488
+ if not p or not p.is_file():
489
+ return None
490
+ try:
491
+ return donor_scene_facts(p.read_bytes())
492
+ except OSError:
493
+ return None
494
+
495
+ def _donor_ai_facts(self):
496
+ p = self._donor_scene_path() # scene/dbfile0000.raw16.bytes -> the scene/ dir
497
+ if not p or not p.is_file():
498
+ return None
499
+ eb = p.parent / "eb" / "us.eb.bytes"
500
+ if not eb.is_file():
501
+ return None
502
+ mes = p.parent / "mes" / "us.mes"
503
+ try:
504
+ return donor_ai_facts(eb.read_bytes(), p.read_bytes(), mes.read_bytes() if mes.is_file() else None)
505
+ except OSError:
506
+ return None
507
+
508
+ # ------------------------------------------------------------------ same-length patch sites (read-only picker)
509
+ def _donor_patch_blob(self, kind):
510
+ """The forked-scene source a same-length patch ADDRESSES: the AI ``.eb`` (ai_patch) / the raw17
511
+ (seq_patch). None for a non-mint override (no forked ``scene/`` dir)."""
512
+ sp = self._donor_scene_path() # scene/dbfile0000.raw16.bytes -> the scene/ dir
513
+ if not sp:
514
+ return None
515
+ sd = sp.parent
516
+ return sd / "eb" / "us.eb.bytes" if kind == _AIPATCH else sd / "btlseq.raw17.bytes"
517
+
518
+ def _donor_patch_sites(self, kind):
519
+ """``[(offset, value, where, lo, hi[, seq])]`` the patch form can cite, or None (no forked scene /
520
+ unparsable bytes). Wraps the pure :func:`ai_patch_sites` / :func:`seq_patch_sites` with the file read."""
521
+ p = self._donor_patch_blob(kind)
522
+ if not p or not p.is_file():
523
+ return None
524
+ try:
525
+ blob = p.read_bytes()
526
+ except OSError:
527
+ return None
528
+ return ai_patch_sites(blob) if kind == _AIPATCH else seq_patch_sites(blob)
529
+
530
+ def _sites_panel(self, kind):
531
+ """A read-only header above the patch form: how many sites the fork exposes + a 'Browse sites…' button
532
+ that fills Offset + Current value (so the user never needs `battle-ai`/`battle-seq --sites`)."""
533
+ which = "AI constants" if kind == _AIPATCH else "sequence operands"
534
+ box = QGroupBox("Donor sites — pick an offset to patch")
535
+ v = QVBoxLayout(box)
536
+ v.setContentsMargins(8, 4, 8, 4)
537
+ v.setSpacing(4)
538
+ sites = self._donor_patch_sites(kind)
539
+ if sites is None:
540
+ note = QLabel("No forked scene to read — a same-length patch only applies to a MINTED fork (re-fork "
541
+ "WITH a Fork scene). You can still type an offset by hand, but it won't take effect here.")
542
+ note.setWordWrap(True)
543
+ note.setStyleSheet(f"color:{self.pal['warn']};")
544
+ v.addWidget(note)
545
+ return box
546
+ row = QHBoxLayout()
547
+ lbl = QLabel(f"{len(sites)} patchable {which} in this fork.")
548
+ lbl.setStyleSheet(f"color:{self.pal['muted']};")
549
+ row.addWidget(lbl, 1)
550
+ btn = QPushButton("Browse sites…")
551
+ btn.setEnabled(bool(sites))
552
+ btn.clicked.connect(lambda: self._browse_sites(kind))
553
+ row.addWidget(btn)
554
+ v.addLayout(row)
555
+ return box
556
+
557
+ def _browse_sites(self, kind):
558
+ """Pick a donor site → fill the current patch form's Offset (``at``) + Current value (``old``) guard
559
+ (and, for seq, the owning ``seq``). Commits the user's typed ``new`` first, then remounts the form."""
560
+ if not self._ctx or self._ctx["kind"] != kind:
561
+ return
562
+ sites = self._donor_patch_sites(kind)
563
+ if not sites:
564
+ return
565
+ rows, by_disp = [], {}
566
+ for s in sites:
567
+ offset, value, where, lo, hi = s[0], s[1], s[2], s[3], s[4]
568
+ disp = f"@{offset} · now {value} · {where} · {lo}–{hi}"
569
+ rows.append(disp)
570
+ by_disp[disp] = s
571
+ chosen = self._choose("Patchable sites", rows)
572
+ if not chosen:
573
+ return
574
+ s = by_disp[chosen]
575
+ idx = self._ctx["idx"]
576
+ if not self._commit_active(): # fold the user's typed `new` first -- but if it's
577
+ self._post(["Fix the highlighted value before browsing sites."], [], "Browse sites")
578
+ return # invalid, bail (don't silently revert it to default)
579
+ tgt = self._target(kind, idx)
580
+ if tgt is None:
581
+ return
582
+ tgt["at"], tgt["old"] = int(s[0]), int(s[1])
583
+ if kind == _SEQPATCH and len(s) > 5: # default the owning-attack cross-check
584
+ tgt["seq"] = int(s[5])
585
+ self._rebuild_nodes() # the node label shows at / old
586
+ self.nodes.blockSignals(True) # restore the highlight WITHOUT re-committing the
587
+ self._select_node(kind, idx) # (now stale) old form's widgets over our new values
588
+ self.nodes.blockSignals(False)
589
+ spec = bf.AI_PATCH_SPEC if kind == _AIPATCH else bf.SEQ_PATCH_SPEC
590
+ self._mount(kind, idx, spec, tgt) # remount fresh so the form shows the filled offset/old
591
+
592
+ def _ai_facts_panel(self, attacks, ai_funcs):
593
+ import html
594
+ box = QGroupBox("Donor AI (this fork) — indices for the form below")
595
+ v = QVBoxLayout(box)
596
+ v.setContentsMargins(8, 4, 8, 4)
597
+ v.setSpacing(3)
598
+ enrage = [f"entry {e}, function {t}" for (e, _ty, t, _r, n) in ai_funcs if n == 1] # exactly one Attack
599
+ e_txt = " · ".join(enrage) if enrage else "none — no AI function has exactly one Attack (use ai_insert)"
600
+ e_lbl = QLabel(f"<b>Enrage-able</b> → set <b>Enemy AI entry</b> / <b>AI function</b> to: {html.escape(e_txt)}")
601
+ e_lbl.setWordWrap(True)
602
+ e_lbl.setStyleSheet(f"color:{self.pal['muted']};")
603
+ v.addWidget(e_lbl)
604
+ if attacks:
605
+ atk = " · ".join(f"{i}={html.escape(str(nm))}" for i, nm in attacks)
606
+ a_lbl = QLabel(f"<b>Attacks</b> (then / else): {atk}")
607
+ a_lbl.setWordWrap(True)
608
+ a_lbl.setStyleSheet(f"color:{self.pal['muted']};")
609
+ v.addWidget(a_lbl)
610
+ other = [f"entry {e} fn {t} ({n} atk)" for (e, _ty, t, _r, n) in ai_funcs if n != 1]
611
+ if other:
612
+ o_lbl = QLabel(f"other AI funcs: {html.escape(' · '.join(other))}")
613
+ o_lbl.setWordWrap(True)
614
+ o_lbl.setStyleSheet(f"color:{self.pal['muted']};font-size:11px;")
615
+ v.addWidget(o_lbl)
616
+ return box
617
+
618
+ def _baseline_panel(self, type_no, pairs):
619
+ return self._facts_panel(f"Donor baseline — enemy type {type_no} (the forked stats you're tuning from)", pairs)
620
+
621
+ def _facts_panel(self, title, pairs, per_row=6):
622
+ """A read-only grid of (label, value) facts in a titled box (the donor enemy baseline / scene rules)."""
623
+ box = QGroupBox(title)
624
+ grid = QGridLayout(box)
625
+ grid.setContentsMargins(8, 4, 8, 4)
626
+ grid.setHorizontalSpacing(16)
627
+ grid.setVerticalSpacing(2)
628
+ for i, (label, val) in enumerate(pairs):
629
+ r, c = divmod(i, per_row)
630
+ cell = QLabel(f"{label} <b>{val}</b>")
631
+ cell.setStyleSheet(f"color:{self.pal['muted']};")
632
+ grid.addWidget(cell, r, c)
633
+ return box
634
+
635
+ def _target(self, kind, idx):
636
+ """The dict a (kind, idx) node edits, or None if the index is out of range (a stale _ctx after its row
637
+ was removed) -- so the shared commit primitive can no-op instead of raising."""
638
+ if kind == _MAP:
639
+ return self.data.setdefault("battlemap", {})
640
+ if kind == _SCENE:
641
+ return self.data.setdefault("scene", {})
642
+ getter = {_ENEMY: self._enemies, _AIPHASE: self._ai_phases,
643
+ _AIPATCH: self._ai_patches, _SEQPATCH: self._seq_patches}.get(kind)
644
+ lst = getter() if getter else (self.data.get(kind) if kind in bf.PLAYER_SPECS else None)
645
+ return lst[idx] if isinstance(lst, list) and 0 <= idx < len(lst) else None
646
+
647
+ def _fold(self, ctx) -> bool:
648
+ """Apply the form's values to its target dict in place (pop the spec keys, keep any non-spec keys --
649
+ e.g. the [scene] form must not drop the enemy list). Returns False on an invalid value / stale target."""
650
+ try:
651
+ entity = forms.build_entity(ctx["spec"], read(ctx["getters"]))
652
+ except ValueError:
653
+ return False
654
+ tgt = self._target(ctx["kind"], ctx["idx"])
655
+ if tgt is None: # the row this form pointed at is gone -> nothing to commit
656
+ return False
657
+ for f in ctx["spec"]:
658
+ tgt.pop(f.key, None)
659
+ tgt.update(entity)
660
+ return True
661
+
662
+ def _commit_active(self) -> bool:
663
+ return self._fold(self._ctx) if self._ctx else True
664
+
665
+ # ------------------------------------------------------------------ save / add / check
666
+ def _save(self):
667
+ if not self._ctx:
668
+ return
669
+ if not self._fold(self._ctx):
670
+ self._post(["Invalid value — not saved (fix the highlighted field)."], [], "Save")
671
+ return
672
+ if not self._write():
673
+ return
674
+ self._rebuild_nodes() # a slot's number may have changed
675
+ if self._ctx: # re-highlight the saved row + re-arm Remove (clear()
676
+ self._select_node(self._ctx["kind"], self._ctx["idx"]) # left currentRow at -1 with del_btn stale)
677
+ self._post([], [], "Save", clean=f"Saved {self.path.name}")
678
+
679
+ def _add_enemy(self):
680
+ if not self.data:
681
+ return
682
+ self._commit_active()
683
+ enemies = self.data.setdefault("scene", {}).setdefault("enemy", [])
684
+ used = {e.get("slot") for e in enemies}
685
+ enemies.append({"slot": next((s for s in range(4) if s not in used), len(enemies))})
686
+ self._rebuild_nodes()
687
+ # land on the new enemy's form (the last ENEMY row, before any player header/rows)
688
+ self._select_node(_ENEMY, len(enemies) - 1)
689
+
690
+ def _add_ai_phase(self):
691
+ if not self.data:
692
+ return
693
+ self._commit_active()
694
+ phases = self.data.setdefault("scene", {}).setdefault("ai_phase", [])
695
+ phases.append({"entry": 1, "tag": 5, "stat": "hp", "below": 0.5, "then": 1, "else": 0})
696
+ self._rebuild_nodes()
697
+ self._select_node(_AIPHASE, len(phases) - 1)
698
+
699
+ def _add_patch(self):
700
+ if not self.data:
701
+ return
702
+ self._commit_active()
703
+ kind = self._pick_patch_kind()
704
+ if not kind:
705
+ return
706
+ lst = self.data.setdefault("scene", {}).setdefault(kind, [])
707
+ lst.append({"at": 0, "old": 0, "new": 0}) # Browse sites… fills at/old against the donor
708
+ self._rebuild_nodes()
709
+ self._select_node(kind, len(lst) - 1) # land on the new patch's form (+ its Browse panel)
710
+
711
+ def _pick_patch_kind(self):
712
+ """A small dialog to choose AI-constant vs sequence patch. Returns the kind key, or None."""
713
+ dlg = QDialog(self)
714
+ dlg.setWindowTitle("Add a same-length patch")
715
+ lay = QVBoxLayout(dlg)
716
+ lbl = QLabel("Which same-length patch? Both cite a byte offset (use 'Browse sites…' on the form) and "
717
+ "guard on the value there now — mint-only, applied to the forked scene.")
718
+ lbl.setWordWrap(True)
719
+ lay.addWidget(lbl)
720
+ combo = QComboBox()
721
+ combo.addItem("AI constant · [[scene.ai_patch]] (HP threshold / attack index / Wait)", _AIPATCH)
722
+ combo.addItem("Choreography · [[scene.seq_patch]] (a Wait / Anim / Camera operand)", _SEQPATCH)
723
+ lay.addWidget(combo)
724
+ bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
725
+ bb.accepted.connect(dlg.accept)
726
+ bb.rejected.connect(dlg.reject)
727
+ lay.addWidget(bb)
728
+ if dlg.exec() != QDialog.DialogCode.Accepted:
729
+ return None
730
+ return combo.currentData()
731
+
732
+ def _add_player(self):
733
+ if not self.data:
734
+ return
735
+ self._commit_active()
736
+ key = self._pick_player_table()
737
+ if not key:
738
+ return
739
+ self.data.setdefault(key, []).append(dict(bf.PLAYER_DEFAULT[key]))
740
+ self._rebuild_nodes()
741
+ self._select_node(key, len(self.data[key]) - 1) # land on the new row's form
742
+
743
+ def _select_node(self, kind, idx):
744
+ for r, node in enumerate(self._nodes):
745
+ if node == (kind, idx):
746
+ self.nodes.setCurrentRow(r)
747
+ return
748
+
749
+ # ------------------------------------------------------------------ delete a list row
750
+ def _delete_selected(self):
751
+ """Remove the selected list row (enemy / ai_phase / ai_patch / seq_patch / a player-table row) after a
752
+ confirm. In-memory like the Add actions — persisted on Save. Map / Formation / headers are not removable."""
753
+ row = self.nodes.currentRow()
754
+ if not (0 <= row < len(self._nodes)):
755
+ return
756
+ kind, idx = self._nodes[row]
757
+ if not self._deletable(kind):
758
+ return
759
+ if not self._confirm_delete(self.nodes.item(row).text()):
760
+ return
761
+ if not self._delete_node(kind, idx): # bad index (parallel lists drifted) -> keep the form
762
+ return
763
+ self._ctx = None # success: the mounted form's row is gone -> don't commit it
764
+ self._rebuild_nodes()
765
+ siblings = [r for r, (k, _i) in enumerate(self._nodes) if k == kind]
766
+ target = siblings[min(idx, len(siblings) - 1)] if siblings else 0 # a remaining sibling, else Map
767
+ if 0 <= target < self.nodes.count():
768
+ self.nodes.setCurrentRow(target) # -> _on_node mounts it (or Map) + re-arms del_btn
769
+
770
+ def _delete_node(self, kind, idx) -> bool:
771
+ """Drop ``(kind, idx)`` from its backing list, popping an emptied container key so the saved TOML stays
772
+ clean (no ``ai_phase = []``). Returns False on a bad kind/index."""
773
+ if kind == _ENEMY:
774
+ lst, scene_key = self._enemies(), "enemy"
775
+ elif kind == _AIPHASE:
776
+ lst, scene_key = self._ai_phases(), "ai_phase"
777
+ elif kind == _AIPATCH:
778
+ lst, scene_key = self._ai_patches(), "ai_patch"
779
+ elif kind == _SEQPATCH:
780
+ lst, scene_key = self._seq_patches(), "seq_patch"
781
+ elif kind in bf.PLAYER_SPECS:
782
+ lst, scene_key = (self.data.get(kind) or []), None
783
+ else:
784
+ return False
785
+ if not (0 <= idx < len(lst)):
786
+ return False
787
+ del lst[idx]
788
+ if scene_key is not None: # a [scene] sub-table: pop it from scene when emptied
789
+ scene = self.data.get("scene") or {}
790
+ if scene_key in scene and not scene[scene_key]:
791
+ scene.pop(scene_key, None)
792
+ if isinstance(scene, dict) and not scene: # the last sub-table gone + no scalars -> drop empty [scene]
793
+ self.data.pop("scene", None)
794
+ elif kind in self.data and not self.data[kind]: # a top-level player table: pop the [[<kind>]] array
795
+ self.data.pop(kind, None)
796
+ return True
797
+
798
+ def _confirm_delete(self, label) -> bool:
799
+ r = QMessageBox.question(self, "Remove", f"Remove “{label.strip()}”?\n\n(Applied when you Save.)",
800
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
801
+ QMessageBox.StandardButton.No)
802
+ return r == QMessageBox.StandardButton.Yes
803
+
804
+ def _pick_player_table(self):
805
+ """A small dialog to pick which player/ability table to add a row to. Returns its key, or None."""
806
+ dlg = QDialog(self)
807
+ dlg.setWindowTitle("Add party / ability tuning")
808
+ lay = QVBoxLayout(dlg)
809
+ lbl = QLabel("Tune which player-side table? It's mod-GLOBAL — deployed with this battle and applied "
810
+ "to the whole game.")
811
+ lbl.setWordWrap(True)
812
+ lay.addWidget(lbl)
813
+ combo = QComboBox()
814
+ for key, label, *_ in bf.PLAYER_TABLES:
815
+ combo.addItem(label, key)
816
+ lay.addWidget(combo)
817
+ bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
818
+ bb.accepted.connect(dlg.accept)
819
+ bb.rejected.connect(dlg.reject)
820
+ lay.addWidget(bb)
821
+ if dlg.exec() != QDialog.DialogCode.Accepted:
822
+ return None
823
+ return combo.currentData()
824
+
825
+ def _write(self) -> bool:
826
+ try: # a battle.toml: [scene] is a big FORMATION table, NOT
827
+ text = _model.dumps(self.data, inline_table_keys=frozenset(), # the field.toml inline Blender-ref --
828
+ root_order=("battlemap", "scene")) # so emit real [scene]/[[scene.enemy]]
829
+ self.path.write_text(text, encoding="utf-8", newline="\n") # sections (+ lead with the map id)
830
+ return True
831
+ except Exception as e: # noqa: BLE001
832
+ self._post([f"Save failed: {e}"], [], "Save")
833
+ return False
834
+
835
+ def _check(self):
836
+ if not self.path:
837
+ return
838
+ self._commit_active() # validate WHAT'S SHOWN; Check does NOT persist (Save is
839
+ errs = [] # the only writer -- so "applied on Save" stays true)
840
+ try:
841
+ from ..battle.build import BattleProject, validate_battle
842
+ errs = list(validate_battle(BattleProject(self.data, self.path.parent))) # the in-memory dict
843
+ except Exception as e: # noqa: BLE001
844
+ errs = [f"{type(e).__name__}: {e}"]
845
+ self._post(errs, [], f"Check {self.path.name}", clean=f"{self.path.name} — no problems")
846
+
847
+ def _post(self, errs, warns, subject, clean=None):
848
+ """Route a Check/Save result to the Problems dock (verdict + rows), or the Output console if undocked."""
849
+ errs, warns = list(errs), list(warns)
850
+ if self._problems is not None:
851
+ v = fb.classify(errs, warns, subject=subject, clean_headline=clean or f"{subject} — OK")
852
+ self._problems(v, fb.problems(errs, warns))
853
+ elif self._output is not None:
854
+ body = "\n".join(errs + warns)
855
+ self._output(f"{clean or subject}{(chr(10) + body) if body else ''}\n")
856
+
857
+ # ------------------------------------------------------------------ fork a real battle background
858
+ def _fork_argv(self, bbg, out, fork_scene=None):
859
+ """The ``ff9mapkit battle-import`` argv that forks a real BBG's geometry into an editable battle.toml."""
860
+ a = [sys.executable, "-m", "ff9mapkit", "battle-import", str(bbg), "--out", str(out)]
861
+ if fork_scene:
862
+ a += ["--fork-scene", str(fork_scene)]
863
+ return a
864
+
865
+ def _run_fork(self, bbg, out, fork_scene=None):
866
+ """Shell out battle-import (streams to the Output dock) and AUTO-OPEN the result on success."""
867
+ if not self._run or not self.kit:
868
+ return
869
+ Path(out).mkdir(parents=True, exist_ok=True)
870
+ self._run(self._fork_argv(bbg, out, fork_scene), cwd=self.kit, subject=f"Fork battle {bbg}",
871
+ ok_headline=f"Forked {bbg} → {out}", ok_next="Opening the new battle.toml…",
872
+ fail_hint="Forking a battle needs UnityPy + your FF9 install (like forking a field).",
873
+ on_finished=lambda code: self._after_fork(code, out))
874
+
875
+ def _after_fork(self, code, out):
876
+ """battle-import done -> open the battle.toml it wrote (only on a clean exit)."""
877
+ toml = Path(out) / "battle.toml"
878
+ if code == 0 and toml.is_file():
879
+ self.load(str(toml))
880
+
881
+ def _pick_out(self, line_edit):
882
+ d = QFileDialog.getExistingDirectory(self, "Folder to write the battle into")
883
+ if d:
884
+ line_edit.setText(d)
885
+
886
+ @staticmethod
887
+ def _browse_row(line_edit, on_browse):
888
+ """A line edit + a 'Browse…' button in one row (for the install-gated BBG / scene pickers)."""
889
+ row = QWidget()
890
+ h = QHBoxLayout(row)
891
+ h.setContentsMargins(0, 0, 0, 0)
892
+ h.addWidget(line_edit, 1)
893
+ b = QPushButton("Browse…")
894
+ b.clicked.connect(on_browse)
895
+ h.addWidget(b)
896
+ return row
897
+
898
+ def _pick_install_list(self, title, loader, target, cache_key):
899
+ """Browse an INSTALL-gated list (BBGs / battle scenes, read from p0data via UnityPy) into ``target``.
900
+ Reads the install on first use (a brief wait), cached per session; a clean warning if the install /
901
+ UnityPy is absent (so a no-install workstation degrades gracefully instead of tracing back)."""
902
+ from PySide6.QtCore import Qt
903
+ from PySide6.QtWidgets import QApplication
904
+ rows = self._install_lists.get(cache_key)
905
+ if rows is None:
906
+ QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
907
+ try:
908
+ rows = list(loader())
909
+ except Exception as e: # noqa: BLE001 -- no install / no UnityPy / read error
910
+ QApplication.restoreOverrideCursor()
911
+ return QMessageBox.warning(self, title, f"Couldn't read {title.lower()} — forking a battle "
912
+ f"needs UnityPy + your FF9 install.\n\n{type(e).__name__}: {e}")
913
+ finally:
914
+ QApplication.restoreOverrideCursor()
915
+ self._install_lists[cache_key] = rows
916
+ if not rows:
917
+ return QMessageBox.information(self, title, f"No {title.lower()} found in your install.")
918
+ name = self._choose(title, rows)
919
+ if name:
920
+ target.setText(name)
921
+
922
+ def _choose(self, title, rows):
923
+ """A simple searchable single-pick list dialog over ``rows`` (names); the chosen name, or None."""
924
+ dlg = QDialog(self)
925
+ dlg.setWindowTitle(title)
926
+ dlg.resize(360, 460)
927
+ lay = QVBoxLayout(dlg)
928
+ q = QLineEdit()
929
+ q.setPlaceholderText("Filter…")
930
+ lay.addWidget(q)
931
+ lst = QListWidget()
932
+ lst.addItems(rows)
933
+ lay.addWidget(lst, 1)
934
+ q.textChanged.connect(lambda t: (lst.clear(), lst.addItems([r for r in rows if t.lower() in r.lower()])))
935
+ lst.itemDoubleClicked.connect(lambda _i: dlg.accept())
936
+ bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
937
+ bb.accepted.connect(dlg.accept)
938
+ bb.rejected.connect(dlg.reject)
939
+ lay.addWidget(bb)
940
+ if dlg.exec() != QDialog.DialogCode.Accepted:
941
+ return None
942
+ it = lst.currentItem()
943
+ return it.text() if it else None
944
+
945
+ def _fork_dialog(self):
946
+ if not self._run or not self.kit:
947
+ return
948
+ from ..battle import extract as _ex
949
+ dlg = QDialog(self)
950
+ dlg.setWindowTitle("Fork a battle background")
951
+ form = QFormLayout(dlg)
952
+ bbg = QLineEdit()
953
+ bbg.setPlaceholderText("BBG_B013 (Browse… to pick from your install)")
954
+ form.addRow("Background (BBG)", self._browse_row(
955
+ bbg, lambda: self._pick_install_list("Battle backgrounds", _ex.list_battle_maps, bbg, "bbg")))
956
+ donor = QLineEdit()
957
+ donor.setPlaceholderText("optional — e.g. EF_R007 (mints a brand-new, separately-triggerable scene)")
958
+ form.addRow("Fork scene", self._browse_row(
959
+ donor, lambda: self._pick_install_list("Battle scenes", _ex.list_battle_scenes, donor, "scene")))
960
+ outrow = QWidget()
961
+ oh = QHBoxLayout(outrow)
962
+ oh.setContentsMargins(0, 0, 0, 0)
963
+ out = QLineEdit(str(Path.home() / "ff9field" / "fight"))
964
+ browse = QPushButton("Browse…")
965
+ browse.clicked.connect(lambda: self._pick_out(out))
966
+ oh.addWidget(out, 1)
967
+ oh.addWidget(browse)
968
+ form.addRow("Write to", outrow)
969
+ hint = QLabel("Forks the real BBG's geometry into an editable battle.toml (needs UnityPy + your FF9 "
970
+ "install). A bare BBG OVERRIDES that real map; add a Fork scene to mint a new one. The "
971
+ "result opens here when it's done.")
972
+ hint.setWordWrap(True)
973
+ hint.setStyleSheet(f"color:{self.pal['muted']};")
974
+ form.addRow(hint)
975
+ bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
976
+ bb.accepted.connect(dlg.accept)
977
+ bb.rejected.connect(dlg.reject)
978
+ form.addRow(bb)
979
+ if dlg.exec() != QDialog.DialogCode.Accepted:
980
+ return
981
+ b, o = bbg.text().strip(), out.text().strip()
982
+ if not b or not o:
983
+ QMessageBox.warning(self, "Fork battle", "Enter a BBG name and an output folder.")
984
+ return
985
+ self._run_fork(b, o, donor.text().strip() or None)