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,950 @@
1
+ """The FF9 Map Kit field-logic editor (Tkinter UI).
2
+
3
+ A friendly form-based front-end so authors edit a field's LOGIC (dialogue, events, story flags,
4
+ encounters, music, cutscenes) without hand-writing TOML. Spatial placement (camera / walkmesh /
5
+ positions / zones) stays in Blender; this edits the ``<field>.field.toml`` and never touches a sibling
6
+ ``<field>.scene.toml``.
7
+
8
+ All the non-UI logic lives in the tk-free :mod:`.model` (load/save/serialize) and :mod:`.forms`
9
+ (specs/parsers), which are unit-tested; this file is the thin Tk wiring over them. Launch with
10
+ ``ff9mapkit edit [field.toml]``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import copy
16
+ import queue
17
+ import subprocess
18
+ import sys
19
+ import threading
20
+ import traceback
21
+ from pathlib import Path
22
+
23
+ import tkinter as tk
24
+ from tkinter import filedialog, messagebox, ttk
25
+
26
+ from . import forms, picker
27
+ from .model import FieldDoc, protected_reason
28
+ from .theme import apply_theme
29
+
30
+ # single-table logic sections and their specs; the rest are arrays-of-tables.
31
+ SINGLE_SPECS = {"field": forms.FIELD_SPEC, "encounter": forms.ENCOUNTER_SPEC,
32
+ "music": forms.MUSIC_SPEC, "dialogue": forms.DIALOGUE_SPEC}
33
+ LIST_SPECS = {"npc": forms.NPC_SPEC, "gateway": forms.GATEWAY_SPEC, "event": forms.EVENT_SPEC,
34
+ "marker": forms.MARKER_SPEC}
35
+ LIST_LABELS = {"npc": "NPCs", "gateway": "Gateways", "event": "Events", "marker": "Markers"}
36
+ OPTIONAL_SINGLES = ("encounter", "music", "cutscene", "dialogue") # add/remove-able
37
+
38
+
39
+ def _find_tool(name):
40
+ """Locate a dev tool script (deploy_field.py / revert_deploy.py) relative to the repo, or None.
41
+ Only present in the dev checkout; a distributed kit just won't show the in-game test buttons."""
42
+ here = Path(__file__).resolve()
43
+ for base in here.parents:
44
+ for cand in (base / "tools" / name, base / "tools" / "scroll_out" / name):
45
+ if cand.is_file():
46
+ return cand
47
+ return None
48
+
49
+
50
+ def _find_app(name):
51
+ """Locate a GUI app (e.g. ff9_dialogue.pyw) under a repo-root ``apps/`` dir, or None (a packaged
52
+ install without the apps/ dir just won't offer the standalone hand-off)."""
53
+ here = Path(__file__).resolve()
54
+ for base in here.parents:
55
+ cand = base / "apps" / name
56
+ if cand.is_file():
57
+ return cand
58
+ return None
59
+
60
+
61
+ class EditorApp:
62
+ def __init__(self, parent, path=None):
63
+ self.container = parent # where the UI mounts (a window OR a notebook tab)
64
+ self.root = parent.winfo_toplevel() # the real Tk root (after / dialogs / theme)
65
+ self.doc: FieldDoc | None = None
66
+ self.active = None # {"type":..., "index":...} currently-edited node
67
+ self.getters = {} # key -> widget reader for the active form
68
+ self.step_widgets = None # cutscene step editor state
69
+ self.opt_widgets = None # choice-options sub-editor state
70
+ self.active_choice = None # index of the choice whose options are being edited
71
+ self.campaign_idmap = None # optional {field_id: member_name} set by the Campaign workspace
72
+ self.campaign_plan = None # optional CampaignPlan -> flag picker source + name resolution
73
+ self.dialogue_opener = None # optional hook: hand the current field to the Dialogue tab (unified window)
74
+ self.busy = False
75
+ self.deploy = _find_tool("deploy_field.py")
76
+ self.revert = _find_tool("revert_deploy.py")
77
+ self.q: queue.Queue = queue.Queue()
78
+
79
+ self.palette = apply_theme(self.root) # modern look (auto light/dark) -> palette colours
80
+ self._build_toolbar()
81
+ ttk.Separator(self.container, orient="horizontal").pack(fill="x")
82
+ panes = ttk.PanedWindow(self.container, orient="horizontal")
83
+ panes.pack(fill="both", expand=True, padx=8, pady=8)
84
+ left = ttk.Frame(panes)
85
+ self.tree = ttk.Treeview(left, show="tree", selectmode="browse")
86
+ self.tree.pack(fill="both", expand=True, side="left")
87
+ sb = ttk.Scrollbar(left, orient="vertical", command=self.tree.yview)
88
+ sb.pack(fill="y", side="right")
89
+ self.tree.configure(yscrollcommand=sb.set)
90
+ self.tree.bind("<<TreeviewSelect>>", self._on_select)
91
+ panes.add(left, weight=1)
92
+ self.form = ttk.Frame(panes)
93
+ panes.add(self.form, weight=3)
94
+
95
+ self.status = self._build_log(self.container)
96
+ self._log("Open a .field.toml, or New. Edit logic on the right; placement stays in Blender. "
97
+ "New here? Click Help for a 30-second tour.")
98
+ self.root.after(120, self._drain)
99
+ if path:
100
+ self._load(Path(path))
101
+ else:
102
+ self._show_welcome()
103
+
104
+ # --------------------------------------------------------------- toolbar
105
+ def _build_toolbar(self):
106
+ bar = ttk.Frame(self.container)
107
+ bar.pack(fill="x", padx=6, pady=6)
108
+ ttk.Button(bar, text="Open", command=self.on_open).pack(side="left")
109
+ ttk.Button(bar, text="New", command=self.on_new).pack(side="left", padx=(6, 0))
110
+ self.btn_save = ttk.Button(bar, text="Save", command=self.on_save, style="Accent.TButton")
111
+ self.btn_save.pack(side="left", padx=(6, 0))
112
+ ttk.Separator(bar, orient="vertical").pack(side="left", fill="y", padx=8)
113
+ self.btn_check = ttk.Button(bar, text="Check logic", command=self.on_check)
114
+ self.btn_check.pack(side="left")
115
+ self.btn_build = ttk.Button(bar, text="Build to game", command=self.on_build_game)
116
+ self.btn_build.pack(side="left", padx=(6, 0))
117
+ ttk.Button(bar, text="Dialogue...", command=self.on_open_dialogue).pack(side="left", padx=(6, 0))
118
+ if self.deploy:
119
+ self.btn_test = ttk.Button(bar, text="Build & Test (4003)", command=self.on_test,
120
+ style="Accent.TButton")
121
+ self.btn_test.pack(side="left", padx=(6, 0))
122
+ if self.revert:
123
+ ttk.Button(bar, text="Revert test", command=self.on_revert).pack(side="left", padx=(6, 0))
124
+ self.title_lbl = ttk.Label(bar, text="(no file)")
125
+ self.title_lbl.pack(side="right")
126
+ ttk.Button(bar, text="Help", command=self.on_help).pack(side="right", padx=(0, 8))
127
+
128
+ def on_help(self):
129
+ messagebox.showinfo(
130
+ "FF9 Map Kit - Field Editor",
131
+ "WHAT THIS IS\n"
132
+ "A form-based editor for a field's LOGIC: dialogue, NPCs, events, story flags, "
133
+ "encounters, music, and cutscenes. No hand-writing TOML.\n\n"
134
+ "WHERE PLACEMENT LIVES\n"
135
+ "The camera, the walkmesh, and where things stand are SPATIAL -- you set those in "
136
+ "Blender (the FF9 Map Kit add-on). This editor only touches the logic file "
137
+ "(<name>.field.toml) and never your Blender scene (<name>.scene.toml).\n\n"
138
+ "GETTING A FILE TO OPEN\n"
139
+ " - ff9mapkit new MY_ROOM scaffold a blank room\n"
140
+ " - ff9mapkit import <field> fork a real FF9 field (add --editable to repaint it)\n"
141
+ " - the Blender add-on's Export\n\n"
142
+ "WORKFLOW\n"
143
+ "Open -> pick a section on the left -> fill the form -> Save -> Check logic -> Build.\n"
144
+ "A section marked (+) can be added; NPCs / Gateways / Events / Markers each hold a list.\n\n"
145
+ "SECTIONS\n"
146
+ " - NPCs / Gateways / Events: people, exits, and walk-in triggers.\n"
147
+ " - Markers: named floor points a cutscene can walk to by name.\n"
148
+ " - Choices: talk to an NPC -> a menu -> branch (each option: reply / item / gil / flag).\n"
149
+ " - Cutscene: ordered steps (control locks). An 'actor' NPC can walk / emote.\n"
150
+ " - Dialogue: auto-wrap width for long lines. Encounter / Music: battles + BGM.\n\n"
151
+ "CUTSCENE STEPS\n"
152
+ " - walk/teleport: a marker name, @player, or \"x, z\" (walk auto-routes around things).\n"
153
+ " - path: a route, \"a; b; c\". animation: a gesture name (glad) or id. say: a line.\n\n"
154
+ "A FEW FIELDS\n"
155
+ " - Field ID: any unique number >= 4000.\n"
156
+ " - Area: must be >= 10 (lower areas don't render).\n"
157
+ " - Text block: leave at 1073 unless you know otherwise.\n"
158
+ " - NPC preset: vivi or zidane is the easy path (a custom model also needs anims set "
159
+ "in the .toml).")
160
+
161
+ def on_open_dialogue(self):
162
+ """Hand the current field to the Dialogue editor (the focused word-smithing + stock-dialogue view).
163
+ In the unified Campaign Editor the host wires ``dialogue_opener`` to flip to the Dialogue tab (which
164
+ shares this very doc); standalone, it saves + launches the Dialogue app on the file."""
165
+ if self.doc is None:
166
+ messagebox.showinfo("No field", "Open or create a field first.")
167
+ return
168
+ if not self._commit_active():
169
+ return
170
+ if self.dialogue_opener is not None: # unified window: same shared doc, just flip tabs
171
+ self.dialogue_opener()
172
+ return
173
+ if not self._ensure_saved(): # standalone: persist, then open the app on the file
174
+ return
175
+ app = _find_app("ff9_dialogue.pyw")
176
+ if app is None:
177
+ messagebox.showinfo("Dialogue editor",
178
+ "The standalone Dialogue app wasn't found (apps/ff9_dialogue.pyw).")
179
+ return
180
+ subprocess.Popen([sys.executable, str(app), str(self.doc.path)])
181
+ self._log("opened the Dialogue editor on this field.")
182
+
183
+ # --------------------------------------------------------------- logging
184
+ def _build_log(self, root):
185
+ """A flat console log (Text + ttk scrollbar) with severity colour tags."""
186
+ pal = self.palette
187
+ wrap = ttk.Frame(root)
188
+ wrap.pack(fill="x", padx=8, pady=(0, 8))
189
+ txt = tk.Text(wrap, height=7, state="disabled", wrap="word", relief="flat", borderwidth=0,
190
+ background=pal["log_bg"], foreground=pal["log_fg"], padx=10, pady=8,
191
+ highlightthickness=1, highlightbackground=pal["border"],
192
+ highlightcolor=pal["border"], insertbackground=pal["text"])
193
+ txt.pack(side="left", fill="both", expand=True)
194
+ sb = ttk.Scrollbar(wrap, orient="vertical", command=txt.yview)
195
+ sb.pack(side="right", fill="y")
196
+ txt.configure(yscrollcommand=sb.set)
197
+ for tag, col in (("error", pal["error"]), ("warn", pal["warn"]),
198
+ ("ok", pal["success"]), ("muted", pal["muted"])):
199
+ txt.tag_configure(tag, foreground=col)
200
+ return txt
201
+
202
+ def _log(self, msg):
203
+ text = str(msg).rstrip() + "\n"
204
+ probe = text.strip()
205
+ if probe.startswith(("ERROR", "INVALID")) or " ERROR " in text:
206
+ tag = "error"
207
+ elif probe.startswith("warn") or probe[:12].startswith("warning"):
208
+ tag = "warn"
209
+ elif probe.startswith(("OK", ">>>")):
210
+ tag = "ok"
211
+ elif probe.startswith("---"):
212
+ tag = "muted"
213
+ else:
214
+ tag = None
215
+ self.status.configure(state="normal")
216
+ self.status.insert("end", text, (tag,) if tag else ())
217
+ self.status.see("end")
218
+ self.status.configure(state="disabled")
219
+
220
+ def post(self, msg):
221
+ self.q.put(msg)
222
+
223
+ def _drain(self):
224
+ try:
225
+ while True:
226
+ self._log(self.q.get_nowait())
227
+ except queue.Empty:
228
+ pass
229
+ self.root.after(120, self._drain)
230
+
231
+ # --------------------------------------------------------------- file io
232
+ def on_open(self):
233
+ f = filedialog.askopenfilename(title="Open field.toml",
234
+ filetypes=[("Field project", "*.field.toml"),
235
+ ("TOML", "*.toml"), ("All files", "*.*")])
236
+ if f:
237
+ self.open_path(Path(f))
238
+
239
+ def open_path(self, path) -> bool:
240
+ """Load a field.toml -- the SINGLE load entry point (the toolbar Open and the campaign navigator
241
+ both route here). If another file is already open, offers to save it first; returns False if the
242
+ user cancels the switch (or a requested save fails) so a caller can keep the prior selection."""
243
+ path = Path(path)
244
+ cur = getattr(self.doc, "path", None) if self.doc is not None else None
245
+ if cur is not None and Path(cur) != path and not self._offer_save_before_switch(path):
246
+ return False
247
+ self._load(path)
248
+ return True
249
+
250
+ def _offer_save_before_switch(self, newpath) -> bool:
251
+ """Give the user a chance to save the open doc before it's replaced. Returns False to abort the
252
+ switch (Cancel, or a save the user asked for that then failed). No prompt when nothing changed."""
253
+ if not self._commit_active(): # fold the active form in first (may report a parse error)
254
+ return False
255
+ if not self._dirty(): # unchanged since load/save -> switch freely, no nag
256
+ return True
257
+ ans = messagebox.askyesnocancel(
258
+ "Save changes?",
259
+ f"Save changes to {self.doc.path.name} before opening {Path(newpath).name}?\n\n"
260
+ "Unsaved edits are lost if you don't.")
261
+ if ans is None: # Cancel -> stay on the current file
262
+ return False
263
+ if ans: # Yes -> save (abort the switch if the save fails)
264
+ return self.on_save()
265
+ return True # No -> discard and switch
266
+
267
+ def _mark_clean(self):
268
+ """Snapshot the doc's data as the 'saved' baseline -- :meth:`_dirty` compares against this."""
269
+ self._clean = copy.deepcopy(self.doc.data) if self.doc is not None else None
270
+
271
+ def _dirty(self) -> bool:
272
+ """True if the (committed) doc differs from its last load/save baseline. Used to skip the
273
+ save-before-switch prompt when the user only navigated. Commit the active form before calling."""
274
+ return self.doc is not None and self.doc.data != getattr(self, "_clean", None)
275
+
276
+ def _load(self, path):
277
+ try:
278
+ self.doc = FieldDoc.load(path)
279
+ except Exception as e: # noqa: BLE001
280
+ messagebox.showerror("Open failed", f"{path}\n\n{e}")
281
+ return
282
+ self.active = None
283
+ self._mark_clean()
284
+ split = " (+ scene.toml)" if self.doc.scene_data is not None else ""
285
+ self.title_lbl.configure(text=path.name + split)
286
+ self._log(f"opened {path.name}{split}")
287
+ self._refresh_tree(reselect="field") # land on the Field form (clears the welcome)
288
+
289
+ def on_new(self):
290
+ f = filedialog.asksaveasfilename(title="New field.toml", defaultextension=".field.toml",
291
+ filetypes=[("Field project", "*.field.toml")])
292
+ if not f:
293
+ return
294
+ p = Path(f)
295
+ reason = protected_reason(p)
296
+ if reason:
297
+ messagebox.showerror("Can't create here", f"{p}\n\n{reason}.\n\nPick a folder of your own.")
298
+ return
299
+ name = p.name[:-len(".field.toml")] if p.name.endswith(".field.toml") else p.stem
300
+ self.doc = FieldDoc.new(p, name=name.upper())
301
+ self.active = None
302
+ self._mark_clean()
303
+ self.title_lbl.configure(text=p.name + " (new, unsaved)")
304
+ self._refresh_tree(reselect="field") # land on the Field form (clears the welcome)
305
+ self._log(f"new field {name.upper()} -- fill in [field], add content, then Save.")
306
+
307
+ def on_save(self):
308
+ if not self._commit_active():
309
+ return False
310
+ if self.doc is None:
311
+ return False
312
+ reason = protected_reason(self.doc.path)
313
+ if reason:
314
+ messagebox.showerror("Can't save here", f"{self.doc.path}\n\n{reason}.\n\n"
315
+ "Save a copy in a folder of your own first.")
316
+ return False
317
+ try:
318
+ self._cleanup_empty()
319
+ self.doc.save()
320
+ self._mark_clean()
321
+ self._log(f"saved {self.doc.path.name}")
322
+ except Exception as e: # noqa: BLE001
323
+ messagebox.showerror("Save failed", str(e))
324
+ return False
325
+ return True
326
+
327
+ def _cleanup_empty(self):
328
+ """Drop empty array sections so we don't write ``npc = []`` etc."""
329
+ for key in list(self.doc.data.keys()):
330
+ v = self.doc.data[key]
331
+ if isinstance(v, list) and not v:
332
+ self.doc.data.pop(key)
333
+
334
+ # --------------------------------------------------------------- tree
335
+ def _refresh_tree(self, reselect=None):
336
+ self.tree.delete(*self.tree.get_children())
337
+ if self.doc is None:
338
+ return
339
+ self.tree.insert("", "end", iid="field", text="Field")
340
+ self.tree.insert("", "end", iid="camera", text="Camera (Blender)")
341
+ for key in ("dialogue", "encounter", "music", "cutscene"):
342
+ present = key in self.doc.data
343
+ label = {"dialogue": "Dialogue", "encounter": "Encounter",
344
+ "music": "Music", "cutscene": "Cutscene"}[key]
345
+ self.tree.insert("", "end", iid=key, text=label + ("" if present else " (+)"))
346
+ for key in ("npc", "gateway", "event", "marker"):
347
+ parent = self.tree.insert("", "end", iid=key, text=f"{LIST_LABELS[key]} (+)", open=True)
348
+ for i, e in enumerate(self.doc.data.get(key, [])):
349
+ nm = e.get("name") or f"#{i}"
350
+ self.tree.insert(parent, "end", iid=f"{key}:{i}", text=nm)
351
+ cparent = self.tree.insert("", "end", iid="choice", text="Choices (+)", open=True)
352
+ for i, ch in enumerate(self.doc.data.get("choice", [])):
353
+ self.tree.insert(cparent, "end", iid=f"choice:{i}", text=forms.choice_summary(ch))
354
+ if reselect and self.tree.exists(reselect):
355
+ self.tree.selection_set(reselect)
356
+ self.tree.see(reselect)
357
+
358
+ def _on_select(self, _evt):
359
+ sel = self.tree.selection()
360
+ if not sel:
361
+ return
362
+ iid = sel[0]
363
+ if self.active and not self._commit_active():
364
+ return # a parse error -- stay put
365
+ self._show(iid)
366
+
367
+ # --------------------------------------------------------------- forms
368
+ def _clear_form(self):
369
+ for w in self.form.winfo_children():
370
+ w.destroy()
371
+ self.getters = {}
372
+ self.step_widgets = None
373
+ self.opt_widgets = None
374
+ self.active = None
375
+
376
+ def _show_welcome(self):
377
+ """Empty-state guidance shown before any file is open (newcomer's first screen)."""
378
+ self._clear_form()
379
+ msg = (
380
+ "FF9 Map Kit - Field Editor\n\n"
381
+ "This edits a field's LOGIC: dialogue, NPCs, events, story flags, encounters, music, and "
382
+ "cutscenes. The SPATIAL side -- the camera, the walkmesh, and where things stand -- is "
383
+ "authored in Blender; this editor never changes it.\n\n"
384
+ "Open a .field.toml to start. You can get one from:\n"
385
+ " - ff9mapkit new MY_ROOM a blank room\n"
386
+ " - ff9mapkit import <field> fork a real FF9 field\n"
387
+ " - the Blender add-on's Export\n\n"
388
+ "Then: pick a section on the left, fill in the form, Save, Check logic, Build.\n\n"
389
+ "On the left, a section marked (+) is one you can add (Encounter, Music, Cutscene) or add "
390
+ "items to (NPCs, Gateways, Events). Click Help in the toolbar any time."
391
+ )
392
+ ttk.Label(self.form, text=msg, justify="left", wraplength=560).pack(anchor="nw", padx=14, pady=14)
393
+
394
+ def _show(self, iid):
395
+ self._clear_form()
396
+ if iid == "field":
397
+ self._show_single("field", forms.FIELD_SPEC, "Field")
398
+ elif iid == "camera":
399
+ self._show_camera()
400
+ elif iid in ("encounter", "music", "dialogue"):
401
+ self._show_optional_single(iid, SINGLE_SPECS[iid], iid.capitalize())
402
+ elif iid == "cutscene":
403
+ self._show_cutscene()
404
+ elif iid == "choice":
405
+ self._show_choice_list()
406
+ elif iid.startswith("choice:"):
407
+ self._show_choice(int(iid.split(":", 1)[1]))
408
+ elif iid in LIST_SPECS:
409
+ self._show_list_parent(iid)
410
+ elif ":" in iid:
411
+ kind, idx = iid.split(":")
412
+ self._show_entity(kind, int(idx))
413
+
414
+ def _header(self, text, key=None):
415
+ ttk.Label(self.form, text=text, font=("", 11, "bold")).pack(anchor="w", padx=8, pady=(8, 2))
416
+ note = forms.SECTION_HELP.get(key) if key else None
417
+ if note:
418
+ ttk.Label(self.form, text=note, foreground=self.palette["muted"], wraplength=580,
419
+ justify="left").pack(anchor="w", padx=10, pady=(0, 4))
420
+
421
+ def _form_grid(self):
422
+ g = ttk.Frame(self.form)
423
+ g.pack(fill="x", padx=8, pady=4)
424
+ return g
425
+
426
+ def _render_spec(self, parent, spec, values, vars_out=None):
427
+ getters = {}
428
+ for r, f in enumerate(spec):
429
+ ttk.Label(parent, text=f.label + ":").grid(row=r, column=0, sticky="ne", padx=4, pady=2)
430
+ if f.kind == forms.BOOL:
431
+ var = tk.BooleanVar(value=bool(values.get(f.key, f.default)))
432
+ ttk.Checkbutton(parent, variable=var).grid(row=r, column=1, sticky="w")
433
+ else:
434
+ var = tk.StringVar(value=str(values.get(f.key, "") or ""))
435
+ mk = ((lambda h: ttk.Combobox(h, textvariable=var, values=forms.PRESETS))
436
+ if f.kind == forms.PRESET else (lambda h: ttk.Entry(h, textvariable=var)))
437
+ if getattr(f, "catalog", None): # a catalog-backed field gets a "Browse..." picker
438
+ host = ttk.Frame(parent)
439
+ host.grid(row=r, column=1, sticky="we")
440
+ host.columnconfigure(0, weight=1, minsize=130) # don't let the button squash the widget
441
+ mk(host).grid(row=0, column=0, sticky="we")
442
+ ttk.Button(host, text="Browse...", width=9,
443
+ command=lambda fk=f, v=var: self._pick_catalog(fk, v)).grid(
444
+ row=0, column=1, padx=(4, 0))
445
+ else:
446
+ mk(parent).grid(row=r, column=1, sticky="we")
447
+ getters[f.key] = var.get
448
+ if vars_out is not None: # expose the vars so a caller can re-populate (option editor)
449
+ vars_out[f.key] = var
450
+ if f.help:
451
+ ttk.Label(parent, text=f.help, foreground=self.palette["muted"]).grid(
452
+ row=r, column=2, sticky="w", padx=6)
453
+ parent.columnconfigure(1, weight=1, minsize=230) # the widget column stays usable even with a Browse button
454
+ return getters
455
+
456
+ def _pick_catalog(self, field, var):
457
+ """Open the Info Hub picker for a catalog-backed field; write the chosen name into its widget.
458
+ Passes the open campaign so a ``catalog="flag"`` field can pick a shared [[flag]] by name."""
459
+ kinds = [k.strip() for k in field.catalog.split(",")] if field.catalog else None
460
+ name = picker.pick(self.root, kinds=kinds, title=f"Pick {field.label}", initial=var.get().strip(),
461
+ campaign_context=self.campaign_plan)
462
+ if name:
463
+ var.set(name)
464
+
465
+ def _campaign_flag_names(self):
466
+ """The open campaign's shared [[flag]] name->index map (or None standalone), so Check/Build resolve
467
+ cross-field flag NAMES exactly as the campaign build does -- else a name would choke _gate_of's int()."""
468
+ plan = self.campaign_plan
469
+ if plan is None:
470
+ return None
471
+ try:
472
+ from ..flags import collect_flag_defs
473
+ return collect_flag_defs({"flag": getattr(plan, "flags", [])})
474
+ except ValueError as e: # a malformed campaign [[flag]] -- surface it, don't hide it
475
+ self.post(f" warn campaign [[flag]] table invalid: {e}")
476
+ return {}
477
+
478
+ def _show_single(self, key, spec, title):
479
+ self._header(title, key)
480
+ values = forms.entity_to_values(spec, self.doc.data.get(key, {}))
481
+ self.getters = self._render_spec(self._form_grid(), spec, values)
482
+ self.active = {"type": key, "section": key}
483
+
484
+ def _show_optional_single(self, key, spec, title):
485
+ if key not in self.doc.data:
486
+ self._header(title + " (not set)", key)
487
+ ttk.Label(self.form, text=f"No {title.lower()} on this field.").pack(anchor="w", padx=10)
488
+ ttk.Button(self.form, text=f"Add {title}",
489
+ command=lambda: self._add_single(key)).pack(anchor="w", padx=10, pady=6)
490
+ self.active = None
491
+ return
492
+ self._header(title, key)
493
+ values = forms.entity_to_values(spec, self.doc.data.get(key, {}))
494
+ self.getters = self._render_spec(self._form_grid(), spec, values)
495
+ self.active = {"type": key, "section": key}
496
+ ttk.Button(self.form, text=f"Remove {title}",
497
+ command=lambda: self._remove_single(key)).pack(anchor="w", padx=10, pady=8)
498
+
499
+ def _add_single(self, key):
500
+ self.doc.data.setdefault(key, {} if key != "cutscene" else {"steps": []})
501
+ self._refresh_tree(reselect=key)
502
+ self._show(key)
503
+
504
+ def _remove_single(self, key):
505
+ self.active = None
506
+ self.doc.remove_section(key)
507
+ self._refresh_tree(reselect=key)
508
+ self._show(key)
509
+
510
+ def _show_list_parent(self, kind):
511
+ self._header(LIST_LABELS[kind], kind)
512
+ ttk.Button(self.form, text=f"Add {kind}", command=lambda: self._add_entity(kind)).pack(
513
+ anchor="w", padx=10, pady=6)
514
+ n = len(self.doc.data.get(kind, []))
515
+ ttk.Label(self.form, text=f"{n} {kind}(s). Select one on the left to edit, or Add.").pack(
516
+ anchor="w", padx=10)
517
+ self.active = None
518
+
519
+ def _add_entity(self, kind):
520
+ defaults = {"npc": {"name": "NPC", "preset": "vivi", "dialogue": "..."},
521
+ "gateway": {"name": "door", "to": 100, "entrance": 0},
522
+ "event": {"name": "event", "message": "..."},
523
+ "marker": {"name": "spot", "pos": [0, 0]}}[kind]
524
+ lst = self.doc.list_section(kind)
525
+ lst.append(dict(defaults))
526
+ self._refresh_tree(reselect=f"{kind}:{len(lst) - 1}")
527
+ self._show_entity(kind, len(lst) - 1)
528
+
529
+ def _show_entity(self, kind, idx):
530
+ spec = LIST_SPECS[kind]
531
+ lst = self.doc.data.get(kind, [])
532
+ if idx >= len(lst):
533
+ return
534
+ entity = lst[idx]
535
+ self._header(f"{LIST_LABELS[kind][:-1]}: {entity.get('name') or '#' + str(idx)}", kind)
536
+ self.getters = self._render_spec(self._form_grid(), spec, forms.entity_to_values(spec, entity))
537
+ self.active = {"type": kind, "section": kind, "index": idx}
538
+ # show the Blender-placed spatial value (read-only hint) if it's in the scene file
539
+ scene_e = self.doc.scene_entities(kind).get(entity.get("name", ""))
540
+ if scene_e:
541
+ spatial = scene_e.get("pos") or scene_e.get("zone")
542
+ ttk.Label(self.form, text=f"placed in Blender: {spatial}",
543
+ foreground=self.palette["success"]).pack(anchor="w", padx=10, pady=(2, 0))
544
+ # in a campaign, resolve a gateway's numeric target to the member it leads to (read-only hint)
545
+ if kind == "gateway" and self.campaign_idmap:
546
+ member = self.campaign_idmap.get(entity.get("to"))
547
+ if member:
548
+ ttk.Label(self.form, text=f"→ leads to campaign member: {member}",
549
+ foreground=self.palette["success"]).pack(anchor="w", padx=10, pady=(2, 0))
550
+ ttk.Button(self.form, text=f"Delete this {kind}",
551
+ command=lambda: self._delete_entity(kind, idx)).pack(anchor="w", padx=10, pady=8)
552
+
553
+ def _delete_entity(self, kind, idx):
554
+ self.active = None
555
+ lst = self.doc.data.get(kind, [])
556
+ if idx < len(lst):
557
+ lst.pop(idx)
558
+ self._refresh_tree(reselect=kind)
559
+ self._show(kind)
560
+
561
+ def _show_camera(self):
562
+ self._header("Camera & placement", "camera")
563
+ msg = ("Camera, walkmesh, layers, and entity positions/zones are SPATIAL — author them in "
564
+ "Blender (FF9 Map Kit add-on), which writes the sibling scene.toml. This editor owns "
565
+ "the logic only.")
566
+ ttk.Label(self.form, text=msg, wraplength=520, justify="left").pack(anchor="w", padx=10, pady=8)
567
+ cam = (self.doc.merged() if self.doc else {}).get("camera", {})
568
+ if cam:
569
+ ttk.Label(self.form, text=f"current: {cam}", foreground=self.palette["muted"],
570
+ wraplength=520, justify="left").pack(anchor="w", padx=10)
571
+ self.active = None
572
+
573
+ # --------------------------------------------------------------- cutscene (steps)
574
+ def _show_cutscene(self):
575
+ if "cutscene" not in self.doc.data:
576
+ self._show_optional_single("cutscene", forms.CUTSCENE_SPEC, "Cutscene")
577
+ return
578
+ self._header("Cutscene", "cutscene")
579
+ cs = self.doc.data["cutscene"]
580
+ pal = self.palette
581
+ self.getters = self._render_spec(self._form_grid(), forms.CUTSCENE_SPEC,
582
+ forms.entity_to_values(forms.CUTSCENE_SPEC, cs))
583
+ # --- the step list ---
584
+ ttk.Label(self.form, text="Steps (run in order; control is locked):",
585
+ font=("", 10, "bold")).pack(anchor="w", padx=10, pady=(8, 2))
586
+ body = ttk.Frame(self.form)
587
+ body.pack(fill="both", expand=True, padx=10)
588
+ lb = tk.Listbox(body, height=8, exportselection=False, relief="flat", borderwidth=0,
589
+ background=pal["field"], foreground=pal["text"], activestyle="none",
590
+ selectbackground=pal["accent"], selectforeground=pal["accent_fg"],
591
+ highlightthickness=1, highlightbackground=pal["border"],
592
+ highlightcolor=pal["accent"])
593
+ lb.pack(side="left", fill="both", expand=True)
594
+ for st in cs.get("steps", []):
595
+ lb.insert("end", forms.step_summary(st))
596
+ side = ttk.Frame(body)
597
+ side.pack(side="left", fill="y", padx=(8, 0))
598
+ kind = tk.StringVar(value="say")
599
+ ttk.Label(side, text="Type:").pack(anchor="w")
600
+ ttk.Combobox(side, textvariable=kind, values=list(forms.STEP_KIND),
601
+ state="readonly", width=14).pack(anchor="w")
602
+ val = tk.StringVar()
603
+ ttk.Label(side, text="Value:").pack(anchor="w", pady=(6, 0))
604
+ ttk.Entry(side, textvariable=val, width=16).pack(anchor="w")
605
+ hint = ttk.Label(side, text=forms.STEP_HELP.get(kind.get(), ""), foreground=pal["muted"],
606
+ wraplength=150, justify="left")
607
+ hint.pack(anchor="w", pady=(3, 0))
608
+ kind.trace_add("write", lambda *_: hint.configure(text=forms.STEP_HELP.get(kind.get(), "")))
609
+ ttk.Label(side, text="(walk/path/teleport/animation/\nturn/face need an actor)",
610
+ foreground=pal["muted"], justify="left").pack(anchor="w", pady=(4, 0))
611
+ self.step_widgets = {"listbox": lb, "kind": kind, "val": val}
612
+ ttk.Button(side, text="Add / Update", command=self._step_add).pack(fill="x", pady=(8, 2))
613
+ ttk.Button(side, text="Remove", command=self._step_remove).pack(fill="x", pady=2)
614
+ ttk.Button(side, text="Up", command=lambda: self._step_move(-1)).pack(fill="x", pady=2)
615
+ ttk.Button(side, text="Down", command=lambda: self._step_move(1)).pack(fill="x", pady=2)
616
+ lb.bind("<<ListboxSelect>>", self._step_selected)
617
+ ttk.Button(self.form, text="Remove Cutscene",
618
+ command=lambda: self._remove_single("cutscene")).pack(anchor="w", padx=10, pady=8)
619
+ self.active = {"type": "cutscene", "section": "cutscene"}
620
+
621
+ def _steps(self):
622
+ return self.doc.data.setdefault("cutscene", {}).setdefault("steps", [])
623
+
624
+ def _step_selected(self, _evt):
625
+ w = self.step_widgets
626
+ sel = w["listbox"].curselection()
627
+ if not sel:
628
+ return
629
+ st = self._steps()[sel[0]]
630
+ w["kind"].set(forms.step_key(st))
631
+ w["val"].set(forms.step_value_text(st))
632
+
633
+ def _step_add(self):
634
+ w = self.step_widgets
635
+ try:
636
+ step = forms.make_step(w["kind"].get(), w["val"].get())
637
+ except ValueError as e:
638
+ messagebox.showerror("Bad step", str(e))
639
+ return
640
+ steps = self._steps()
641
+ sel = w["listbox"].curselection()
642
+ if sel and forms.step_key(steps[sel[0]]) == forms.step_key(step):
643
+ steps[sel[0]] = step # update the selected same-type step
644
+ else:
645
+ steps.append(step)
646
+ self._reload_steps()
647
+
648
+ def _step_remove(self):
649
+ w = self.step_widgets
650
+ sel = w["listbox"].curselection()
651
+ if sel:
652
+ self._steps().pop(sel[0])
653
+ self._reload_steps()
654
+
655
+ def _step_move(self, d):
656
+ w = self.step_widgets
657
+ sel = w["listbox"].curselection()
658
+ if not sel:
659
+ return
660
+ i = sel[0]
661
+ steps = self._steps()
662
+ j = i + d
663
+ if 0 <= j < len(steps):
664
+ steps[i], steps[j] = steps[j], steps[i]
665
+ self._reload_steps(select=j)
666
+
667
+ def _reload_steps(self, select=None):
668
+ w = self.step_widgets
669
+ lb = w["listbox"]
670
+ lb.delete(0, "end")
671
+ for st in self._steps():
672
+ lb.insert("end", forms.step_summary(st))
673
+ if select is not None and 0 <= select < lb.size():
674
+ lb.selection_set(select)
675
+
676
+ # --------------------------------------------------------------- choices (npc + options)
677
+ def _show_choice_list(self):
678
+ self._header("Choices", "choice")
679
+ ttk.Button(self.form, text="Add choice", command=self._add_choice).pack(anchor="w", padx=10, pady=6)
680
+ n = len(self.doc.data.get("choice", []))
681
+ ttk.Label(self.form, text=f"{n} choice(s). Select one on the left to edit, or Add. A choice "
682
+ "attaches to an NPC by name; talking to it shows the menu.").pack(anchor="w", padx=10)
683
+ self.active = None
684
+
685
+ def _add_choice(self):
686
+ lst = self.doc.data.setdefault("choice", [])
687
+ lst.append({"npc": "", "prompt": "What'll it be?", "options": [{"text": "Yes"}, {"text": "No"}]})
688
+ self._refresh_tree(reselect=f"choice:{len(lst) - 1}")
689
+ self._show_choice(len(lst) - 1)
690
+
691
+ def _show_choice(self, idx):
692
+ lst = self.doc.data.get("choice", [])
693
+ if idx >= len(lst):
694
+ return
695
+ ch = lst[idx]
696
+ pal = self.palette
697
+ self.active_choice = idx
698
+ self._header(f"Choice: {ch.get('npc') or '#' + str(idx)}", "choice")
699
+ self.getters = self._render_spec(self._form_grid(), forms.CHOICE_SPEC,
700
+ forms.entity_to_values(forms.CHOICE_SPEC, ch))
701
+ self.active = {"type": "choice", "section": "choice", "index": idx}
702
+ # --- the options list + a per-option form ---
703
+ ttk.Label(self.form, text="Options (top-to-bottom; Cancel/B picks the LAST):",
704
+ font=("", 10, "bold")).pack(anchor="w", padx=10, pady=(10, 2))
705
+ row = ttk.Frame(self.form)
706
+ row.pack(fill="x", padx=10)
707
+ lb = tk.Listbox(row, height=5, exportselection=False, relief="flat", borderwidth=0,
708
+ background=pal["field"], foreground=pal["text"], activestyle="none",
709
+ selectbackground=pal["accent"], selectforeground=pal["accent_fg"],
710
+ highlightthickness=1, highlightbackground=pal["border"], highlightcolor=pal["accent"])
711
+ lb.pack(side="left", fill="both", expand=True)
712
+ for o in ch.get("options", []):
713
+ lb.insert("end", forms.option_summary(o))
714
+ side = ttk.Frame(row)
715
+ side.pack(side="left", fill="y", padx=(8, 0))
716
+ ttk.Button(side, text="Add new", command=lambda: self._opt_apply(True)).pack(fill="x")
717
+ ttk.Button(side, text="Update sel.", command=lambda: self._opt_apply(False)).pack(fill="x", pady=2)
718
+ ttk.Button(side, text="Remove", command=self._opt_remove).pack(fill="x", pady=2)
719
+ ttk.Button(side, text="Up", command=lambda: self._opt_move(-1)).pack(fill="x", pady=2)
720
+ ttk.Button(side, text="Down", command=lambda: self._opt_move(1)).pack(fill="x", pady=2)
721
+ ttk.Label(self.form, text="Edit the selected option below, then Update (or Add new):",
722
+ foreground=pal["muted"]).pack(anchor="w", padx=10, pady=(6, 0))
723
+ ovars = {}
724
+ og = self._render_spec(self._form_grid(), forms.CHOICE_OPTION_SPEC,
725
+ forms.entity_to_values(forms.CHOICE_OPTION_SPEC, {}), vars_out=ovars)
726
+ self.opt_widgets = {"listbox": lb, "getters": og, "vars": ovars}
727
+ lb.bind("<<ListboxSelect>>", self._opt_load)
728
+ ttk.Button(self.form, text="Delete this choice",
729
+ command=lambda: self._delete_choice(idx)).pack(anchor="w", padx=10, pady=8)
730
+
731
+ def _opt_options(self):
732
+ return self.doc.data["choice"][self.active_choice].setdefault("options", [])
733
+
734
+ def _opt_load(self, _evt):
735
+ w = self.opt_widgets
736
+ sel = w["listbox"].curselection()
737
+ if not sel:
738
+ return
739
+ vals = forms.entity_to_values(forms.CHOICE_OPTION_SPEC, self._opt_options()[sel[0]])
740
+ for k, var in w["vars"].items():
741
+ var.set(vals.get(k, ""))
742
+
743
+ def _opt_apply(self, append):
744
+ w = self.opt_widgets
745
+ try:
746
+ opt = forms.build_entity(forms.CHOICE_OPTION_SPEC, {k: g() for k, g in w["getters"].items()})
747
+ except ValueError as e:
748
+ messagebox.showerror("Bad option", str(e))
749
+ return
750
+ if not opt.get("text"):
751
+ messagebox.showerror("Bad option", "An option needs 'text' (the menu row shown to the player).")
752
+ return
753
+ opts = self._opt_options()
754
+ sel = w["listbox"].curselection()
755
+ if append or not sel:
756
+ opts.append(opt)
757
+ self._reload_opts(select=len(opts) - 1)
758
+ else:
759
+ opts[sel[0]] = opt
760
+ self._reload_opts(select=sel[0])
761
+
762
+ def _opt_remove(self):
763
+ sel = self.opt_widgets["listbox"].curselection()
764
+ if sel:
765
+ self._opt_options().pop(sel[0])
766
+ self._reload_opts()
767
+
768
+ def _opt_move(self, d):
769
+ sel = self.opt_widgets["listbox"].curselection()
770
+ if not sel:
771
+ return
772
+ i = sel[0]
773
+ opts = self._opt_options()
774
+ j = i + d
775
+ if 0 <= j < len(opts):
776
+ opts[i], opts[j] = opts[j], opts[i]
777
+ self._reload_opts(select=j)
778
+
779
+ def _reload_opts(self, select=None):
780
+ lb = self.opt_widgets["listbox"]
781
+ lb.delete(0, "end")
782
+ for o in self._opt_options():
783
+ lb.insert("end", forms.option_summary(o))
784
+ if select is not None and 0 <= select < lb.size():
785
+ lb.selection_set(select)
786
+
787
+ def _delete_choice(self, idx):
788
+ self.active = None
789
+ lst = self.doc.data.get("choice", [])
790
+ if idx < len(lst):
791
+ lst.pop(idx)
792
+ self._refresh_tree(reselect="choice")
793
+ self._show("choice")
794
+
795
+ # --------------------------------------------------------------- commit
796
+ def _commit_active(self) -> bool:
797
+ """Write the active form back into the doc. Returns False (and reports) on a parse error."""
798
+ if not self.active or not self.getters:
799
+ return True
800
+ a = self.active
801
+ spec = (SINGLE_SPECS.get(a["type"]) or LIST_SPECS.get(a["type"])
802
+ or {"field": forms.FIELD_SPEC, "cutscene": forms.CUTSCENE_SPEC,
803
+ "choice": forms.CHOICE_SPEC}.get(a["type"], forms.CUTSCENE_SPEC))
804
+ values = {k: g() for k, g in self.getters.items()}
805
+ try:
806
+ entity = forms.build_entity(spec, values)
807
+ except ValueError as e:
808
+ messagebox.showerror("Invalid value", str(e))
809
+ return False
810
+ if "index" in a: # an array-of-tables entity
811
+ lst = self.doc.data.get(a["section"], [])
812
+ if a["index"] < len(lst):
813
+ _apply(lst[a["index"]], spec, entity)
814
+ elif a["type"] == "cutscene":
815
+ cs = self.doc.data.setdefault("cutscene", {})
816
+ steps = cs.get("steps", [])
817
+ _apply(cs, spec, entity)
818
+ cs["steps"] = steps # keep the steps the list editor manages
819
+ else: # a single table (field/encounter/music)
820
+ _apply(self.doc.data.setdefault(a["section"], {}), spec, entity)
821
+ return True
822
+
823
+ # --------------------------------------------------------------- build / check / deploy
824
+ def _ensure_saved(self):
825
+ if self.doc is None:
826
+ messagebox.showinfo("No field", "Open or create a field first.")
827
+ return False
828
+ return self.on_save() is not False and self.doc is not None
829
+
830
+ def on_check(self):
831
+ if self.busy or not self._ensure_saved():
832
+ return
833
+ self._run(self._check)
834
+
835
+ def _check(self):
836
+ from ..build import FieldProject, lint_logic, validate
837
+ self.post(f"\n--- check {self.doc.path.name} ---")
838
+ try:
839
+ p = FieldProject.load(self.doc.path, flag_names=self._campaign_flag_names()) # resolve names
840
+ except ValueError as e:
841
+ self.post(f" ERROR flag name didn't resolve: {e}")
842
+ self.post(" (a campaign-shared flag? open its campaign in the Campaign Editor, or define "
843
+ "it in a [[flag]] table / use a numeric index.)")
844
+ return
845
+ probs, lints = validate(p), lint_logic(p)
846
+ for m in probs:
847
+ self.post(" ERROR " + m)
848
+ for m in lints:
849
+ self.post(" warn " + m)
850
+ self.post(" OK — no problems." if not (probs or lints)
851
+ else f" {len(probs)} error(s), {len(lints)} warning(s)")
852
+
853
+ def on_build_game(self):
854
+ if self.busy or not self._ensure_saved():
855
+ return
856
+ try:
857
+ from .. import config
858
+ out = config.find_game_path() / "FF9CustomMap"
859
+ except Exception: # noqa: BLE001
860
+ d = filedialog.askdirectory(title="Build output folder")
861
+ if not d:
862
+ return
863
+ out = Path(d)
864
+ if messagebox.askyesno("Build", f"Build this field into:\n{out}\n\n(installs at its real id)"):
865
+ self._run(self._build, str(out))
866
+
867
+ def _build(self, out):
868
+ from ..build import FieldProject, build_mod, validate
869
+ self.post(f"\n--- build {self.doc.path.name} -> {out} ---")
870
+ try:
871
+ p = FieldProject.load(self.doc.path, flag_names=self._campaign_flag_names()) # resolve names
872
+ except ValueError as e:
873
+ self.post(f"INVALID: flag name didn't resolve: {e} (open its campaign, or use a [[flag]]/index)")
874
+ return
875
+ probs = validate(p)
876
+ if probs:
877
+ self.post("INVALID:\n - " + "\n - ".join(probs))
878
+ return
879
+ info = build_mod([p], out, mod_name="FF9CustomMap")
880
+ self.post("OK: " + info["dictionary"][0])
881
+ for w in info.get("warnings", []):
882
+ self.post(" warning: " + w)
883
+
884
+ def on_test(self):
885
+ if self.busy or not self._ensure_saved():
886
+ return
887
+ if messagebox.askyesno("Build & Test", "Build + deploy to the in-game test field (4003)?"):
888
+ self._run(self._deploy)
889
+
890
+ def _deploy(self):
891
+ self.post(f"\n--- deploy {self.doc.path.name} -> test field 4003 ---")
892
+ r = subprocess.run([sys.executable, str(self.deploy), str(self.doc.path)],
893
+ cwd=str(self.deploy.parents[1]), capture_output=True, text=True)
894
+ if r.stdout:
895
+ self.post(r.stdout)
896
+ if r.returncode:
897
+ self.post("ERROR:\n" + (r.stderr or "(no detail)"))
898
+ else:
899
+ self.post(">>> In-game: New Game -> walk to the hut door -> your field.")
900
+
901
+ def on_revert(self):
902
+ if self.busy:
903
+ return
904
+ if messagebox.askyesno("Revert", "Restore the game to before the last test deploy?"):
905
+ self._run(self._do_revert)
906
+
907
+ def _do_revert(self):
908
+ self.post("\n--- revert test field ---")
909
+ r = subprocess.run([sys.executable, str(self.revert)], cwd=str(self.revert.parents[1]),
910
+ capture_output=True, text=True)
911
+ self.post(r.stdout or "")
912
+ if r.returncode:
913
+ self.post("ERROR:\n" + (r.stderr or "(no detail)"))
914
+
915
+ def _run(self, fn, *args):
916
+ self.busy = True
917
+
918
+ def work():
919
+ try:
920
+ fn(*args)
921
+ except Exception: # noqa: BLE001
922
+ self.post("ERROR:\n" + traceback.format_exc())
923
+ finally:
924
+ self.busy = False
925
+ threading.Thread(target=work, daemon=True).start()
926
+
927
+
928
+ def _apply(target: dict, spec, entity: dict):
929
+ """Update ``target`` in place: clear this spec's keys, set the present ones, keep any others
930
+ (so a single-file project's spatial keys and unknown future keys survive an edit)."""
931
+ for f in spec:
932
+ target.pop(f.key, None)
933
+ target.update(entity)
934
+
935
+
936
+ def main(path=None):
937
+ root = tk.Tk()
938
+ root.title("FF9 Map Kit - Field Editor")
939
+ root.minsize(960, 600)
940
+ root.geometry("1180x720") # roomy default: tree + form + the help column + pickers
941
+ try:
942
+ EditorApp(root, path)
943
+ except Exception: # noqa: BLE001
944
+ messagebox.showerror("FF9 Map Kit", traceback.format_exc())
945
+ raise
946
+ root.mainloop()
947
+
948
+
949
+ if __name__ == "__main__":
950
+ main(sys.argv[1] if len(sys.argv) > 1 else None)