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,342 @@
1
+ """tk-free build / deploy / import job layer -- the backend the GUIs are a view over.
2
+
3
+ The Build & Deploy and FFIX Import flows are forms + a subprocess stream + a verdict. This module holds
4
+ the *non-view* parts of both so the Qt Workspace (and a test) can reuse them verbatim, with no tk and no
5
+ Qt: the file-kind detector, the deploy-target reader, the deployed-field lister, and the argv builders
6
+ for every shell-out (the ``ff9mapkit import ...`` line, the ``tools/deploy_*.py`` deploys, the reverts).
7
+
8
+ The deploy *tools* live at the REPO root (``tools/``), not inside the kit package, so the argv builders
9
+ take ``repo_root`` rather than hardcoding a checkout path. ``detect_game_mod`` / ``detect_deployed_fields``
10
+ go through :mod:`..config` (the install resolver), so they need no repo path.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ import tomllib
17
+ from pathlib import Path
18
+
19
+
20
+ # --------------------------------------------------------------------------- file-kind detection
21
+ def detect_kind(path):
22
+ """``('campaign', plan)`` | ``('journey', manifest)`` | ``('battle', None)`` | ``('field', None)``.
23
+
24
+ A campaign.toml has a ``[campaign]`` table (``load_campaign`` raises on anything else); a journeys.toml
25
+ has a ``[hub]`` table and/or ``[[journey]]`` rows (``load_journeys`` raises otherwise); a battle.toml
26
+ has a ``[battlemap]`` table; else it's a field.toml -- the cheap, exact discriminators (the four kinds
27
+ are table-disjoint, so the order is just for readability). Mirrors the tkinter Build GUI + the journey
28
+ front door."""
29
+ try:
30
+ from ..campaign import load_campaign
31
+ return "campaign", load_campaign(path)
32
+ except Exception:
33
+ pass
34
+ try:
35
+ from ..journey import load_journeys
36
+ return "journey", load_journeys(path)
37
+ except Exception:
38
+ pass
39
+ try:
40
+ with open(path, "rb") as fh:
41
+ if "battlemap" in tomllib.load(fh):
42
+ return "battle", None
43
+ except Exception:
44
+ pass
45
+ return "field", None
46
+
47
+
48
+ def field_id_name(path):
49
+ """``(id, name)`` from a field.toml's ``[field]`` table, or ``(None, None)`` -- a light parse."""
50
+ try:
51
+ d = tomllib.loads(Path(path).read_text(encoding="utf-8"))
52
+ f = d.get("field", {}) or {}
53
+ return (f.get("id"), f.get("name"))
54
+ except Exception:
55
+ return (None, None)
56
+
57
+
58
+ # --------------------------------------------------------------------------- install / deploy targets
59
+ def detect_game_mod():
60
+ """The game's ``FF9CustomMap`` folder, or ``None`` if the install can't be found."""
61
+ try:
62
+ from .. import config
63
+ return config.find_game_path() / "FF9CustomMap"
64
+ except Exception:
65
+ return None
66
+
67
+
68
+ def detect_deploy_target(repo_root):
69
+ """``(mod_folder, field_id)`` from this worktree's ``.ff9deploy.toml``, or sane defaults -- the test
70
+ slot the field deploy and battle deploy write into."""
71
+ mod, fid = "FF9CustomMap", None
72
+ f = Path(repo_root) / ".ff9deploy.toml"
73
+ if f.is_file():
74
+ try:
75
+ d = tomllib.loads(f.read_text(encoding="utf-8"))
76
+ mod = d.get("mod_folder", mod) or mod
77
+ fid = d.get("id")
78
+ except Exception:
79
+ pass
80
+ return mod, fid
81
+
82
+
83
+ def detect_deployed_fields(mod_folder):
84
+ """``[(id, name), ...]`` of the FieldScene lines in the worktree mod folder's DictionaryPatch -- the
85
+ fields whose encounter a battle-mint can repoint (the valid 'trigger field' choices)."""
86
+ out = []
87
+ try:
88
+ from .. import config
89
+ dp = config.find_game_path() / mod_folder / "DictionaryPatch.txt"
90
+ if dp.is_file():
91
+ for ln in dp.read_text(encoding="utf-8").splitlines():
92
+ p = ln.split()
93
+ if p[:1] == ["FieldScene"] and len(p) >= 5:
94
+ out.append((p[1], p[4]))
95
+ except Exception:
96
+ pass
97
+ return out
98
+
99
+
100
+ def latest_battle_revert(repo_root):
101
+ """The most recently written ``tools/scroll_out/revert_battle_*.py``, or ``None``."""
102
+ scroll = Path(repo_root) / "tools" / "scroll_out"
103
+ scripts = sorted(scroll.glob("revert_battle_*.py"), key=lambda p: p.stat().st_mtime, reverse=True)
104
+ return scripts[0] if scripts else None
105
+
106
+
107
+ def latest_journey_revert(repo_root):
108
+ """The most recently written journey revert script, or ``None``.
109
+
110
+ A journey deploy writes ONE of two reverts depending on the mode: the full ``--apply`` one-shot writes the
111
+ unified ``revert_journey.py``; a standalone ``--apply-links`` writes only ``revert_journey_links.py``. The
112
+ GUI Revert button must undo the user's LAST journey action, so we pick the most-recently-modified of the
113
+ two (mirrors :func:`latest_battle_revert`) -- never a stale unified revert left over from an earlier run."""
114
+ scroll = Path(repo_root) / "tools" / "scroll_out"
115
+ cands = [p for p in (scroll / "revert_journey.py", scroll / "revert_journey_links.py") if p.is_file()]
116
+ return max(cands, key=lambda p: p.stat().st_mtime) if cands else None
117
+
118
+
119
+ # --------------------------------------------------------------------------- import argv (FFIX Import)
120
+ def import_args(field, *, out, field_id, name=None, art="native", carry_npcs=True, carry_text=True,
121
+ dialogue_stubs=False, save_moogle=False, verbatim=False,
122
+ swap_player=None, neutralize_gestures=False):
123
+ """The ``ff9mapkit import ...`` argv for a field fork (no ``py -m ff9mapkit`` prefix).
124
+
125
+ ``verbatim`` = the TRUEST fork (``--verbatim``): ship the donor's whole ``.eb`` + ``.mes`` and run the
126
+ real logic (story gating, rotating cast, real doors -- the proven faithful path, docs/FORK_FIDELITY.md).
127
+ It implies ``--native`` and carries every NPC/prop/line itself, so the ``art``/carry options DON'T apply
128
+ and we emit ONLY ``--verbatim`` (a short, honest command). ``art``/carry below are the RE-AUTHORABLE path:
129
+ ``art`` is 'native' (--native) / 'borrow' (neither flag) / 'editable' (--editable); the carry flags map to
130
+ the fidelity options, and --carry-text / --save-moogle imply --graft-player-funcs (kit-enforced, passed
131
+ explicitly so the command reads honestly).
132
+
133
+ ``swap_player`` (--swap-player WHO) changes who you WALK as -- a playable name or any GEO model; it implies
134
+ ``--verbatim`` in the CLI, so the flags go BEFORE the verbatim early-return (they apply to either path).
135
+ ``neutralize_gestures`` (--neutralize-gestures) rewrites the swapped rig's scripted gestures to idle; the
136
+ CLI requires it be paired with ``swap_player`` (the GUI guards this before building the argv)."""
137
+ args = ["import", str(field), "--out", str(out), "--id", str(field_id)]
138
+ if name:
139
+ args += ["--name", str(name)]
140
+ if swap_player:
141
+ args += ["--swap-player", str(swap_player)]
142
+ if neutralize_gestures:
143
+ args.append("--neutralize-gestures")
144
+ if verbatim:
145
+ args.append("--verbatim")
146
+ return args
147
+ if art == "native":
148
+ args.append("--native")
149
+ elif art == "editable":
150
+ args.append("--editable")
151
+ if carry_npcs or carry_text or save_moogle:
152
+ args.append("--graft-player-funcs")
153
+ if carry_text:
154
+ args.append("--carry-text")
155
+ if dialogue_stubs:
156
+ args.append("--dialogue")
157
+ if save_moogle:
158
+ args.append("--save-moogle")
159
+ return args
160
+
161
+
162
+ def import_chain_args(seeds, *, out=None, whole_zone=True, ids=None, verbatim=True, id_base=None,
163
+ name_prefix=None, fresh_ids=False, flags_per_field=None, max_fields=None,
164
+ campaign_name=None, swap_player=None, neutralize_gestures=False):
165
+ """The ``ff9mapkit import-chain ...`` argv for forking a CONNECTED REGION (a multi-field chain) into ONE
166
+ campaign -- the workflow behind the disc-1 opening, now a GUI action.
167
+
168
+ ``seeds`` is the raw seed string ('300', '50,100,64', or an FBG substring). With no ``out`` it's the
169
+ DRY-RUN (prints the blast radius + coverage, touches nothing) -- the region analogue of fork-report.
170
+ ``ids`` (a compact range string, e.g. '100-117') scopes the fork to an EXPLICIT id set -- one story-state
171
+ cluster of a revisited zone, not all its visits; it takes precedence over and suppresses ``whole_zone``.
172
+ Otherwise ``whole_zone`` seeds every field in each seed's zone (catches cutscene-only screens the door-walk
173
+ misses; it also auto-raises the walk's --max-fields to fit). ``verbatim`` ships each member's real .eb +
174
+ .mes so the chain runs the real logic. STABLE IDS are the kit DEFAULT (re-forking into an existing ``out``
175
+ reuses its donor->id+name map so in-fork saves survive) -- ``fresh_ids`` opts out (re-number from scratch).
176
+
177
+ ``swap_player`` (--swap-player WHO) plays the WHOLE chain as one character/model (implies --verbatim);
178
+ ``neutralize_gestures`` (--neutralize-gestures) stands cleanly through cutscene gestures (requires a swap;
179
+ the GUI guards that before building the argv)."""
180
+ args = ["import-chain", str(seeds)]
181
+ if ids: # explicit cluster wins over whole-zone (the two are mutually exclusive)
182
+ args += ["--ids", str(ids)]
183
+ elif whole_zone:
184
+ args.append("--whole-zone")
185
+ if verbatim:
186
+ args.append("--verbatim")
187
+ if swap_player:
188
+ args += ["--swap-player", str(swap_player)]
189
+ if neutralize_gestures:
190
+ args.append("--neutralize-gestures")
191
+ if out:
192
+ args += ["--out", str(out)]
193
+ if id_base is not None:
194
+ args += ["--id-base", str(id_base)]
195
+ if name_prefix:
196
+ args += ["--name-prefix", str(name_prefix)]
197
+ if flags_per_field is not None:
198
+ args += ["--flags-per-field", str(flags_per_field)]
199
+ if max_fields is not None:
200
+ args += ["--max-fields", str(max_fields)]
201
+ if campaign_name:
202
+ args += ["--campaign-name", str(campaign_name)]
203
+ if fresh_ids:
204
+ args.append("--fresh-ids")
205
+ return args
206
+
207
+
208
+ # --------------------------------------------------------------------------- deploy / revert argv
209
+ # Each returns a FULL argv whose [0] is the interpreter, so a QProcess can split it into
210
+ # program=argv[0], arguments=argv[1:], and a subprocess can run it as-is.
211
+ def _tool(repo_root, *parts):
212
+ return str(Path(repo_root, "tools", *parts))
213
+
214
+
215
+ def build_argv(field, out, *, mod_name="FF9CustomMap"):
216
+ """``ff9mapkit build`` a single field.toml into ``out`` (the 'build only' target, no deploy)."""
217
+ return [sys.executable, "-m", "ff9mapkit", "build", str(field), "--out", str(out),
218
+ "--mod-name", mod_name]
219
+
220
+
221
+ def build_campaign_argv(path):
222
+ """``ff9mapkit build-all`` -- compile every member of a campaign into its dist/ (no deploy)."""
223
+ return [sys.executable, "-m", "ff9mapkit", "build-all", str(path)]
224
+
225
+
226
+ def deploy_field_argv(repo_root, field):
227
+ """Reversibly deploy a field.toml into this worktree's test slot (``tools/deploy_field.py``)."""
228
+ return [sys.executable, _tool(repo_root, "deploy_field.py"), str(field)]
229
+
230
+
231
+ def deploy_campaign_argv(repo_root, path, *, wire_newgame=False):
232
+ """Reversibly deploy a whole campaign (``tools/deploy_campaign.py --apply``)."""
233
+ a = [sys.executable, _tool(repo_root, "deploy_campaign.py"), str(path), "--apply"]
234
+ if not wire_newgame:
235
+ a.append("--no-warp")
236
+ return a
237
+
238
+
239
+ def deploy_battle_argv(repo_root, battle, *, trigger=None):
240
+ """Reversibly deploy a battle map (``tools/deploy_battle.py``), optionally repointing a trigger field."""
241
+ a = [sys.executable, _tool(repo_root, "deploy_battle.py"), str(battle)]
242
+ if trigger:
243
+ a += ["--trigger-field", str(trigger)]
244
+ return a
245
+
246
+
247
+ def fork_command_argv(command, *, out_abs=None):
248
+ """Turn a reference-arc playbook line (``import-chain <seed> --out <key> ...``, from
249
+ :func:`..refarc.parse_fork_commands`) into a runnable argv: ``[python, -m, ff9mapkit, import-chain, ...]``.
250
+ With ``out_abs`` the ``--out`` value is rewritten to that absolute path, so the fork can run from the kit
251
+ root (the local-package shadow) yet still land the campaign folder beside the journeys.toml."""
252
+ import shlex
253
+ parts = shlex.split(str(command))
254
+ if out_abs is not None and "--out" in parts:
255
+ i = parts.index("--out")
256
+ if i + 1 < len(parts):
257
+ parts[i + 1] = str(out_abs)
258
+ return [sys.executable, "-m", "ff9mapkit", *parts]
259
+
260
+
261
+ def deploy_journey_argv(repo_root, journeys, *, apply=False, newgame="none", wire_newgame=False, apply_links=False,
262
+ single_folder=False):
263
+ """Deploy (or dry-run) a multi-campaign journey manifest via ``tools/deploy_journey.py``.
264
+
265
+ Default (no flags) = a DRY-RUN that lints + prints the ordered deploy playbook (no game files touched).
266
+ ``apply`` = the ONE-SHOT deploy (every campaign into its own stacked folder, the cross-campaign links,
267
+ then the hub field -- one unified revert). ``newgame`` (gated under ``--apply``) chooses where New Game
268
+ lands -- SINGLE-OWNER, replaces the current target: ``"none"`` (unchanged, reach the hub via F6), ``"hub"``
269
+ (the hub selector menu, seamless), or ``"entry"`` (STRAIGHT into the opening field, no menu -- single-journey
270
+ only; keeps the real opening FMV). ``wire_newgame=True`` is a back-compat alias for ``newgame="hub"``.
271
+ ``apply_links`` = re-apply ONLY the cross-campaign link ``.eb`` remaps (run after a campaign re-deploy).
272
+ ``single_folder`` (with ``apply``) = MERGE the whole journey into ONE stacked mod folder (a single
273
+ FolderNames entry) instead of one folder per campaign."""
274
+ mode = newgame if (newgame and newgame != "none") else "none"
275
+ a = [sys.executable, _tool(repo_root, "deploy_journey.py"), str(journeys)]
276
+ if apply:
277
+ a.append("--apply")
278
+ if single_folder:
279
+ a.append("--single-folder")
280
+ if mode != "none":
281
+ a += ["--newgame", mode]
282
+ elif wire_newgame: # back-compat alias (deploy_journey maps --wire-newgame -> hub)
283
+ a.append("--wire-newgame")
284
+ elif apply_links:
285
+ a.append("--apply-links")
286
+ return a
287
+
288
+
289
+ def revert_field_argv(repo_root):
290
+ return [sys.executable, _tool(repo_root, "scroll_out", "revert_deploy.py")]
291
+
292
+
293
+ def revert_campaign_argv(repo_root):
294
+ return [sys.executable, _tool(repo_root, "scroll_out", "revert_campaign.py")]
295
+
296
+
297
+ def revert_journey_argv(repo_root):
298
+ """The interpreter + the MOST RECENT journey revert script (the unified ``revert_journey.py`` from a full
299
+ ``--apply``, or the links-only ``revert_journey_links.py`` from ``--apply-links``), or ``None`` if no
300
+ journey deploy is undoable yet. Picking by mtime (like :func:`revert_battle_argv`) means the GUI Revert
301
+ undoes the user's LAST journey action, never a stale earlier unified revert."""
302
+ s = latest_journey_revert(repo_root)
303
+ return [sys.executable, str(s)] if s else None
304
+
305
+
306
+ def newgame_from_stock_argv(repo_root, field_id):
307
+ """Point New Game at a deployed field id by CREATING the field-70 override from STOCK
308
+ (``tools/wire_newgame_from_stock.py``) -- the robust path: it extracts stock field 70, repoints its
309
+ terminal ``Field(50)``->``Field(<id>)`` (all 7 langs, the opening FMV+fade preserved), and works even when
310
+ NO override exists yet (a clean install, or after a fresh wholesale campaign deploy wiped it). This is the
311
+ disc-1-proven New-Game wiring; the patch-only :func:`newgame_retarget_argv` no-ops when there's nothing to
312
+ patch. Reversible (writes ``revert_newgame_from_stock.py``)."""
313
+ return [sys.executable, _tool(repo_root, "wire_newgame_from_stock.py"), str(field_id)]
314
+
315
+
316
+ def newgame_retarget_argv(repo_root, field_id):
317
+ """Point New Game straight at a deployed field id by PATCHING an existing field-70 override
318
+ (``tools/retarget_newgame_warp.py``). NO-OPS when no override exists -- prefer
319
+ :func:`newgame_from_stock_argv` (create-from-stock) for a fresh fork. Reversible."""
320
+ return [sys.executable, _tool(repo_root, "retarget_newgame_warp.py"), str(field_id)]
321
+
322
+
323
+ def latest_newgame_revert(repo_root):
324
+ """The most-recent New-Game revert script -- the create-from-stock ``revert_newgame_from_stock.py`` OR the
325
+ patch ``revert_newgame_retarget.py`` -- by mtime (like :func:`latest_journey_revert`), or ``None``. So the
326
+ GUI Revert undoes whichever New-Game action ran LAST, regardless of which wiring tool wrote it."""
327
+ scroll = Path(repo_root) / "tools" / "scroll_out"
328
+ cands = [p for p in (scroll / "revert_newgame_from_stock.py", scroll / "revert_newgame_retarget.py")
329
+ if p.is_file()]
330
+ return max(cands, key=lambda p: p.stat().st_mtime) if cands else None
331
+
332
+
333
+ def revert_newgame_argv(repo_root):
334
+ """The interpreter + the most-recent New-Game revert script (from-stock or retarget), or ``None``."""
335
+ s = latest_newgame_revert(repo_root)
336
+ return [sys.executable, str(s)] if s else None
337
+
338
+
339
+ def revert_battle_argv(repo_root):
340
+ """The interpreter + the latest ``revert_battle_*.py``, or ``None`` if no battle deploy to undo."""
341
+ s = latest_battle_revert(repo_root)
342
+ return [sys.executable, str(s)] if s else None
@@ -0,0 +1,243 @@
1
+ """The editor's data model: load / edit / serialize a ``field.toml`` (bpy/tk-FREE, fully testable).
2
+
3
+ The kit reads TOML with the stdlib ``tomllib`` (read-only). For writing we ship a small, schema-aware
4
+ serializer (:func:`dumps`) so the editor regenerates a clean ``field.toml`` with **zero new
5
+ dependencies** -- no ``tomli_w``/``tomlkit``. The contract that makes it safe is round-trip equality:
6
+
7
+ tomllib.loads(dumps(d)) == d # for every value type the field.toml schema uses
8
+
9
+ (see ``tests/test_editor_model.py``: proven on a representative doc AND every bundled example).
10
+
11
+ :class:`FieldDoc` wraps a loaded field.toml. It edits + saves the **logic** file only; a sibling
12
+ ``<stem>.scene.toml`` (Blender-owned, spatial) is loaded read-only for the merged display view and is
13
+ never written, so the editor can't clobber a Blender scene. The merged view reuses the kit's own
14
+ ``build._merge_scene`` so what the editor shows is exactly what ``ff9mapkit build`` will compile.
15
+
16
+ NOTE: regenerating the file drops hand-written comments (the intended audience edits via the UI, not
17
+ the text). The data always round-trips.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import tomllib
23
+ from pathlib import Path
24
+
25
+ # Dict values emitted INLINE as ``key = {..}`` (small value-tables, not their own [section]).
26
+ _INLINE_TABLE_KEYS = frozenset({"anims", "scene", "scroll", "frame"})
27
+ # List-of-table values emitted as a multiline inline-table array ``key = [ {..}, {..} ]``.
28
+ _INLINE_AOT_KEYS = frozenset({"steps"})
29
+
30
+ # Canonical section order for readable output (unknown keys keep their insertion order, appended).
31
+ _ROOT_ORDER = ("field", "camera", "walkmesh", "layers", "player", "npc", "gateway", "event",
32
+ "choice", "camera_zone", "encounter", "music", "cutscene", "scene")
33
+
34
+
35
+ # --------------------------------------------------------------------------- serializer
36
+ def _fmt_str(s) -> str:
37
+ """A TOML basic-string literal with the special characters escaped."""
38
+ s = (str(s).replace("\\", "\\\\").replace('"', '\\"')
39
+ .replace("\n", "\\n").replace("\t", "\\t").replace("\r", "\\r"))
40
+ return f'"{s}"'
41
+
42
+
43
+ def _fmt_value(v) -> str:
44
+ """Any TOML value as an inline literal: scalar, array, or inline table (recursive)."""
45
+ if isinstance(v, bool):
46
+ return "true" if v else "false"
47
+ if isinstance(v, int):
48
+ return str(v)
49
+ if isinstance(v, float):
50
+ return repr(v) # shortest round-trip repr (Python >= 3.1)
51
+ if isinstance(v, str):
52
+ return _fmt_str(v)
53
+ if isinstance(v, dict):
54
+ return "{" + ", ".join(f"{k} = {_fmt_value(x)}" for k, x in v.items()) + "}"
55
+ if isinstance(v, (list, tuple)):
56
+ return "[" + ", ".join(_fmt_value(x) for x in v) + "]"
57
+ raise TypeError(f"cannot serialize {type(v).__name__} to TOML: {v!r}")
58
+
59
+
60
+ def _is_aot(v) -> bool:
61
+ """True if v is a non-empty array of tables (list of dicts)."""
62
+ return isinstance(v, (list, tuple)) and len(v) > 0 and all(isinstance(x, dict) for x in v)
63
+
64
+
65
+ def _fmt_inline_aot(key, items) -> str:
66
+ """A readable multiline inline-table array, e.g. cutscene ``steps``."""
67
+ lines = [f"{key} = ["]
68
+ for it in items:
69
+ lines.append(" " + _fmt_value(it) + ",")
70
+ lines.append("]")
71
+ return "\n".join(lines)
72
+
73
+
74
+ def _ordered_items(table, root_order=_ROOT_ORDER):
75
+ """(key, value) pairs in canonical root order first (for the top level), else insertion order."""
76
+ keys = list(table.keys())
77
+ if any(k in root_order for k in keys):
78
+ rank = {k: i for i, k in enumerate(root_order)}
79
+ keys.sort(key=lambda k: (rank.get(k, len(root_order)), )) # stable: unknown keep order
80
+ return [(k, table[k]) for k in keys]
81
+
82
+
83
+ def _emit_table(path, table, out, *, ordered=False, inline_keys=_INLINE_TABLE_KEYS, root_order=_ROOT_ORDER):
84
+ """Emit one table's scalars/inline values, then its sub-tables / arrays-of-tables as sections."""
85
+ items = _ordered_items(table, root_order) if ordered else list(table.items())
86
+ deferred = []
87
+ for k, v in items:
88
+ if isinstance(v, dict) and k not in inline_keys:
89
+ deferred.append((k, v, "table"))
90
+ elif _is_aot(v) and k not in _INLINE_AOT_KEYS:
91
+ deferred.append((k, v, "aot"))
92
+ elif _is_aot(v) and k in _INLINE_AOT_KEYS:
93
+ out.append(_fmt_inline_aot(k, v))
94
+ else:
95
+ out.append(f"{k} = {_fmt_value(v)}")
96
+ for k, v, kind in deferred:
97
+ sub = f"{path}.{k}" if path else k
98
+ if kind == "table":
99
+ out.append("")
100
+ out.append(f"[{sub}]")
101
+ _emit_table(sub, v, out, inline_keys=inline_keys, root_order=root_order)
102
+ else:
103
+ for elem in v:
104
+ out.append("")
105
+ out.append(f"[[{sub}]]")
106
+ _emit_table(sub, elem, out, inline_keys=inline_keys, root_order=root_order)
107
+
108
+
109
+ def dumps(data: dict, *, inline_table_keys=_INLINE_TABLE_KEYS, root_order=_ROOT_ORDER) -> str:
110
+ """Serialize a TOML dict to text (round-trip-safe; canonical section order).
111
+
112
+ ``inline_table_keys`` / ``root_order`` default to the field.toml schema. A document with a DIFFERENT schema
113
+ overrides them -- e.g. a **battle.toml** passes ``inline_table_keys=frozenset()`` so its big ``[scene]``
114
+ FORMATION table (+ ``[[scene.enemy]]`` etc.) emit as real sections, not one ``scene = {...}`` line (the
115
+ field.toml ``scene`` is a small inline Blender-ref, a name collision), and ``root_order=("battlemap","scene")``
116
+ to lead with the map identity."""
117
+ out: list[str] = []
118
+ _emit_table("", data, out, ordered=True, inline_keys=inline_table_keys, root_order=root_order)
119
+ return "\n".join(out).strip("\n") + "\n"
120
+
121
+
122
+ def loads(text: str) -> dict:
123
+ """Parse TOML text into a dict (thin wrapper over tomllib)."""
124
+ return tomllib.loads(text)
125
+
126
+
127
+ # --------------------------------------------------------------------------- save guard
128
+ def _within(p: Path, base: Path) -> bool:
129
+ """True if ``p`` is ``base`` or sits underneath it (lexical; no filesystem access)."""
130
+ try:
131
+ return p == base or p.is_relative_to(base)
132
+ except ValueError:
133
+ return False
134
+
135
+
136
+ def protected_reason(path) -> "str | None":
137
+ """A reason string if ``path`` is a location the editor must NOT overwrite, else None.
138
+
139
+ Guards the footgun where Save clobbers a shipped asset (e.g. the golden
140
+ ``examples/vivi-hut/hut_int.field.toml``) or an installed package file -- both have bitten us.
141
+ Pure + unit-testable (no Tk). Author on a copy, or scaffold a fresh project with
142
+ ``ff9mapkit new``.
143
+ """
144
+ try:
145
+ p = Path(path).resolve()
146
+ except (OSError, ValueError):
147
+ return None
148
+ if any(part.lower() in ("site-packages", "dist-packages") for part in p.parts):
149
+ return "that path is inside Python's site-packages (an installed copy of the kit)"
150
+ pkg = Path(__file__).resolve().parents[1] # the importable `ff9mapkit` package dir
151
+ if _within(p, pkg):
152
+ return "that path is inside the installed ff9mapkit package"
153
+ if _within(p, pkg.parent / "examples"): # bundled examples in a source checkout
154
+ return "that is a bundled example -- edit a copy, or scaffold one with `ff9mapkit new`"
155
+ return None
156
+
157
+
158
+ # --------------------------------------------------------------------------- the document
159
+ def _find_scene_path(field_path: Path, data: dict):
160
+ """The sibling scene file for a field.toml: explicit ``[scene] file`` wins, else ``<stem>.scene.toml``
161
+ (``<x>.field.toml`` -> ``<x>.scene.toml``). Returns a Path (may not exist) or None."""
162
+ ref = data.get("scene", {}).get("file")
163
+ if ref:
164
+ return (field_path.parent / ref)
165
+ stem = field_path.name
166
+ stem = stem[:-len(".field.toml")] if stem.endswith(".field.toml") else field_path.stem
167
+ return field_path.parent / f"{stem}.scene.toml"
168
+
169
+
170
+ class FieldDoc:
171
+ """An open field.toml: its raw logic ``data`` (edited + saved) + a read-only Blender ``scene``.
172
+
173
+ Edit ``data`` (or via the section helpers), then :meth:`save`. The scene file is never written.
174
+ :meth:`merged` is the build-accurate view (logic + scene spatial), for display/validation.
175
+ """
176
+
177
+ def __init__(self, path: Path, data: dict, scene_path=None, scene_data=None):
178
+ self.path = Path(path)
179
+ self.data = data
180
+ self.scene_path = Path(scene_path) if scene_path else None
181
+ self.scene_data = scene_data
182
+
183
+ # ---- io ----
184
+ @classmethod
185
+ def load(cls, path) -> "FieldDoc":
186
+ path = Path(path)
187
+ with path.open("rb") as fh:
188
+ data = tomllib.load(fh)
189
+ sp = _find_scene_path(path, data)
190
+ scene_data = None
191
+ if sp and sp.is_file():
192
+ with sp.open("rb") as fh:
193
+ scene_data = tomllib.load(fh)
194
+ else:
195
+ sp = None
196
+ return cls(path, data, sp, scene_data)
197
+
198
+ @classmethod
199
+ def new(cls, path, field_id=4003, name="MY_ROOM", area=11, text_block=1073) -> "FieldDoc":
200
+ """A fresh in-memory doc (not yet written) with a minimal [field] + a borrow camera stub."""
201
+ data = {"field": {"id": int(field_id), "name": str(name), "area": int(area),
202
+ "text_block": int(text_block)},
203
+ "camera": {"borrow": "camera.bgx"}}
204
+ return cls(Path(path), data)
205
+
206
+ def save(self) -> None:
207
+ """Write the logic file (``data``) as TOML. The scene file is left untouched."""
208
+ self.path.write_text(dumps(self.data), encoding="utf-8", newline="\n")
209
+
210
+ def to_text(self) -> str:
211
+ """The TOML text that :meth:`save` would write (for previews/diffs)."""
212
+ return dumps(self.data)
213
+
214
+ # ---- merged (build-accurate) view ----
215
+ def merged(self) -> dict:
216
+ """The field overlaid with its Blender scene (spatial), exactly as ``ff9mapkit build`` sees it.
217
+ Reuses the kit's own merge so the editor's display matches the compiler."""
218
+ if self.scene_data is None:
219
+ return self.data
220
+ from .. import build # lazy: avoid importing the builder unless needed
221
+ return build._merge_scene(self.data, self.scene_data)
222
+
223
+ # ---- section helpers (operate on the editable logic ``data``) ----
224
+ @property
225
+ def field(self) -> dict:
226
+ return self.data.setdefault("field", {})
227
+
228
+ def section(self, name: str) -> dict:
229
+ """Get-or-create a single-table section ([camera]/[encounter]/[music]/[cutscene]/...)."""
230
+ return self.data.setdefault(name, {})
231
+
232
+ def list_section(self, name: str) -> list:
233
+ """Get-or-create an array-of-tables section ([[npc]]/[[gateway]]/[[event]]/...)."""
234
+ return self.data.setdefault(name, [])
235
+
236
+ def remove_section(self, name: str) -> None:
237
+ self.data.pop(name, None)
238
+
239
+ def scene_entities(self, name: str) -> dict:
240
+ """Scene-side spatial entities of a kind, keyed by ``name`` (for showing pos/zone), {} if none."""
241
+ if not self.scene_data:
242
+ return {}
243
+ return {e["name"]: e for e in self.scene_data.get(name, []) if "name" in e}