ff9mapkit 1.0.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. ff9mapkit/__init__.py +18 -0
  2. ff9mapkit/__main__.py +36 -0
  3. ff9mapkit/_animdb.py +2994 -0
  4. ff9mapkit/_animdb_all.py +14125 -0
  5. ff9mapkit/_fieldtable.py +1516 -0
  6. ff9mapkit/_fieldtext.py +845 -0
  7. ff9mapkit/_held_poses.py +44 -0
  8. ff9mapkit/_itemdb.py +65 -0
  9. ff9mapkit/_modeldb.py +725 -0
  10. ff9mapkit/_narrowmap_data.py +10 -0
  11. ff9mapkit/_npcparams.py +634 -0
  12. ff9mapkit/_regen_animdb.py +72 -0
  13. ff9mapkit/_regen_animdb_all.py +66 -0
  14. ff9mapkit/_regen_fieldtable.py +95 -0
  15. ff9mapkit/_regen_fieldtext.py +66 -0
  16. ff9mapkit/_regen_modeldb.py +67 -0
  17. ff9mapkit/_regen_npcparams.py +123 -0
  18. ff9mapkit/_regen_scenedb.py +57 -0
  19. ff9mapkit/_scenedb.py +869 -0
  20. ff9mapkit/abilities.py +225 -0
  21. ff9mapkit/animations.py +120 -0
  22. ff9mapkit/archetypes.py +218 -0
  23. ff9mapkit/areatitle.py +76 -0
  24. ff9mapkit/battle/__init__.py +21 -0
  25. ff9mapkit/battle/abilityfeatures.py +294 -0
  26. ff9mapkit/battle/actiondelta.py +441 -0
  27. ff9mapkit/battle/aiauthor.py +305 -0
  28. ff9mapkit/battle/ailint.py +140 -0
  29. ff9mapkit/battle/aipatch.py +175 -0
  30. ff9mapkit/battle/battleai.py +148 -0
  31. ff9mapkit/battle/battlecsv.py +390 -0
  32. ff9mapkit/battle/battlepatch.py +395 -0
  33. ff9mapkit/battle/build.py +558 -0
  34. ff9mapkit/battle/camera_codec.py +332 -0
  35. ff9mapkit/battle/camera_data.py +128 -0
  36. ff9mapkit/battle/characterdelta.py +789 -0
  37. ff9mapkit/battle/event_data.py +72 -0
  38. ff9mapkit/battle/extract.py +540 -0
  39. ff9mapkit/battle/fbx.py +223 -0
  40. ff9mapkit/battle/reskin.py +149 -0
  41. ff9mapkit/battle/scene_codec.py +314 -0
  42. ff9mapkit/battle/scene_data.py +369 -0
  43. ff9mapkit/battle/scenelint.py +125 -0
  44. ff9mapkit/battle/seqasm.py +131 -0
  45. ff9mapkit/battle/seqauthor.py +220 -0
  46. ff9mapkit/battle/seqcodec.py +300 -0
  47. ff9mapkit/battle/seqdis.py +106 -0
  48. ff9mapkit/battle/seqpatch.py +137 -0
  49. ff9mapkit/battle_bgm.py +133 -0
  50. ff9mapkit/binutils.py +60 -0
  51. ff9mapkit/build.py +5445 -0
  52. ff9mapkit/campaign.py +1276 -0
  53. ff9mapkit/catalog.py +316 -0
  54. ff9mapkit/chain.py +358 -0
  55. ff9mapkit/cli.py +3114 -0
  56. ff9mapkit/config.py +360 -0
  57. ff9mapkit/content/__init__.py +13 -0
  58. ff9mapkit/content/areatitle.py +36 -0
  59. ff9mapkit/content/ate.py +118 -0
  60. ff9mapkit/content/camera.py +123 -0
  61. ff9mapkit/content/chest.py +186 -0
  62. ff9mapkit/content/choice.py +163 -0
  63. ff9mapkit/content/conductor.py +217 -0
  64. ff9mapkit/content/cutscene.py +290 -0
  65. ff9mapkit/content/encounter.py +41 -0
  66. ff9mapkit/content/entry_settle.py +50 -0
  67. ff9mapkit/content/equipment.py +93 -0
  68. ff9mapkit/content/event.py +191 -0
  69. ff9mapkit/content/gateway.py +101 -0
  70. ff9mapkit/content/inventory.py +59 -0
  71. ff9mapkit/content/itemdata.py +644 -0
  72. ff9mapkit/content/itemtext.py +168 -0
  73. ff9mapkit/content/jump.py +114 -0
  74. ff9mapkit/content/ladder.py +633 -0
  75. ff9mapkit/content/movement.py +53 -0
  76. ff9mapkit/content/music.py +97 -0
  77. ff9mapkit/content/npc.py +348 -0
  78. ff9mapkit/content/object.py +340 -0
  79. ff9mapkit/content/onentry.py +135 -0
  80. ff9mapkit/content/party.py +111 -0
  81. ff9mapkit/content/pathfind.py +138 -0
  82. ff9mapkit/content/platform.py +314 -0
  83. ff9mapkit/content/player.py +168 -0
  84. ff9mapkit/content/prop.py +75 -0
  85. ff9mapkit/content/region.py +340 -0
  86. ff9mapkit/content/reinit.py +59 -0
  87. ff9mapkit/content/savepoint.py +90 -0
  88. ff9mapkit/content/shop.py +178 -0
  89. ff9mapkit/content/sps_trigger.py +66 -0
  90. ff9mapkit/content/startup.py +71 -0
  91. ff9mapkit/content/synthesis.py +106 -0
  92. ff9mapkit/content/text.py +183 -0
  93. ff9mapkit/content/textcarry.py +290 -0
  94. ff9mapkit/content/verbatim.py +86 -0
  95. ff9mapkit/content/walkmesh_hotfix.py +38 -0
  96. ff9mapkit/data/__init__.py +48 -0
  97. ff9mapkit/data/_regen_provenance.py +142 -0
  98. ff9mapkit/data/provenance/blank.es.patch +1 -0
  99. ff9mapkit/data/provenance/blank.fr.patch +1 -0
  100. ff9mapkit/data/provenance/blank.gr.patch +1 -0
  101. ff9mapkit/data/provenance/blank.it.patch +1 -0
  102. ff9mapkit/data/provenance/blank.jp.patch +1 -0
  103. ff9mapkit/data/provenance/blank.uk.patch +1 -0
  104. ff9mapkit/data/provenance/blank.us.patch +1 -0
  105. ff9mapkit/data/provenance/manifest.json +65 -0
  106. ff9mapkit/data/provenance/region_template.patch +1 -0
  107. ff9mapkit/data/reference_arcs.toml +89 -0
  108. ff9mapkit/data/region_catalog.toml +593 -0
  109. ff9mapkit/deploystack.py +358 -0
  110. ff9mapkit/dialogue.py +803 -0
  111. ff9mapkit/eb/__init__.py +12 -0
  112. ff9mapkit/eb/_exprtable.py +59 -0
  113. ff9mapkit/eb/_membertable.py +38 -0
  114. ff9mapkit/eb/_optables.py +537 -0
  115. ff9mapkit/eb/_regen_optables.py +76 -0
  116. ff9mapkit/eb/cmdasm.py +323 -0
  117. ff9mapkit/eb/disasm.py +332 -0
  118. ff9mapkit/eb/edit.py +439 -0
  119. ff9mapkit/eb/exprasm.py +158 -0
  120. ff9mapkit/eb/model.py +178 -0
  121. ff9mapkit/eb/opcodes.py +463 -0
  122. ff9mapkit/eblint.py +177 -0
  123. ff9mapkit/editor/__init__.py +20 -0
  124. ff9mapkit/editor/app.py +950 -0
  125. ff9mapkit/editor/battle_forms.py +240 -0
  126. ff9mapkit/editor/breadcrumb.py +89 -0
  127. ff9mapkit/editor/dialogs.py +116 -0
  128. ff9mapkit/editor/feedback.py +208 -0
  129. ff9mapkit/editor/forms.py +632 -0
  130. ff9mapkit/editor/graphview.py +350 -0
  131. ff9mapkit/editor/jobs.py +342 -0
  132. ff9mapkit/editor/model.py +243 -0
  133. ff9mapkit/editor/picker.py +120 -0
  134. ff9mapkit/editor/theme.py +212 -0
  135. ff9mapkit/eventscan.py +1441 -0
  136. ff9mapkit/extract.py +2279 -0
  137. ff9mapkit/flags.py +693 -0
  138. ff9mapkit/forkreport.py +1383 -0
  139. ff9mapkit/hub.py +477 -0
  140. ff9mapkit/idgated.py +101 -0
  141. ff9mapkit/infohub.py +580 -0
  142. ff9mapkit/items.py +63 -0
  143. ff9mapkit/itemstats.py +346 -0
  144. ff9mapkit/journey.py +1902 -0
  145. ff9mapkit/keyitems.py +93 -0
  146. ff9mapkit/logic_add.py +632 -0
  147. ff9mapkit/logic_edit.py +728 -0
  148. ff9mapkit/logic_map.py +526 -0
  149. ff9mapkit/pack.py +175 -0
  150. ff9mapkit/playerswap.py +231 -0
  151. ff9mapkit/prop_archetypes.py +228 -0
  152. ff9mapkit/provision.py +282 -0
  153. ff9mapkit/refarc.py +825 -0
  154. ff9mapkit/save.py +337 -0
  155. ff9mapkit/save_items.py +1673 -0
  156. ff9mapkit/scene/__init__.py +11 -0
  157. ff9mapkit/scene/arena.py +63 -0
  158. ff9mapkit/scene/bgart.py +140 -0
  159. ff9mapkit/scene/bgi.py +732 -0
  160. ff9mapkit/scene/bgs.py +174 -0
  161. ff9mapkit/scene/bgx.py +185 -0
  162. ff9mapkit/scene/cam.py +345 -0
  163. ff9mapkit/scene/guide.py +311 -0
  164. ff9mapkit/scene/paint.py +506 -0
  165. ff9mapkit/scene/placeholder.py +107 -0
  166. ff9mapkit/sjbinary.py +285 -0
  167. ff9mapkit/sps/__init__.py +17 -0
  168. ff9mapkit/sps/author.py +294 -0
  169. ff9mapkit/sps/catalog.py +88 -0
  170. ff9mapkit/sps/codec.py +264 -0
  171. ff9mapkit/sps/edit.py +184 -0
  172. ff9mapkit/sps/lint.py +58 -0
  173. ff9mapkit/sps/render.py +116 -0
  174. ff9mapkit/sps/templates.py +47 -0
  175. ff9mapkit/sps/texture.py +131 -0
  176. ff9mapkit/walkmesh_hotfixes.py +163 -0
  177. ff9mapkit/workspace/__init__.py +18 -0
  178. ff9mapkit/workspace/battledoc.py +985 -0
  179. ff9mapkit/workspace/builddoc.py +607 -0
  180. ff9mapkit/workspace/forms_qt.py +586 -0
  181. ff9mapkit/workspace/importdoc.py +665 -0
  182. ff9mapkit/workspace/mapview.py +131 -0
  183. ff9mapkit/workspace/palette.py +85 -0
  184. ff9mapkit/workspace/savedoc.py +664 -0
  185. ff9mapkit/workspace/shell.py +6907 -0
  186. ff9mapkit/workspace/style.py +105 -0
  187. ff9mapkit/workspace/tuningdialog.py +223 -0
  188. ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
  189. ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
  190. ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
  191. ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
  192. ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
  193. ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
ff9mapkit/cli.py ADDED
@@ -0,0 +1,3114 @@
1
+ """``ff9mapkit`` command-line entry point.
2
+
3
+ Subcommands are wired up incrementally as the library lands:
4
+ doctor - show resolved game/mod paths and sanity-check the install (Phase 0)
5
+ disasm - disassemble a .eb field script (Phase 1)
6
+ camera - read/synthesize/round-trip a .bgx camera (Phase 2)
7
+ walkmesh - convert .obj->.bgi / fix neighbor links / verify a walkmesh (Phase 2)
8
+ guide - emit a paint guide + walkmesh-in-frame for a camera spec (Phase 2)
9
+ lint - check a field.toml's logic (story flags / dup names / placement) (P2)
10
+ build - compile a field.toml into a Memoria mod folder (Phase 4)
11
+ new - scaffold a new field project directory (Phase 5)
12
+ pack - package a built mod for distribution (Phase 5)
13
+ gen-hub - generate a World-Hub field.toml from a journeys.toml registry (P6)
14
+ lint-journey - validate a multi-campaign journeys.toml (id/flag disjointness, links resolve)
15
+ assemble-journey - lint + emit the World-Hub field for bare AND multi-campaign journeys
16
+ reference-arcs - FF9 reference-arc scaffold: emit a chained journeys.toml of FF9's real story arcs
17
+ extract-field - cache a real field's camera+walkmesh into the gitignored workspace cache
18
+ import - fork a real FF9 field (BG-borrow, or --editable custom scene) (Tier 3)
19
+ list-fields - list the real FF9 fields available to import (Tier 3)
20
+ find-field - resolve a field id / name / FBG substring -> id + friendly name + archive folder
21
+ battle-import - fork a real FF9 battle background (BBG) into an editable battle.toml (needs UnityPy)
22
+ battle-build - compile a battle.toml into a Memoria mod (custom 3D battle map; stock engine)
23
+ battle-list - list the real FF9 battle backgrounds available to fork
24
+ battle-actions- list the shared PLAYER abilities (Actions.csv) + the scriptId formula catalog
25
+ battle-scene - inspect a real battle scene's enemy data (stats/affinities/rewards/attacks)
26
+ dialogue - view a field.toml's authored dialogue + how each line wraps on screen
27
+ dialogue-import - read a REAL FF9 field's dialogue (or a built mod's) -- 'NPC -> text'
28
+ fork-report - preview, offline, what a fork of a REAL field will/won't reproduce (fidelity report)
29
+ animations/items - browse the cutscene-gesture / item catalogs by name
30
+ models/scenes/catalog - the Info Hub: browse models (+ their animations), battle scenes, or
31
+ search every reference catalog by name
32
+ extract-templates - regenerate base assets from the user's own FF9 install (no game data shipped)
33
+
34
+ Anything not yet implemented prints a clear "coming in Phase N" message rather than failing
35
+ with an import error, so the installed console script is always runnable.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import argparse
41
+ import sys
42
+
43
+ from . import __version__
44
+ from .config import ConfigError, ModLayout, find_game_path, find_mod_root
45
+ from .flags import FIRST_SAFE_FLAG # the census-grounded safe campaign flag floor (clear of real-FF9 flags)
46
+
47
+
48
+ def _has_unitypy() -> bool:
49
+ """True if UnityPy imports (the optional dep used by `import` / `list-fields`)."""
50
+ try:
51
+ import UnityPy # noqa: F401
52
+ return True
53
+ except ImportError:
54
+ return False
55
+
56
+
57
+ def _cmd_doctor(args: argparse.Namespace) -> int:
58
+ # Environment first, so these show even if the game path isn't configured yet.
59
+ print(f"ff9mapkit {__version__}")
60
+ print(f" UnityPy : {'present' if _has_unitypy() else 'absent (only needed for import / list-fields)'}")
61
+ try:
62
+ game = find_game_path(args.game)
63
+ except ConfigError as e:
64
+ print(str(e), file=sys.stderr)
65
+ return 2
66
+ mod_root = find_mod_root(game, args.mod_folder)
67
+ layout = ModLayout(mod_root)
68
+ print(f"game install : {game}")
69
+ print(f" exists : {game.is_dir()}")
70
+ launcher = game / "FF9_Launcher.exe"
71
+ print(f" launcher : {'found' if launcher.is_file() else 'MISSING'} ({launcher.name})")
72
+ streaming = game / "StreamingAssets"
73
+ print(f" assets : {'found' if streaming.is_dir() else 'MISSING'} (StreamingAssets)")
74
+ print(f"mod root : {mod_root}")
75
+ print(f" exists : {mod_root.is_dir()}")
76
+ print(f" FieldMaps : {layout.fieldmaps_dir}")
77
+ print(f" eb/field : {layout.eventbinary_field_dir}")
78
+ print(f" dict patch : {layout.dictionary_patch} ({'present' if layout.dictionary_patch.is_file() else 'absent'})")
79
+ from . import provision
80
+ print(f"templates : {'extracted' if provision.templates_present() else 'NOT extracted -- run: ff9mapkit extract-templates'}")
81
+ return 0
82
+
83
+
84
+ def _cmd_extract_templates(args: argparse.Namespace) -> int:
85
+ """Regenerate the kit's base assets (blank field, exit-region template, test fixtures) from the
86
+ user's own FF9 install -- the bring-your-own-install step that lets the repo ship no game data."""
87
+ from . import provision
88
+ if not _has_unitypy():
89
+ print("extract-templates needs UnityPy (reads FF9's p0data assetbundles). Install it:\n"
90
+ " py -m pip install UnityPy", file=sys.stderr)
91
+ return 2
92
+ try:
93
+ find_game_path(args.game) # clear error if the install can't be resolved
94
+ except ConfigError as e:
95
+ print(str(e), file=sys.stderr)
96
+ return 2
97
+ print("Regenerating base assets from your FF9 install (no game data is shipped with ff9mapkit):")
98
+ try:
99
+ rep = provision.extract_templates(game=args.game, fixtures=not args.no_fixtures, verbose=True)
100
+ except Exception as e:
101
+ print(f"\nextract-templates failed: {e}", file=sys.stderr)
102
+ return 1
103
+ print(f"\nOK -- {len(rep['verified'])} assets regenerated + verified against the manifest.")
104
+ return 0
105
+
106
+
107
+ def _cmd_disasm(args: argparse.Namespace) -> int:
108
+ from .eb import EbScript
109
+
110
+ eb = EbScript.from_file(args.file)
111
+ print(f"=== {args.file} size={len(eb.data)} entries={eb.entry_count} ===")
112
+ for e in eb.entries:
113
+ if e.empty:
114
+ if args.all:
115
+ print(f"\nENTRY {e.index}: (empty, off={e.off})")
116
+ continue
117
+ if args.entry is not None and e.index != args.entry:
118
+ continue
119
+ print(f"\nENTRY {e.index}: off={e.off} sz={e.size} type={e.type} "
120
+ f"funcs={[f.tag for f in e.funcs]} [{e.abs_start}..{e.abs_end}]")
121
+ for f in e.funcs:
122
+ print(f" --- func{f.index} tag={f.tag} [{f.abs_start}..{f.abs_end}]")
123
+ for ins in eb.instrs(f):
124
+ print(f" {ins}")
125
+ return 0
126
+
127
+
128
+ def _cmd_camera(args: argparse.Namespace) -> int:
129
+ from .scene import bgx, cam
130
+ scene = bgx.BgxScene.from_file(args.bgx)
131
+ if not scene.cameras:
132
+ print("no CAMERA block in scene", file=sys.stderr)
133
+ return 2
134
+ c = scene.cameras[0]
135
+ d = cam.decompose(c)
136
+ print(f"camera: proj(H)={c.proj} pos={c.t} range={c.range} fovX={d['fov_x_deg']:.2f} "
137
+ f"k={d['k']:.5f} C={tuple(round(x) for x in d['C'])} pitch={cam.pitch_deg(c):.1f}")
138
+ w = cam.pitch_warning(cam.pitch_deg(c))
139
+ if w:
140
+ print(f"warning: {w}", file=sys.stderr)
141
+ if args.regen:
142
+ r, t = cam.synth_r_t(d["C"], d["R_ortho"], c.proj, k=d["k"])
143
+ c.r, c.t = r, t
144
+ scene.set_camera(c)
145
+ with open(args.regen, "w", newline="\n", encoding="utf-8") as fh:
146
+ fh.write(scene.to_text())
147
+ print(f"regenerated camera -> {args.regen}")
148
+ return 0
149
+
150
+
151
+ def _cmd_walkmesh(args: argparse.Namespace) -> int:
152
+ from .scene import bgi
153
+ if args.action == "obj":
154
+ out = bgi.obj_to_bgi(args.input)
155
+ with open(args.output, "wb") as fh:
156
+ fh.write(out)
157
+ m = bgi.BgiWalkmesh.from_bytes(out)
158
+ print(f"obj -> .bgi: {len(m.tris)} tris, {len(m.verts)} verts, {len(out)} bytes -> {args.output}")
159
+ elif args.action == "fix":
160
+ m = bgi.BgiWalkmesh.from_file(args.input)
161
+ m.rebuild_neighbors()
162
+ out = m.to_bytes()
163
+ with open(args.output or args.input, "wb") as fh:
164
+ fh.write(out)
165
+ print(f"rebuilt neighbor links for {len(m.tris)} tris -> {args.output or args.input}")
166
+ elif args.action == "verify":
167
+ return _walkmesh_verify(args.input)
168
+ return 0
169
+
170
+
171
+ def _walkmesh_verify(path: str) -> int:
172
+ """Run the walkmesh + content checks standalone (no build). Accepts a .field.toml (full checks:
173
+ geometry, content placement, layer art) or a raw .bgi (geometry only). Exit 1 if any warning."""
174
+ from .scene import bgi
175
+ if str(path).endswith(".toml"):
176
+ from .build import FieldProject, verify_walkmesh
177
+ rep = verify_walkmesh(FieldProject.load(path))
178
+ print(f"walkmesh verify: {path} [{rep.get('source', '?')}]")
179
+ else:
180
+ from .build import _walkmesh_stats
181
+ rep = {**_walkmesh_stats(bgi.BgiWalkmesh.from_file(path)), "warnings": []}
182
+ print(f"walkmesh verify: {path}")
183
+ if rep.get("floors") is not None:
184
+ line = f" floors {rep['floors']} | walk-reachable {rep['reachable']}"
185
+ if rep["stranded"]:
186
+ line += f" | NOT reachable on foot: {rep['stranded']}"
187
+ print(line)
188
+ extra = f", {len(rep['degenerate'])} degenerate tri(s)" if rep["degenerate"] else ""
189
+ print(f" {rep['tris']} tris, {rep['verts']} verts, {rep['seams']} cross-floor seam(s){extra}")
190
+ if rep.get("bounds"):
191
+ b = rep["bounds"]
192
+ print(f" bounds x{b['x']} z{b['z']}")
193
+ warns = rep.get("warnings", [])
194
+ if warns:
195
+ print(f" {len(warns)} warning(s):")
196
+ for m in warns:
197
+ print(f" ! {m}")
198
+ return 1
199
+ print(" OK -- no warnings.")
200
+ return 0
201
+
202
+
203
+ def _cmd_guide(args: argparse.Namespace) -> int:
204
+ from .scene import bgi, cam, guide
205
+ if args.from_bgx: # use an existing camera (e.g. the Blender export)
206
+ cams = cam.parse_bgx_cameras(args.from_bgx)
207
+ if not cams:
208
+ print(f"no CAMERA in {args.from_bgx}", file=sys.stderr)
209
+ return 2
210
+ g = cams[0]
211
+ pitch = cam.pitch_deg(g)
212
+ else: # author a camera from pitch/distance/fov
213
+ g = guide.make_camera(args.pitch, args.distance, fov_x_deg=args.fov)
214
+ pitch = args.pitch
215
+ try:
216
+ fr = guide.frame_floor(g, back_canvas_y=args.back, front_canvas_y=args.front)
217
+ except ValueError as e:
218
+ print(f"error: {e}", file=sys.stderr)
219
+ return 2
220
+ print(f"camera pitch={pitch:.1f} fovX={cam.decompose(g)['fov_x_deg']:.1f}")
221
+ w = cam.pitch_warning(pitch)
222
+ if w:
223
+ print(f"warning: {w}", file=sys.stderr)
224
+ print(f"floor world z [{fr.zf}..{fr.zb}] half-width {fr.half_width}")
225
+ for nm, wld, cv in zip(("BL", "BR", "FR", "FL"), fr.corners_world, fr.corners_canvas):
226
+ print(f" {nm}: world {wld} -> canvas px {cv}")
227
+ print(f"walkmesh corners (x,z): {guide.walkmesh_corners(fr)}")
228
+ if args.png:
229
+ if args.template and getattr(args, "template_layers", False):
230
+ import os
231
+ out_dir = os.path.dirname(args.png) or "."
232
+ base = os.path.splitext(os.path.basename(args.png))[0]
233
+ files = guide.render_paint_template_layers(g, fr, out_dir, basename=base)
234
+ print(f"paint template: {len(files) - 1} layer PNGs + manifest -> {out_dir} "
235
+ f"(load {base}.manifest.json in your paint app)")
236
+ elif args.template:
237
+ wpx, hpx = guide.render_paint_template(g, fr, args.png)
238
+ print(f"paint template ({wpx}x{hpx}, transparent - paint UNDER it) -> {args.png}")
239
+ else:
240
+ guide.render_paint_guide(g, fr, args.png)
241
+ print(f"paint guide (checkerboard) -> {args.png}")
242
+ return 0
243
+
244
+
245
+ def _cmd_build(args: argparse.Namespace) -> int:
246
+ from pathlib import Path
247
+ from .build import BuildError, FieldProject, build_mod
248
+ try:
249
+ projects = [FieldProject.load(p) for p in args.field]
250
+ except (OSError, ValueError) as e:
251
+ print(f"failed to load project: {e}", file=sys.stderr)
252
+ return 2
253
+ out = Path(args.out)
254
+ try:
255
+ info = build_mod(projects, out, mod_name=args.mod_name, author=args.author,
256
+ description=args.description)
257
+ except (BuildError, ValueError) as e:
258
+ print(str(e), file=sys.stderr)
259
+ return 2
260
+ print(f"built mod '{args.mod_name}' -> {info['root']}")
261
+ for line in info["dictionary"]:
262
+ print(f" {line}")
263
+ for w in info.get("warnings", []):
264
+ print(f"warning: {w}", file=sys.stderr)
265
+ print("To install: copy that folder into the game install (next to FF9_Launcher.exe), or "
266
+ "build with --out pointing at the game's mod folder.")
267
+ return 0
268
+
269
+
270
+ def _cmd_paint_template(args: argparse.Namespace) -> int:
271
+ """Project a field.toml's FLOOR + CONTENT onto per-layer trace-over paint-template PNGs (+ a legend).
272
+
273
+ Unlike `guide` (camera-only), this reads the whole field: it resolves the camera, frames the floor,
274
+ and projects every content marker (NPCs/props/gateways/events/save points/ladders/jumps/choices/
275
+ waypoints/camera zones/spawn) -- so the artist sees where each thing lands + how tall to paint it.
276
+ Works on a from-scratch field (full floor + content) or a fork (content overlay on the real art)."""
277
+ import os
278
+
279
+ from . import build
280
+ from .scene import guide, paint
281
+ try:
282
+ project = build.FieldProject.load(args.field)
283
+ except (OSError, ValueError, KeyError) as e:
284
+ print(f"error: can't load {args.field}: {e}", file=sys.stderr)
285
+ return 2
286
+ try:
287
+ cams = build.resolve_cameras(project)
288
+ except build.BuildError as e:
289
+ print(f"error: {e}", file=sys.stderr)
290
+ return 2
291
+ if not cams:
292
+ print("error: [camera] section is required", file=sys.stderr)
293
+ return 2
294
+ cam0 = cams[0]
295
+ cfgs = build.camera_cfgs(project)
296
+ c0 = cfgs[0] if cfgs else {}
297
+ fr_cfg = c0.get("frame") or {}
298
+ frame = None
299
+ if "borrow" not in c0 or fr_cfg: # synth field, or a borrow with an explicit frame
300
+ try:
301
+ frame = guide.frame_floor(cam0, back_canvas_y=float(fr_cfg.get("back", 205)),
302
+ front_canvas_y=float(fr_cfg.get("front", 432)))
303
+ except ValueError as e:
304
+ print(f"note: floor layers skipped ({e})", file=sys.stderr)
305
+ scene_cfg = None # the two-file split: <field>.scene.toml sibling
306
+ if args.field.endswith(".field.toml"):
307
+ spath = args.field[: -len(".field.toml")] + ".scene.toml"
308
+ if os.path.isfile(spath):
309
+ import tomllib
310
+ with open(spath, "rb") as fh:
311
+ scene_cfg = tomllib.load(fh)
312
+ items = paint.normalize_content(project.raw, scene_cfg)
313
+ walkmesh = None # the field's REAL floor outline (forks / modeled)
314
+ try:
315
+ from .scene import bgi
316
+ wm_cfg = project.raw.get("walkmesh", {}) or {}
317
+ ref = wm_cfg.get("bgi") or wm_cfg.get("reference") # a shipped/borrowed .bgi (forks)
318
+ wm_bytes = project.path(ref).read_bytes() if ref else build.resolve_walkmesh(project, cam0)
319
+ wmesh = bgi.BgiWalkmesh.from_bytes(wm_bytes)
320
+ verts, tris = wmesh.world_verts(), [tuple(t.vtx) for t in wmesh.tris]
321
+ if verts and tris:
322
+ walkmesh = (verts, tris)
323
+ except Exception: # no/odd walkmesh (e.g. BG-borrow only) -> skip the layer
324
+ walkmesh = None
325
+ out_dir = args.out or os.path.dirname(os.path.abspath(args.field)) or "."
326
+ # a fork ships a composited background.png next to the field -> use it as the base layer so the
327
+ # guides sit on the real art (not black). From-scratch fields have no background.png -> stays None.
328
+ base_image = "background.png" if os.path.isfile(os.path.join(out_dir, "background.png")) else None
329
+ files = paint.render_full_template(cam0, frame, items, out_dir, basename=args.basename,
330
+ walkmesh=walkmesh, base_image=base_image)
331
+ ntypes = len({it["type"] for it in items})
332
+ print(f"paint template: {len(files) - 3} layer PNGs + legend + manifest + Photoshop importer -> {out_dir}")
333
+ print(f" {len(items)} content markers across {ntypes} types; in Photoshop run "
334
+ f"{args.basename}.import.jsx (File > Scripts > Browse...) to load all layers, "
335
+ f"{args.basename}.legend.json for names/heights")
336
+ if len(cams) > 1:
337
+ print(f" note: field has {len(cams)} cameras; projected camera 0 only "
338
+ "(per-camera fan-out is a follow-up)", file=sys.stderr)
339
+ return 0
340
+
341
+
342
+ def _cmd_lint(args: argparse.Namespace) -> int:
343
+ """Check a field.toml WITHOUT building -- ONE pass over every offline validator: schema errors
344
+ (validate), story/flag logic + dialogue overflow + dup names (lint_logic), reserved flag-band use
345
+ (lint_flag_bands), walkmesh geometry + content placement + layer art + cutscene movement
346
+ (verify_walkmesh), and camera pitch range. Warnings are grouped by [section]. Exits 1 if anything is
347
+ reported, so it's scriptable. Merges a sibling scene.toml first."""
348
+ from .build import FieldProject, lint_all
349
+ try:
350
+ proj = FieldProject.load(args.field)
351
+ except (OSError, ValueError) as e:
352
+ print(f"failed to load: {e}", file=sys.stderr)
353
+ return 2
354
+ rep = lint_all(proj)
355
+ print(f"lint: {args.field} [{rep.source}]")
356
+ for p in rep.errors:
357
+ print(f" ERROR {p}")
358
+ for tag, items in (("logic", rep.logic), ("flags", rep.flags),
359
+ ("placement", rep.placement), ("camera", rep.camera)):
360
+ for w in items:
361
+ print(f" warn [{tag}] {w}")
362
+ if rep.ok:
363
+ print(" OK -- no problems.")
364
+ return 0
365
+ print(f" {len(rep.errors)} error(s), {len(rep.warnings)} warning(s)")
366
+ return 1
367
+
368
+
369
+ def _cmd_new(args: argparse.Namespace) -> int:
370
+ from .pack import new_project, suggest_base
371
+ proj = new_project(args.name, args.dest, field_id=args.id, area=args.area, pitch=args.pitch)
372
+ fid = args.id if args.id is not None else suggest_base(args.name)
373
+ print(f"scaffolded {proj} (suggested field id {fid}, area {args.area})")
374
+ print(f" edit {proj}/{args.name.lower()}.field.toml, add art, then: ff9mapkit build "
375
+ f"{proj}/{args.name.lower()}.field.toml")
376
+ return 0
377
+
378
+
379
+ def _cmd_gen_hub(args: argparse.Namespace) -> int:
380
+ """Generate a World-Hub field.toml from a journeys.toml registry. Pure codegen (no game install): it
381
+ emits a BG-borrow hub field whose narrator menu warps to each journey's entry. Build/deploy the emitted
382
+ field.toml like any field. With --extract-camera the borrowed camera is cached for you (no manual step)."""
383
+ from . import hub
384
+ if args.extract_camera and not _has_unitypy():
385
+ print("--extract-camera needs UnityPy (pip install UnityPy) + your FF9 install.", file=sys.stderr)
386
+ return 2
387
+ try:
388
+ info = hub.generate(args.journeys, out_path=args.out, extract_camera=args.extract_camera,
389
+ game=args.game, force=args.force)
390
+ except (OSError, ValueError, ConfigError) as e: # HubError (a ValueError) + unreadable/parse/install errors
391
+ print(str(e), file=sys.stderr)
392
+ return 2
393
+ spec = info["spec"]
394
+ print(f"generated hub '{spec.name}' (id {spec.id}, {info['journeys']} journey(s)) -> {info['path']}")
395
+ for j in spec.journeys:
396
+ seed = f", seed {j.set_scenario}" if j.set_scenario is not None else ""
397
+ print(f" {j.name!r} -> field {j.entry}{seed}")
398
+ for w in info.get("warnings", []):
399
+ print(f"warning: {w}", file=sys.stderr)
400
+ ex = info.get("extracted")
401
+ if ex:
402
+ verb = "reused cached" if ex.get("cached") else "extracted"
403
+ print(f"camera: {verb} field {spec.borrow_field} -> {ex['camera']}")
404
+ print(f"Next: `ff9mapkit build {info['path']}` (or tools/deploy_field.py) -- the camera is wired up.")
405
+ else:
406
+ print(f"Next: extract the borrowed camera ({spec.camera}) -- e.g. "
407
+ f"`ff9mapkit extract-field <id>` or `gen-hub --extract-camera` -- then "
408
+ f"`ff9mapkit build {info['path']}` (or tools/deploy_field.py).")
409
+ return 0
410
+
411
+
412
+ def _cmd_lint_journey(args: argparse.Namespace) -> int:
413
+ """Validate a multi-campaign journeys.toml offline: campaigns exist + parse, the GLOBAL id-disjointness
414
+ guarantee (every campaign of every journey + bare entries share one EventDB namespace), flag windows fit,
415
+ links resolve to real members + boundaries, entries valid, seeds in range. Pure (no game install)."""
416
+ from . import journey
417
+ try:
418
+ manifest = journey.load_journeys(args.journeys)
419
+ errors, warnings = journey.lint_manifest(manifest)
420
+ except (journey.JourneyError, FileNotFoundError, ValueError) as e:
421
+ print(str(e), file=sys.stderr)
422
+ return 2
423
+ if getattr(args, "graph", False):
424
+ print(journey.render_journey_plan(manifest))
425
+ for w in warnings:
426
+ print("warning: " + w, file=sys.stderr)
427
+ for e in errors:
428
+ print("error: " + e, file=sys.stderr)
429
+ n = len(manifest.journeys)
430
+ if errors:
431
+ print(f"journeys '{manifest.path.name}': FAILED -- {len(errors)} error(s), {len(warnings)} warning(s)",
432
+ file=sys.stderr)
433
+ return 2
434
+ print(f"journeys '{manifest.path.name}' OK -- {n} journey(s), {len(warnings)} warning(s)")
435
+ return 0
436
+
437
+
438
+ def _cmd_assemble_journey(args: argparse.Namespace) -> int:
439
+ """Assemble a multi-campaign journeys.toml: lint it (the namespace guarantee), then emit the World-Hub
440
+ field.toml resolving BOTH bare single-field and multi-campaign journeys (gen-hub handles only the bare
441
+ form). Pure offline codegen -- build/deploy the emitted hub like any field; the per-campaign deploy +
442
+ cross-campaign link wiring is the in-game step (tools/deploy_campaign.py per member, then the hub)."""
443
+ from . import journey
444
+ try:
445
+ manifest = journey.load_journeys(args.journeys)
446
+ if getattr(args, "graph", False) or args.dry_run:
447
+ errors, warnings = journey.lint_manifest(manifest)
448
+ for w in warnings:
449
+ print("warning: " + w, file=sys.stderr)
450
+ if errors:
451
+ for e in errors:
452
+ print("error: " + e, file=sys.stderr)
453
+ return 2
454
+ print(journey.render_journey_plan(manifest))
455
+ if args.dry_run:
456
+ print("DRY-RUN -- no hub field.toml written. Drop --dry-run to emit it.")
457
+ return 0
458
+ if getattr(args, "extract_camera", False) and not _has_unitypy():
459
+ print("--extract-camera needs UnityPy (pip install UnityPy) + your FF9 install.", file=sys.stderr)
460
+ return 2
461
+ info = journey.generate_hub(manifest.path, out_path=args.out,
462
+ extract_camera=getattr(args, "extract_camera", False),
463
+ game=getattr(args, "game", None), force=getattr(args, "force", False))
464
+ except (journey.JourneyError, FileNotFoundError, ValueError) as e:
465
+ print(str(e), file=sys.stderr)
466
+ return 2
467
+ spec = info["spec"]
468
+ print(f"assembled hub '{spec.name}' (id {spec.id}, {len(spec.journeys)} journey(s)) -> {info['path']}")
469
+ for j in spec.journeys:
470
+ seed = f", seed {j.set_scenario}" if j.set_scenario is not None else ""
471
+ print(f" {j.name!r} -> field {j.entry}{seed}")
472
+ for w in info.get("warnings", []):
473
+ print("warning: " + w, file=sys.stderr)
474
+ ex = info.get("extracted")
475
+ if ex:
476
+ print(f"camera: {'reused cached' if ex.get('cached') else 'extracted'} field {spec.borrow_field} "
477
+ f"-> {ex['camera']} (the hub [camera] borrow is wired up)")
478
+ elif spec.borrow_field:
479
+ print(f"Next: extract the hub camera -- `ff9mapkit assemble-journey {args.journeys} --extract-camera` "
480
+ f"(or `ff9mapkit extract-field {spec.borrow_field}`) -- then build/deploy the hub.")
481
+ print(f"Then build + deploy the hub (`tools/deploy_field.py {info['path']}`) + each campaign "
482
+ f"(`tools/deploy_campaign.py --no-warp`); or run the whole journey with `tools/deploy_journey.py "
483
+ f"{args.journeys} --apply`.")
484
+ return 0
485
+
486
+
487
+ def _cmd_reference_arcs(args: argparse.Namespace) -> int:
488
+ """The FF9 reference-arc scaffold -- the north-star planning + fork-and-test harness. List the curated
489
+ arc->seed table, print the fork PLAYBOOK, or EMIT a multi-campaign journeys.toml laying the arcs out as a
490
+ chained journey + the `import-chain` commands to fork each one. Pure offline (no game install). It is NOT
491
+ a one-click rebuild of FF9 -- it's a PLAN you execute arc-by-arc (docs/FORK_FIDELITY.md)."""
492
+ from pathlib import Path
493
+ from . import refarc
494
+ if args.reconcile: # STEP 2 -- operates on a journeys.toml, NOT an arc table
495
+ jp = Path(args.reconcile)
496
+ if not jp.is_file():
497
+ print(f"{jp}: no such file", file=sys.stderr)
498
+ return 2
499
+ new_text, notes = refarc.reconcile_arc_journey(jp.read_text(encoding="utf-8"), jp.parent)
500
+ for n in notes:
501
+ print(f" [{n.level}] {n.text}")
502
+ if new_text == jp.read_text(encoding="utf-8"):
503
+ print("nothing to fill (fork the campaigns first, or the entry/links are already set).")
504
+ return 0
505
+ jp.write_text(new_text, encoding="utf-8", newline="\n")
506
+ print(f"wrote {jp} (entry + links filled from the forked campaigns). Re-lint with `lint-journey`.")
507
+ return 0
508
+ if args.regen: # REGENERATE the region PICKER's all-zones catalog
509
+ if args.pattern and not args.out: # a filtered regen must NOT clobber the shipped full catalog
510
+ print("--pattern needs --out (a filtered catalog is a subset -- it would overwrite the shipped "
511
+ "all-zones catalog). Add --out <path>, or drop --pattern to refresh the full catalog.",
512
+ file=sys.stderr)
513
+ return 2
514
+ try:
515
+ p, n = refarc.regenerate_region_catalog(out=args.out, pattern=args.pattern,
516
+ split_visits=not args.no_split_visits, gap=args.gap)
517
+ except refarc.RefArcError as e:
518
+ print(str(e), file=sys.stderr)
519
+ return 2
520
+ print(f"wrote {n} regions -> {p}")
521
+ print("The region picker ('Browse FF9 regions' / 'Add region to arc') now reads this accurate, "
522
+ "all-zones catalog (each zone -> its real entry seed).")
523
+ return 0
524
+ try:
525
+ aset = refarc.load_reference_arcs(args.table)
526
+ except (refarc.RefArcError, FileNotFoundError, ValueError) as e:
527
+ print(str(e), file=sys.stderr)
528
+ return 2
529
+ if args.emit:
530
+ out = Path(args.emit)
531
+ out.mkdir(parents=True, exist_ok=True)
532
+ jpath = out / "journeys.toml"
533
+ if jpath.exists() and not args.force:
534
+ print(f"{jpath} already exists (use --force to overwrite)", file=sys.stderr)
535
+ return 2
536
+ jpath.write_text(refarc.render_arc_journey_toml(
537
+ aset, hub_name=args.hub_name, hub_id=args.hub_id, borrow_bg=args.borrow_bg, id_base=args.id_base),
538
+ encoding="utf-8", newline="\n")
539
+ print(f"wrote {jpath} ({len(aset.arcs)} arcs, hub id {args.hub_id})")
540
+ print("Next: fork each arc (the import-chain playbook is in the file header), fill the entry from "
541
+ "the forked entry campaign (links auto-wire at deploy), then deploy (Build & Deploy -> this file, or "
542
+ f"`py tools/deploy_journey.py {jpath.as_posix()} --apply`).")
543
+ return 0
544
+ if args.playbook:
545
+ for i, (_arc, cmd) in enumerate(refarc.fork_playbook(aset, id_base=args.id_base), 1):
546
+ print(f"{i:>2}. {cmd}")
547
+ return 0
548
+ print(f"{aset.title} ({len(aset.arcs)} arcs, in story order):")
549
+ for arc in aset.arcs:
550
+ print(f" {arc.key:<14} seed {arc.seed:<5} {arc.name}")
551
+ print("\n--emit <dir> to scaffold a chained journeys.toml; --playbook for just the fork commands.")
552
+ return 0
553
+
554
+
555
+ def _cmd_extract_field(args: argparse.Namespace) -> int:
556
+ """Cache real FF9 fields' camera + walkmesh in the gitignored workspace cache, for reuse by BG-borrow
557
+ tomls / `gen-hub --extract-camera`. Needs the install + UnityPy."""
558
+ if not _has_unitypy():
559
+ print("extract-field needs UnityPy (pip install UnityPy) + your FF9 install.", file=sys.stderr)
560
+ return 2
561
+ from . import extract
562
+ rc = 0
563
+ for fid in args.ids:
564
+ try:
565
+ res = extract.cache_field(fid, game=args.game, force=args.force)
566
+ except (OSError, ValueError, ConfigError) as e:
567
+ print(f"field {fid}: {e}", file=sys.stderr)
568
+ rc = 1
569
+ continue
570
+ print(f"field {fid}: {'already cached' if res.get('cached') else 'extracted'} -> {res['camera']}")
571
+ return rc
572
+
573
+
574
+ def _cmd_export_art(args: argparse.Namespace) -> int:
575
+ """Assemble per-overlay background PNGs OFFLINE -- our own `[Export] Field=1`, no in-game hang.
576
+ Targets: one field, a campaign.toml (every member's donor field), or --all."""
577
+ if not _has_unitypy():
578
+ print("export-art needs UnityPy (pip install UnityPy) + your FF9 install.", file=sys.stderr)
579
+ return 2
580
+ from . import extract
581
+ _safe_console()
582
+ write_atlas = not args.no_atlas
583
+ comp = args.composite
584
+
585
+ def progress(k, total, folder, summ, err):
586
+ if err:
587
+ print(f" [{k}/{total}] {folder}: SKIP ({err})", file=sys.stderr)
588
+ elif comp:
589
+ print(f" [{k}/{total}] {folder}: {summ['size'][0]}x{summ['size'][1]}")
590
+ else:
591
+ tag = "" if summ["atlas"] else " (no atlas)"
592
+ print(f" [{k}/{total}] {folder}: {summ['overlays']} overlays{tag}")
593
+
594
+ try:
595
+ if args.all:
596
+ res = extract.export_all_art(args.out, game=args.game, pattern=args.pattern,
597
+ write_atlas=write_atlas, composite=comp, on_field=progress)
598
+ elif args.target and str(args.target).lower().endswith(".toml"):
599
+ res = extract.export_campaign_art(args.target, args.out, game=args.game,
600
+ write_atlas=write_atlas, composite=comp, on_field=progress)
601
+ elif args.target:
602
+ if comp:
603
+ summ = extract.export_field_composite(args.target, args.out, game=args.game)
604
+ print(f"{summ['folder']}: {summ['size'][0]}x{summ['size'][1]} background -> {summ['path']}")
605
+ else:
606
+ summ = extract.export_field_art(args.target, args.out, game=args.game, write_atlas=write_atlas)
607
+ atxt = " + atlas.png" if summ["atlas"] else ""
608
+ print(f"{summ['folder']}: {summ['overlays']} overlays ({summ['source']}){atxt} -> {summ['dir']}")
609
+ return 0
610
+ else:
611
+ print("export-art: give a field, a campaign.toml, or --all", file=sys.stderr)
612
+ return 2
613
+ except (FileNotFoundError, ValueError, RuntimeError, ConfigError) as e:
614
+ print(str(e), file=sys.stderr)
615
+ return 2
616
+ where = args.out or "<install>/StreamingAssets/FieldMaps"
617
+ unit = "background PNG(s)" if comp else "overlays"
618
+ print(f"\nexported {res['fields']}/{res['total']} field(s), {res['units']} {unit} -> {where}")
619
+ if res["failed"]:
620
+ print(f" {len(res['failed'])} field(s) skipped (no readable art):", file=sys.stderr)
621
+ for tok, err in res["failed"][:10]:
622
+ print(f" {tok}: {err}", file=sys.stderr)
623
+ return 0 if res["fields"] else 1
624
+
625
+
626
+ def _cmd_repaint_native(args: argparse.Namespace) -> int:
627
+ """SPATIAL<->ATLAS repaint round-trip for a NATIVE fork: unpack the tile-packed atlas.png into
628
+ per-overlay spatial layers (default), or --pack the (edited) layers back into atlas.png. No game
629
+ needed -- operates on the project's own scene.bgs.bytes + atlas.png (provenance-clean)."""
630
+ from . import extract
631
+ _safe_console()
632
+ try:
633
+ from PIL import Image # noqa: F401, PLC0415 - the round-trip needs Pillow
634
+ except ImportError:
635
+ print("repaint-native needs Pillow (pip install Pillow).", file=sys.stderr)
636
+ return 2
637
+ try:
638
+ if args.pack:
639
+ res = extract.repack_native_atlas(args.project, args.from_dir, backup=not args.no_backup)
640
+ for note in res["notes"]:
641
+ print(f" {note}")
642
+ print(f"repacked {res['overlays_repacked']} layer(s), {res['cells_written']} tile(s) changed "
643
+ f"(TileSize {res['tile_size']}) -> {res['atlas']}")
644
+ print("Next: deploy the project -> the repainted background shows in-game."
645
+ if res["cells_written"] else "No tiles changed (the layers match the atlas) -- nothing to do.")
646
+ else:
647
+ res = extract.export_native_repaint(args.project, args.out)
648
+ print(f"unpacked {res['overlays']} layer(s) (TileSize {res['tile_size']}, atlas "
649
+ f"{res['atlas_size'][0]}x{res['atlas_size'][1]}) -> {res['dir']}")
650
+ print(f"Next: repaint any Overlay*.png, then: ff9mapkit repaint-native {args.project} --pack")
651
+ except (FileNotFoundError, ValueError, RuntimeError) as e:
652
+ print(str(e), file=sys.stderr)
653
+ return 2
654
+ return 0
655
+
656
+
657
+ def _cmd_import_all(args: argparse.Namespace) -> int:
658
+ """Bulk-import a foldered, Blender-ready archive of fields -- whole game (--all), a zone (--pattern),
659
+ or a campaign.toml. Lightweight model-against projects by default; --editable for repaintable scenes."""
660
+ if not _has_unitypy():
661
+ print("import-all needs UnityPy (pip install UnityPy) + your FF9 install.", file=sys.stderr)
662
+ return 2
663
+ from . import extract
664
+ _safe_console()
665
+
666
+ def progress(k, total, label, dest, err):
667
+ if err:
668
+ print(f" [{k}/{total}] {label}: SKIP ({err})", file=sys.stderr)
669
+ else:
670
+ print(f" [{k}/{total}] {label} -> {dest}")
671
+
672
+ try:
673
+ if args.target and str(args.target).lower().endswith(".toml"):
674
+ res = extract.import_campaign_fields(args.target, args.out, game=args.game,
675
+ editable=args.editable, on_field=progress)
676
+ elif args.all or args.pattern:
677
+ res = extract.import_all(args.out, game=args.game, pattern=args.pattern,
678
+ editable=args.editable, on_field=progress)
679
+ else:
680
+ print("import-all: give a campaign.toml, or --all (optionally --pattern <zone>)", file=sys.stderr)
681
+ return 2
682
+ except (FileNotFoundError, ValueError, RuntimeError, ConfigError) as e:
683
+ print(str(e), file=sys.stderr)
684
+ return 2
685
+ mode = "editable" if args.editable else "lightweight"
686
+ print(f"\nimported {res['fields']}/{res['total']} field(s) [{mode}] -> {args.out}")
687
+ if res["failed"]:
688
+ print(f" {len(res['failed'])} field(s) skipped (no readable scene/art):", file=sys.stderr)
689
+ for lbl, err in res["failed"][:10]:
690
+ print(f" {lbl}: {err}", file=sys.stderr)
691
+ return 0 if res["fields"] else 1
692
+
693
+
694
+ def _cmd_pack(args: argparse.Namespace) -> int:
695
+ from pathlib import Path
696
+ from .pack import pack_mod
697
+ out = args.out or (Path(args.mod_root).resolve().name + ".zip")
698
+ try:
699
+ z = pack_mod(args.mod_root, out)
700
+ except FileNotFoundError as e:
701
+ print(f"mod folder not found: {e}", file=sys.stderr)
702
+ return 2
703
+ print(f"packed {args.mod_root} -> {z}")
704
+ return 0
705
+
706
+
707
+ def _cmd_import(args: argparse.Namespace) -> int:
708
+ from pathlib import Path
709
+ from . import extract
710
+ try:
711
+ gpf = getattr(args, "graft_player_funcs", False)
712
+ ct = getattr(args, "carry_text", False)
713
+ sm = getattr(args, "save_moogle", False)
714
+ if ct or sm:
715
+ gpf = True # text carry / save-moogle ride on the graft (the carried objects/funcs must exist)
716
+ # #4 (FORK_FIDELITY.md): BG-borrow black-screens area<10 -- the engine builds 'FBG_N<area>' and reads
717
+ # exactly 2 chars, so single-digit areas never resolve. The native path ships its own art at a remapped
718
+ # area>=10 (seam-free + lit), so auto-route the default (borrow) path to native there -- this unblocks
719
+ # forking the early-game fields (Alexandria area1, Cargo Ship area0) with a plain `import`.
720
+ if getattr(args, "neutralize_gestures", False) and not getattr(args, "swap_player", None):
721
+ print("--neutralize-gestures requires --swap-player (it rewrites the swapped rig's gestures)",
722
+ file=sys.stderr)
723
+ return 2
724
+ if getattr(args, "swap_player", None):
725
+ from . import playerswap as _ps
726
+ _ps.resolve_char(args.swap_player) # fail fast on an unknown character (ValueError -> caught below)
727
+ args.verbatim = True # the swap patches the donor's player entry -> needs the verbatim .eb
728
+ if args.verbatim:
729
+ args.native = True # --verbatim ships the native scene + the donor's WHOLE .eb
730
+ auto_native_area = None
731
+ if not args.native and not args.editable:
732
+ try:
733
+ _folder, _ = extract.resolve_field(args.field, args.game)
734
+ _area, _ = extract.parse_fbg_folder(_folder)
735
+ if _area < extract.MIN_CUSTOM_AREA:
736
+ args.native = True
737
+ auto_native_area = _area
738
+ except (RuntimeError, FileNotFoundError, ValueError):
739
+ pass # can't resolve area offline -> let the normal dispatch surface any error
740
+ if args.native:
741
+ meta, toml = extract.write_native_project(
742
+ args.field, Path(args.out), name=args.name, field_id=args.id, game=args.game,
743
+ graft_player_funcs=gpf, carry_text=ct, graft_savepoint=sm, verbatim=args.verbatim)
744
+ elif args.editable:
745
+ meta, toml = extract.write_editable_project(
746
+ args.field, Path(args.out), name=args.name, field_id=args.id, game=args.game,
747
+ graft_player_funcs=gpf, carry_text=ct, graft_savepoint=sm)
748
+ else:
749
+ meta, toml = extract.write_field_project(
750
+ args.field, Path(args.out), name=args.name, field_id=args.id,
751
+ game=args.game, want_atlas=args.atlas, graft_player_funcs=gpf, carry_text=ct, graft_savepoint=sm)
752
+ args._swapped_to = None
753
+ args._swap_gestures = 0
754
+ if getattr(args, "swap_player", None): # productionized Tier-A: walk as a different existing char
755
+ from . import playerswap
756
+ args._swapped_to, _ = playerswap.resolve_char(args.swap_player)
757
+ args._swap_gestures = extract.apply_player_swap(
758
+ toml, args._swapped_to, neutralize=getattr(args, "neutralize_gestures", False)) or 0
759
+ except (RuntimeError, FileNotFoundError, ValueError) as e:
760
+ print(str(e), file=sys.stderr)
761
+ return 2
762
+ cm = meta["camera"]
763
+ print(f"imported {meta['field']} (area {meta['area']}, mapid {meta['mapid']})")
764
+ if args.native:
765
+ print(" mode : NATIVE custom scene (atlas.png + .bgs, NO .bgx -- seamless per-tile render, Moguri-style)")
766
+ print(f" atlas : {meta.get('atlas_source', '?')}")
767
+ if args.verbatim:
768
+ ic = meta.get("imported_content", {})
769
+ n_exits = len(ic.get("field_exits", []))
770
+ print(f" logic : VERBATIM .eb -- ships the field's REAL event script whole ({n_exits} Field() exit(s); "
771
+ "add a [startup] block to boot a beat). The declarative blocks are not used in this mode.")
772
+ if ic.get("battle_bgm"):
773
+ print(f" bgm : {ic['battle_bgm']} scripted battle(s) carry the donor's real battle theme "
774
+ "([[battle_bgm]] -> a scene-keyed Music: line; a mint would otherwise lose it)")
775
+ if getattr(args, "_swapped_to", None):
776
+ print(f" player : SWAPPED -> you walk as {args._swapped_to} (SetModel + movement anims patched; "
777
+ "party/menu state unchanged)")
778
+ if args._swap_gestures and getattr(args, "neutralize_gestures", False):
779
+ print(f" gesture: NEUTRALIZED {args._swap_gestures} scripted gesture(s) -> the rig's idle, so "
780
+ f"{args._swapped_to} STANDS cleanly through the cutscene (it won't emote -- for story "
781
+ "fidelity use a verbatim fork at the right beat). WaitAnimation timing left intact.")
782
+ elif args._swap_gestures:
783
+ print(f" WARN : the player plays {args._swap_gestures} scripted GESTURE(s) (RunAnimation) -- those "
784
+ f"reference the ORIGINAL rig and will glitch on {args._swapped_to} (only movement clips are "
785
+ "swapped). Add --neutralize-gestures to stand cleanly, or fork a free-roam field.")
786
+ if auto_native_area is not None:
787
+ print(f" note : auto-selected --native (source area {auto_native_area} < 10 black-screens via BG-borrow)")
788
+ elif args.editable:
789
+ nb = meta.get("blend_layers", 0)
790
+ print(f" mode : EDITABLE custom scene ({meta['layers']} art layers"
791
+ f"{f', {nb} light/shadow' if nb else ''})")
792
+ else:
793
+ print(" mode : BG-borrow (reuses the real art as-is)")
794
+ print(f" camera : pitch {cm['pitch_deg']} fov {cm['fov_deg']} range {cm['range']}"
795
+ f"{' SCROLLING' if meta['scrolling'] else ''}")
796
+ print(f" spawn : {meta['player_start']} walkmesh x{meta['walkmesh_bounds']['x']} z{meta['walkmesh_bounds']['z']}")
797
+ ic = meta.get("imported_content")
798
+ if ic and not ic.get("verbatim_eb"): # verbatim mode has no declarative content summary (it's all .eb)
799
+ bits = []
800
+ if ic["gateways"]:
801
+ bits.append(f"{ic['gateways']} gateway(s)")
802
+ if ic["encounter"]:
803
+ bits.append("encounter")
804
+ if ic["music"] is not None:
805
+ bits.append(f"BGM song {ic['music']}")
806
+ if ic["control_direction"] is not None:
807
+ bits.append(f"movement dir {ic['control_direction']}")
808
+ if ic.get("ladders"):
809
+ bits.append(f"{ic['ladders']} ladder(s)")
810
+ if ic.get("jumps"):
811
+ bits.append(f"{ic['jumps']} jump(s)")
812
+ if ic.get("objects"):
813
+ bits.append(f"{ic['objects']} object(s) carried")
814
+ if ic.get("player_funcs"):
815
+ bits.append(f"{ic['player_funcs']} player-func(s) grafted (interactions)")
816
+ if ic.get("carry_text"):
817
+ bits.append(f"{ic['carry_text']} dialogue line(s) carried verbatim")
818
+ if ic.get("save_moogle"):
819
+ bits.append("a faithful SAVE MOOGLE (pops out of the barrel + saves)")
820
+ if ic.get("gateway_carry"):
821
+ bits.append(f"{ic['gateway_carry']} story-gated door(s) carried verbatim")
822
+ print(f" content: {', '.join(bits) if bits else 'none found in the source script'}"
823
+ + (" (gateways point at REAL fields -- retarget them)" if ic["gateways"] else ""))
824
+ if ic.get("spawn_flash_fixed"):
825
+ print(" note : the save Moogle's spawn pose was normalised to its rest pose (no load flash on a fork)")
826
+ if ic.get("spawn_flash"):
827
+ print(f" warning: {ic['spawn_flash']} carried object(s) spawn at a different pose than they rest -- they "
828
+ "may visibly snap to rest on a fork (the source field's entrance fade hides it). (docs/SAVEPOINT.md)")
829
+ if ic.get("story_branch"):
830
+ print(f" warning: {ic['story_branch']} STORY-BRANCH door(s) share a zone (the real field selects one by "
831
+ "story flag).\n Gate each with requires_flag in the field.toml, else both arm and you "
832
+ "hit the wrong exit. (FORK_FIDELITY.md #2)")
833
+ if ic.get("gateway_gated_seam"):
834
+ print(f" warning: {ic['gateway_gated_seam']} story-gated door(s) reference other entries and couldn't be "
835
+ "carried\n verbatim -- they're left as ungated seams (the gate is dropped). (FORK_FIDELITY.md #2b)")
836
+ if args.dialogue:
837
+ from . import dialogue as DLG
838
+ try:
839
+ lines = DLG.read_field_dialogue(args.field, lang="us", game=args.game)
840
+ n = sum(1 for ln in DLG.present(lines) if ln.source == "npc" and ln.text)
841
+ if n:
842
+ with open(toml, "a", encoding="utf-8") as fh:
843
+ fh.write("\n" + DLG.npc_stub_toml(lines, field_ref=args.field))
844
+ print(f" dialogue: appended {n} editable [[npc]] stub(s) (commented) -- uncomment + re-author them")
845
+ else:
846
+ print(" dialogue: no NPC dialogue found in this field")
847
+ except (RuntimeError, FileNotFoundError, ValueError) as e:
848
+ print(f" dialogue: skipped ({e})", file=sys.stderr)
849
+ print(f" wrote : {toml}")
850
+ if args.native:
851
+ print(f"Next: add content (retarget imported gateways, add [[npc]]/dialogue), then: ff9mapkit build {toml}")
852
+ elif args.editable:
853
+ print(f"Next: repaint any layer_*.png / reshape walkmesh.obj / add content, then: ff9mapkit build {toml}")
854
+ else:
855
+ print(f"Next: edit it (retarget imported gateways, add [[npc]]/dialogue), then: ff9mapkit build {toml}")
856
+ return 0
857
+
858
+
859
+ def _chain_label_fn(game=None):
860
+ """id -> display name. Prefers reference/field-manifest.tsv (nice names like 'Ice Cavern/Entrance');
861
+ falls back to the FBG mapid (always available, provenance-clean) so it works with no reference dir."""
862
+ from pathlib import Path
863
+ from . import extract
864
+ names: dict = {}
865
+ for cand in (Path(__file__).resolve().parents[2] / "reference" / "field-manifest.tsv",
866
+ Path.cwd() / "reference" / "field-manifest.tsv"):
867
+ try:
868
+ if cand.is_file():
869
+ for line in cand.read_text(encoding="utf-8", errors="replace").splitlines():
870
+ cols = line.split("\t")
871
+ if len(cols) >= 3 and cols[1].strip().isdigit():
872
+ names.setdefault(int(cols[1]), cols[2].strip())
873
+ break
874
+ except OSError:
875
+ pass
876
+
877
+ def label(fid):
878
+ if fid in names:
879
+ return names[fid]
880
+ folder = extract.ID_TO_FBG.get(int(fid))
881
+ return re.sub(r"^fbg_n\d+_", "", folder) if folder else "?"
882
+ return label
883
+
884
+
885
+ def _resolve_chain_seeds(seed: str, game=None):
886
+ """Seed(s) -> field-id list. Accepts a COMMA-SEPARATED list of tokens; each token is a numeric field id
887
+ OR an FBG substring that seeds EVERY matching field (e.g. 'iccv' = the whole Ice Cavern zone). Several
888
+ tokens fork multiple zones as ONE campaign (with --whole-zone -> cross-zone warps auto-retarget in-fork);
889
+ seeds keep token order (so the first stays the campaign entry) and are de-duplicated."""
890
+ from . import extract
891
+ out: list[int] = []
892
+ seen: set = set()
893
+ for tok in seed.split(","):
894
+ s = tok.strip()
895
+ if not s:
896
+ continue
897
+ if s.lstrip("-").isdigit():
898
+ ids = [int(s)]
899
+ else:
900
+ sl = s.lower()
901
+ ids = sorted(fid for fid, folder in extract.ID_TO_FBG.items() if sl in folder)
902
+ if not ids:
903
+ raise FileNotFoundError(f"no field id or FBG folder matches seed token {s!r}")
904
+ for fid in ids:
905
+ if fid not in seen:
906
+ seen.add(fid)
907
+ out.append(fid)
908
+ if not out:
909
+ raise FileNotFoundError(f"no field id or FBG folder matches seed {seed!r}")
910
+ return out
911
+
912
+
913
+ def _deploy_cfg():
914
+ """The worktree's .ff9deploy.toml (mod_folder + campaign_id_base defaults), or {}."""
915
+ import tomllib
916
+ from pathlib import Path
917
+ f = Path(__file__).resolve().parents[2] / ".ff9deploy.toml"
918
+ try:
919
+ return tomllib.loads(f.read_text(encoding="utf-8")) if f.is_file() else {}
920
+ except Exception:
921
+ return {}
922
+
923
+
924
+ def _print_campaign_summary(plan, out_dir, *, verbatim=False):
925
+ n = len(plan.members)
926
+ ids = f"{plan.members[0].new_id}-{plan.members[-1].new_id}" if n else "-"
927
+ sc = sum(1 for e in plan.edges if e["story_conditional"])
928
+ mode = "VERBATIM (whole donor .eb + .mes, real logic)" if verbatim else "declarative"
929
+ print(f"{n} fields forked [{mode}] into {out_dir} (ids {ids}); "
930
+ f"{len(plan.edges)} in-chain gateways retargeted.")
931
+ if sc:
932
+ print(f" {sc} STORY-COND edge(s) flagged -- add requires_flag (see campaign.toml).")
933
+ if plan.seams:
934
+ kinds = {}
935
+ for s in plan.seams:
936
+ kinds[s["kind"]] = kinds.get(s["kind"], 0) + 1
937
+ print(" " + str(len(plan.seams)) + " seam(s): " + ", ".join(f"{v} {k}" for k, v in sorted(kinds.items())))
938
+ degraded = set(getattr(plan, "verbatim_degraded", []) or [])
939
+ if degraded: # verbatim members that lost their .eb (no native atlas)
940
+ print(f" {len(degraded)} member(s) fell back to DECLARATIVE -- NOT verbatim (no native atlas; "
941
+ f"re-synthesized logic, no real .eb): " + " ".join(sorted(degraded)))
942
+ plain_export = [n for n in plan.needs_export if n not in degraded]
943
+ if plain_export:
944
+ print(f" {len(plain_export)} member(s) NEED an in-game [Export] before deploy: "
945
+ + " ".join(plain_export))
946
+ swap = getattr(plan, "swap_player", None)
947
+ if swap: # --swap-player: walk as one char across the chain
948
+ gw = getattr(plan, "swap_gesture_warn", {}) or {}
949
+ sk = getattr(plan, "swap_skipped", []) or []
950
+ swapped_n = sum(1 for m in plan.members if m.name not in sk and m.name not in degraded)
951
+ print(f" PLAYER SWAP: you walk as {swap} across the chain ({swapped_n} verbatim member(s) swapped).")
952
+ if sk:
953
+ print(f" {len(sk)} member(s) had no swappable player entry -- left as the donor's: " + " ".join(sorted(sk)))
954
+ if gw:
955
+ tot = sum(gw.values())
956
+ if getattr(plan, "neutralized", False):
957
+ print(f" NEUTRALIZED {tot} scripted gesture(s) across {len(gw)} member(s) -> {swap}'s idle "
958
+ f"(stands cleanly through the cutscenes): " + " ".join(sorted(gw)))
959
+ else:
960
+ print(f" WARN: {len(gw)} member(s) play {tot} scripted GESTURE(s) that will glitch on {swap} "
961
+ f"(cutscene fields; only movement clips are swapped). Add --neutralize-gestures: "
962
+ + " ".join(sorted(gw)))
963
+ print(f" wrote: {out_dir}/campaign.toml")
964
+ print(f"Next: ff9mapkit build-all {out_dir}/campaign.toml")
965
+
966
+
967
+ def _cmd_build_all(args: argparse.Namespace) -> int:
968
+ from . import campaign
969
+ try:
970
+ info = campaign.build_campaign(args.campaign, out=args.out, author=args.author or "",
971
+ description=args.description or "", allow_artless=args.allow_artless)
972
+ except (campaign.CampaignError, FileNotFoundError, ValueError, RuntimeError) as e:
973
+ print(str(e), file=sys.stderr)
974
+ return 2
975
+ plan = info["plan"]
976
+ print(f"built campaign '{plan.name}' (mod {plan.mod_folder}, {len(info['dictionary'])} fields) -> {info['out']}")
977
+ for line in info["dictionary"]:
978
+ print(" " + line)
979
+ for w in info["warnings"]:
980
+ print(" warning: " + w, file=sys.stderr)
981
+ print(f"Next: add '{plan.mod_folder}' to Memoria.ini [Mod] FolderNames + relaunch, then deploy-all (P4).")
982
+ return 0
983
+
984
+
985
+ def _cmd_lint_campaign(args: argparse.Namespace) -> int:
986
+ from pathlib import Path
987
+ from . import campaign
988
+ try:
989
+ plan = campaign.load_campaign(args.campaign)
990
+ errors, warnings = campaign.lint_campaign(plan, Path(args.campaign).parent)
991
+ except (campaign.CampaignError, FileNotFoundError, ValueError) as e:
992
+ print(str(e), file=sys.stderr)
993
+ return 2
994
+ if getattr(args, "graph", False):
995
+ print(campaign.render_graph(plan))
996
+ for w in warnings:
997
+ print("warning: " + w, file=sys.stderr)
998
+ for e in errors:
999
+ print("error: " + e, file=sys.stderr)
1000
+ if errors:
1001
+ print(f"campaign '{plan.name}': FAILED -- {len(errors)} error(s), {len(warnings)} warning(s)",
1002
+ file=sys.stderr)
1003
+ return 2
1004
+ print(f"campaign '{plan.name}' OK -- {len(plan.members)} members, {len(plan.edges)} edges, "
1005
+ f"{len(plan.seams)} seams, {len(warnings)} warning(s)")
1006
+ return 0
1007
+
1008
+
1009
+ def _cmd_new_campaign(args: argparse.Namespace) -> int:
1010
+ from pathlib import Path
1011
+ from . import campaign
1012
+ cfg = _deploy_cfg()
1013
+ id_base = args.id_base if args.id_base is not None else int(cfg.get("campaign_id_base", 4000))
1014
+ mod_folder = args.mod_folder or cfg.get("mod_folder") or "FF9CustomMap"
1015
+ try:
1016
+ plan = campaign.new_campaign(args.name, mod_folder, Path(args.dir), id_base=id_base,
1017
+ flag_base=args.flag_base, flags_per_field=args.flags_per_field)
1018
+ except campaign.CampaignError as e:
1019
+ print(str(e), file=sys.stderr)
1020
+ return 2
1021
+ cpath = Path(args.dir) / "campaign.toml"
1022
+ print(f"created empty campaign '{plan.name}' at {cpath} (id_base {plan.id_base}, "
1023
+ f"mod_folder {plan.mod_folder}).\nNext: ff9mapkit add-field {cpath} --name ROOM1")
1024
+ return 0
1025
+
1026
+
1027
+ def _cmd_add_field(args: argparse.Namespace) -> int:
1028
+ from pathlib import Path
1029
+ from . import campaign
1030
+ cpath = Path(args.campaign)
1031
+ try:
1032
+ plan = campaign.load_campaign(cpath)
1033
+ m = campaign.add_field(plan, cpath.parent, name=args.name, source=args.source, game=args.game)
1034
+ except (campaign.CampaignError, RuntimeError, FileNotFoundError, ValueError) as e:
1035
+ print(str(e), file=sys.stderr)
1036
+ return 2
1037
+ kind = f"forked field {args.source}" if args.source else "blank room"
1038
+ print(f"added {m.name} (id {m.new_id}, {kind}) -> {m.toml_rel}; campaign now has {len(plan.members)} "
1039
+ f"member(s).\nEdit it: ff9mapkit edit {cpath.parent / m.toml_rel}")
1040
+ return 0
1041
+
1042
+
1043
+ def _cmd_import_chain(args: argparse.Namespace) -> int:
1044
+ from pathlib import Path
1045
+ from . import chain, eventscan, extract
1046
+ if getattr(args, "neutralize_gestures", False) and not getattr(args, "swap_player", None):
1047
+ print("--neutralize-gestures requires --swap-player (it rewrites the swapped rig's gestures)",
1048
+ file=sys.stderr)
1049
+ return 2
1050
+ if getattr(args, "swap_player", None): # validate the char + force verbatim BEFORE the (costly) walk
1051
+ from . import playerswap
1052
+ try:
1053
+ playerswap.resolve_char(args.swap_player)
1054
+ except ValueError as e:
1055
+ print(str(e), file=sys.stderr)
1056
+ return 2
1057
+ args.verbatim = True # the swap patches each member's donor player entry
1058
+ try:
1059
+ seeds = _resolve_chain_seeds(args.seed, game=args.game)
1060
+ bundle = extract.EventBundle(game=args.game)
1061
+ except (RuntimeError, FileNotFoundError, ValueError) as e:
1062
+ print(str(e), file=sys.stderr)
1063
+ return 2
1064
+
1065
+ restrict_ids = None # set by --ids: bound the walk to exactly the cluster
1066
+ if getattr(args, "ids", None) and getattr(args, "whole_zone", False):
1067
+ print("--ids and --whole-zone are mutually exclusive (--ids forks an explicit cluster; --whole-zone "
1068
+ "forks the whole zone)", file=sys.stderr)
1069
+ return 2
1070
+ if getattr(args, "ids", None): # fork EXACTLY this id set -> scope to one story-state cluster
1071
+ try:
1072
+ want = chain.parse_id_ranges(args.ids)
1073
+ except ValueError as e:
1074
+ print(f"--ids: {e}", file=sys.stderr)
1075
+ return 2
1076
+ live = extract.build_field_index(args.game, verbose=False) # folder_lower -> bundle (LIVE-forkable only)
1077
+ missing = [fid for fid in want if fid not in extract.ID_TO_FBG]
1078
+ if missing:
1079
+ print(f"--ids: {len(missing)} id(s) have no background field and were dropped: "
1080
+ f"{chain.format_id_ranges(missing)}", file=sys.stderr)
1081
+ not_live = [fid for fid in want if fid in extract.ID_TO_FBG and fid not in seeds
1082
+ and extract.ID_TO_FBG[fid].lower() not in live] # in the static table but no install bundle
1083
+ if not_live:
1084
+ print(f"--ids: {len(not_live)} id(s) have no live background bundle in this install and were "
1085
+ f"dropped: {chain.format_id_ranges(not_live)}", file=sys.stderr)
1086
+ extra = sorted(fid for fid in want if fid not in seeds
1087
+ and fid in extract.ID_TO_FBG
1088
+ and extract.ID_TO_FBG[fid].lower() in live) # skip table-only variants with NO live bundle
1089
+ seeds = seeds + extra # original seed(s) FIRST -> entry_field stays the intended entry
1090
+ restrict_ids = set(seeds) # bound the BFS to the cluster so a door can't pull in a
1091
+ # same-zone sibling visit (the leak finding #1 fix)
1092
+ args.max_fields = max(args.max_fields, len(seeds)) # never truncate the cluster we asked for
1093
+ elif getattr(args, "whole_zone", False): # seed EVERY forkable field in the seed's zone(s) -> the whole
1094
+ live = extract.build_field_index(args.game, verbose=False) # folder_lower -> bundle (LIVE-forkable only)
1095
+ seed_zones = {chain.zone_label(extract.ID_TO_FBG[s]) # zone forks, not just the door-reachable
1096
+ for s in seeds if s in extract.ID_TO_FBG} # slice (cutscene-only screens included)
1097
+ extra = sorted(fid for fid, folder in extract.ID_TO_FBG.items()
1098
+ if chain.zone_label(folder) in seed_zones and fid not in seeds
1099
+ and folder.lower() in live) # skip table-only variants with NO live bundle
1100
+ seeds = seeds + extra # original seed(s) FIRST -> entry_field stays the intended entry
1101
+ args.max_fields = max(args.max_fields, len(seeds)) # never truncate the zone we asked for
1102
+
1103
+ def zone_fn(fid):
1104
+ return chain.zone_label(extract.ID_TO_FBG.get(int(fid)))
1105
+
1106
+ def forkable_fn(fid):
1107
+ return int(fid) in extract.ID_TO_FBG # has a real background -> a walkable field we can fork
1108
+
1109
+ def scan_fn(fid):
1110
+ eb = bundle.eb_for_id(fid)
1111
+ if eb is None:
1112
+ return {"found": False}
1113
+ warps = eventscan.scan_all_warps(eb)
1114
+ edges = [{"to": g["to"], "kind": chain.WALK_IN, "entrance": g["entrance"],
1115
+ "zone": g["zone"], "story_conditional": g["story_conditional"]}
1116
+ for g in warps["walk_in"]]
1117
+ edges += [{"to": s["to"], "kind": chain.SCRIPTED, "entrance": s["entrance"],
1118
+ "trigger": s["trigger"]} for s in warps["scripted"]]
1119
+ return {"found": True, "edges": edges, "overworld_exits": warps["overworld_exits"],
1120
+ "encounter": eventscan.scan_encounter(eb), "music": eventscan.scan_music(eb)}
1121
+
1122
+ zones = [z.strip().lower() for z in args.zones.split(",") if z.strip()] if args.zones else None
1123
+ stop_at = [int(x) for x in args.stop_at.split(",") if x.strip()] if args.stop_at else None
1124
+ result = chain.walk(seeds, scan_fn, zone_fn, forkable_fn=forkable_fn, max_hops=args.max_hops,
1125
+ zones=zones, stop_at=stop_at, max_fields=args.max_fields,
1126
+ follow_scripted=args.follow_scripted,
1127
+ stop_at_zone_boundary=not args.cross_zones, restrict_ids=restrict_ids)
1128
+
1129
+ def _zone_members(z): # all forkable fields in a zone (for the coverage hint)
1130
+ return [fid for fid, folder in extract.ID_TO_FBG.items() if chain.zone_label(folder) == z]
1131
+ # the coverage hint nudges toward --whole-zone for an under-forked zone; with --ids the partial fork is
1132
+ # DELIBERATE (one story-state cluster), so the "you missed 30 fields" nudge would be misleading -> skip it.
1133
+ coverage = {} if restrict_ids is not None else chain.zone_coverage(result, _zone_members)
1134
+
1135
+ if args.out: # P2 write mode: fork the chain into campaign/
1136
+ from . import campaign
1137
+ cfg = _deploy_cfg()
1138
+ id_base = args.id_base if args.id_base is not None else int(cfg.get("campaign_id_base", 6000))
1139
+ mod_folder = args.mod_folder or cfg.get("mod_folder") or "FF9CustomMap-ow"
1140
+ seed_zone = chain.zone_label(extract.ID_TO_FBG.get(seeds[0]))
1141
+ cname = args.campaign_name or f"{seed_zone.upper()}_CAMPAIGN" # --swap-player validated at fn top (fail-fast)
1142
+ # STABLE-ID mode: a re-fork reuses the EXISTING <out>/campaign.toml's donor->id+name map so an in-fork
1143
+ # SAVE survives (default ON when --out already holds one; --fresh-ids opts out). Same dir only -- the
1144
+ # carried member files live under --out, so reuse and re-fork share one tree.
1145
+ prior_plan = None
1146
+ prior_src = Path(args.out) / "campaign.toml"
1147
+ if not getattr(args, "fresh_ids", False) and prior_src.exists():
1148
+ try:
1149
+ prior_plan = campaign.load_campaign(prior_src)
1150
+ except Exception as e: # a corrupt/non-manifest toml -> fall back to fresh ids
1151
+ print(f"warn: could not read {prior_src} for stable ids ({e}); allocating FRESH ids "
1152
+ f"(re-fork will shift them -- stale in-fork saves)", file=sys.stderr)
1153
+ if prior_plan is not None:
1154
+ # Loud guards: silently reusing the WRONG prior, or changing the flag geometry, corrupts saves.
1155
+ if prior_plan.name and prior_plan.name != cname:
1156
+ print(f"warn: --out holds campaign '{prior_plan.name}' but this fork is '{cname}' -- reusing its "
1157
+ f"ids for stable allocation. If that's not the same campaign, use --fresh-ids or a clean "
1158
+ f"--out.", file=sys.stderr)
1159
+ if prior_plan.flag_base != args.flag_base or prior_plan.flags_per_field != args.flags_per_field:
1160
+ print(f"warn: flag geometry changed (prior flag_base={prior_plan.flag_base}/"
1161
+ f"per_field={prior_plan.flags_per_field} -> now {args.flag_base}/{args.flags_per_field}); "
1162
+ f"ids stay stable but EVERY member's story-flag window shifts -- in-fork save flags will "
1163
+ f"desync. Keep them equal to preserve saves.", file=sys.stderr)
1164
+ try:
1165
+ plan = campaign.write_campaign(result, Path(args.out), id_base=id_base,
1166
+ flag_base=args.flag_base, flags_per_field=args.flags_per_field,
1167
+ name=cname, mod_folder=mod_folder, game=args.game, live_seams=args.live_seams,
1168
+ verbatim=args.verbatim, swap_player=getattr(args, "swap_player", None),
1169
+ neutralize_gestures=getattr(args, "neutralize_gestures", False),
1170
+ name_prefix=getattr(args, "name_prefix", "") or "", prior_plan=prior_plan)
1171
+ except (RuntimeError, FileNotFoundError, ValueError) as e:
1172
+ print(str(e), file=sys.stderr)
1173
+ return 2
1174
+ if prior_plan is not None:
1175
+ real_priors = sum(1 for m in prior_plan.members if m.real_id)
1176
+ print(f"stable-ids: reused {len(plan.reused_ids)} prior id(s) from {prior_src}"
1177
+ + (f", appended {len(plan.appended_ids)} new (>{max([m.new_id for m in prior_plan.members], default=0)})"
1178
+ if plan.appended_ids else "")
1179
+ + (f", carried {len(plan.carried)} not-re-discovered ({', '.join(plan.carried)})" if plan.carried else "")
1180
+ + " -- in-fork saves survive this re-fork.")
1181
+ if real_priors and not plan.reused_ids: # nothing matched -> almost certainly the wrong manifest
1182
+ print(f" warn: 0 of {real_priors} prior donor(s) were re-discovered -- '{prior_src}' looks like a "
1183
+ f"DIFFERENT campaign. If so, use --fresh-ids or a clean --out (else its ids leak in).",
1184
+ file=sys.stderr)
1185
+ for nm, fid in plan.carried_missing:
1186
+ print(f" warn: prior member {nm} (id {fid}) has no files on disk -- dropped; later members' "
1187
+ f"flag windows may shift. Re-append it or restore its dir.", file=sys.stderr)
1188
+ _print_campaign_summary(plan, args.out, verbatim=args.verbatim)
1189
+ cov_lines = chain.render_coverage(coverage)
1190
+ if cov_lines:
1191
+ print("\nUNDER-FORKED ZONES (the bytes are there -- re-fork with --whole-zone to capture them):")
1192
+ print("\n".join(cov_lines))
1193
+ return 0
1194
+
1195
+ print(chain.render(result, label_fn=_chain_label_fn(game=args.game), coverage=coverage))
1196
+ return 0
1197
+
1198
+
1199
+ def _cmd_battle_import(args: argparse.Namespace) -> int:
1200
+ from pathlib import Path
1201
+ from .battle import extract as bextract
1202
+ try:
1203
+ meta, toml = bextract.write_battle_project(
1204
+ args.bbg, Path(args.out), name=args.name, scene_id=args.id, game=args.game,
1205
+ fork_scene=args.fork_scene, ship_as=args.ship_as)
1206
+ except (RuntimeError, FileNotFoundError, ValueError) as e:
1207
+ print(str(e), file=sys.stderr)
1208
+ return 2
1209
+ print(f"imported {meta['bbg']} ({meta['groups']} groups, {meta['geometries']} meshes, "
1210
+ f"{len(meta['textures'])} textures)")
1211
+ if meta.get("scene"):
1212
+ s = meta["scene"]
1213
+ print(f" forked scene {s['donor']} (id {s['donor_id']}): raw16 {s['raw16']}B + raw17 {s['raw17']}B"
1214
+ f" + eb/mes x{s['langs']} -> MINT (scene_id {args.id})")
1215
+ if s.get("mes_note"):
1216
+ print(f"warning: {s['mes_note']}", file=sys.stderr)
1217
+ print(f" wrote : {toml} (+ {meta['bbg']}.fbx + image#.png"
1218
+ f"{' + scene/' if meta.get('scene') else ''})")
1219
+ nxt = ("edit %s.fbx in Blender / repaint PNGs, then: ff9mapkit battle-build %s" % (meta['bbg'], toml))
1220
+ if meta.get("scene"):
1221
+ nxt += " then py tools/deploy_battle.py %s --trigger-field 5000 (relaunch + walk)" % toml
1222
+ print(f"Next: {nxt}")
1223
+ return 0
1224
+
1225
+
1226
+ def _cmd_battle_build(args: argparse.Namespace) -> int:
1227
+ from pathlib import Path
1228
+ from .battle.build import BattleBuildError, BattleProject, build_battle_mod
1229
+ try:
1230
+ projects = [BattleProject.load(p) for p in args.battle]
1231
+ except (OSError, ValueError) as e:
1232
+ print(f"failed to load project: {e}", file=sys.stderr)
1233
+ return 2
1234
+ try:
1235
+ info = build_battle_mod(projects, Path(args.out), mod_name=args.mod_name,
1236
+ author=args.author, description=args.description, game=args.game)
1237
+ except (BattleBuildError, ValueError) as e:
1238
+ print(str(e), file=sys.stderr)
1239
+ return 2
1240
+ print(f"built battle mod '{args.mod_name}' -> {info['root']}")
1241
+ for m in info["maps"]:
1242
+ print(f" map: {m}")
1243
+ for line in info["dictionary"]:
1244
+ print(f" DictionaryPatch: {line}")
1245
+ for line in info["battle_patch"]:
1246
+ print(f" BattlePatch: {line}")
1247
+ for w in info["warnings"]:
1248
+ print(f"warning: {w}", file=sys.stderr)
1249
+ for ln in info.get("lint", []):
1250
+ print(f" lint {ln}")
1251
+ print("To install reversibly into your mod folder: py tools/deploy_battle.py <battle.toml>")
1252
+ return 0
1253
+
1254
+
1255
+ def _cmd_battle_list(args: argparse.Namespace) -> int:
1256
+ from .battle import extract as bextract
1257
+ try:
1258
+ if args.scenes:
1259
+ rows = bextract.list_battle_scenes(args.pattern, game=args.game)
1260
+ kind = "battle scene(s) [mint donors]"
1261
+ else:
1262
+ rows = bextract.list_battle_maps(args.pattern, game=args.game)
1263
+ kind = "battle map(s)"
1264
+ except (RuntimeError, FileNotFoundError) as e:
1265
+ print(str(e), file=sys.stderr)
1266
+ return 2
1267
+ for n in rows:
1268
+ print(n)
1269
+ print(f"{len(rows)} {kind}")
1270
+ return 0
1271
+
1272
+
1273
+ def _cmd_battle_actions(args: argparse.Namespace) -> int:
1274
+ """List the shared PLAYER ability table (Actions.csv) + the scriptId formula catalog (read-live)."""
1275
+ _safe_console()
1276
+ from .battle import battlecsv as B
1277
+ if args.script_ids:
1278
+ for sid in sorted(B.SCRIPT_IDS):
1279
+ print(f" {sid:>3} {B.SCRIPT_IDS[sid]}")
1280
+ print(f"\n{len(B.SCRIPT_IDS)} stock battle-calc formulas. Re-pointing an action's scriptId at one of "
1281
+ "these is pure CSV (no DLL);\na NEW formula needs a Memoria.Scripts.<Mod>.dll (not the engine DLL).")
1282
+ return 0
1283
+ if not B.available(game=args.game):
1284
+ print("needs your FF9 install (StreamingAssets/Data/Battle/Actions.csv); set FF9_GAME_PATH "
1285
+ "or run from the game dir.", file=sys.stderr)
1286
+ return 2
1287
+ rows = B.actions(game=args.game)
1288
+ if args.filter:
1289
+ f = args.filter.lower()
1290
+ rows = [a for a in rows if f in a.name.lower()]
1291
+ for a in rows:
1292
+ print(f" {a.id:>3} {a.summary()}")
1293
+ print(f"\n{len(rows)} action(s) -- the PLAYER ability table. (Enemy attacks live per-scene in the raw16; "
1294
+ "see `battle-scene`.)")
1295
+ return 0
1296
+
1297
+
1298
+ def _cmd_characters(args: argparse.Namespace) -> int:
1299
+ """List the playable characters' base combat stats (BaseStats.csv, read-live) -- the ``[[character]]``
1300
+ targets. The player side of battle tuning; the growth curve = ``[[leveling]]`` (Leveling.csv)."""
1301
+ _safe_console()
1302
+ from .battle import characterdelta as CD
1303
+ cat = CD.basestats_catalog(game=args.game)
1304
+ if cat is None:
1305
+ print("needs your FF9 install (StreamingAssets/Data/Characters/BaseStats.csv); set FF9_GAME_PATH "
1306
+ "or run from the game dir.", file=sys.stderr)
1307
+ return 2
1308
+ for name, cid, stats in cat:
1309
+ print(f" {cid:>2} {name:<10} " + " ".join(f"{s[:3].title()} {v}" for s, v in stats))
1310
+ print(f"\n{len(cat)} characters -- the [[character]] targets (BaseStats.csv, partial per-id delta). The "
1311
+ "99-step\ngrowth curve is Leveling.csv ([[leveling]] level = N, whole-file).")
1312
+ return 0
1313
+
1314
+
1315
+ def _cmd_battle_ai(args: argparse.Namespace) -> int:
1316
+ """Disassemble a battle scene's enemy AI (EVT_BATTLE_<scene>.eb) -- the read-only 'see the enemy's AI' view:
1317
+ Main_Init spawn-binding + per-type AI functions by tag, with named commands + annotated expressions.
1318
+ With ``--asm`` instead ASSEMBLES an expression (the disassembler's inverse) -> its bytes + a re-disasm proof."""
1319
+ _safe_console()
1320
+ if args.asm is not None: # Phase-6c-i: assemble an AI expression -> bytes
1321
+ from .eb import exprasm, disasm
1322
+ try:
1323
+ b = exprasm.assemble(args.asm)
1324
+ except exprasm.AssembleError as e:
1325
+ print(f"assemble error: {e}", file=sys.stderr)
1326
+ return 2
1327
+ back, _ = disasm.pretty_expr(b, 0)
1328
+ print(f"bytes ({len(b)}): {b.hex(' ')}\nre-disasm: {back}")
1329
+ return 0
1330
+ from .battle import battleai as BA
1331
+ if args.asm_block is not None: # Phase-6c-ii: assemble a COMMAND block -> bytes
1332
+ from .eb import cmdasm
1333
+ try:
1334
+ b = cmdasm.assemble_block(args.asm_block.replace(";", "\n")) # ';' separates lines for a 1-line arg
1335
+ except cmdasm.CmdAsmError as e:
1336
+ print(f"assemble error: {e}", file=sys.stderr)
1337
+ return 2
1338
+ print(f"bytes ({len(b)}): {b.hex(' ')}")
1339
+ for off, mn, ops in BA._decode_func_pretty(b, 0, len(b)): # the re-disassembly proof
1340
+ print(f" [{off}] {mn}({', '.join(ops)})")
1341
+ return 0
1342
+ if not args.donor:
1343
+ print("a scene name is required (or use --asm / --asm-block to assemble AI source)", file=sys.stderr)
1344
+ return 2
1345
+ if args.lint: # Phase-6c-iii: lint a scene's enemy AI offline
1346
+ from .battle import ailint, extract, scene_data
1347
+ try:
1348
+ eb = BA._scene_eb(args.donor, game=args.game)
1349
+ except (RuntimeError, FileNotFoundError, ValueError) as e:
1350
+ print(str(e), file=sys.stderr)
1351
+ return 2
1352
+ atk = None
1353
+ try: # the scene's attack count enables the Attack-idx check
1354
+ assets = extract.read_scene_assets(args.donor, game=args.game)
1355
+ if assets.get("raw16"):
1356
+ atk = scene_data.parse_counts(assets["raw16"])[2]
1357
+ except Exception: # noqa: BLE001 -- atk-count is optional
1358
+ atk = None
1359
+ issues = ailint.lint_ai(eb, atk_count=atk)
1360
+ if not issues:
1361
+ print(f"# {args.donor} AI: clean ({'no attack-idx check -- ' if atk is None else ''}no issues)")
1362
+ return 0
1363
+ print(f"# {args.donor} AI: {len(issues)} issue(s)")
1364
+ for i in issues:
1365
+ print(f" {i}")
1366
+ return 1
1367
+ try:
1368
+ print(BA.scene_ai_sites(args.donor, game=args.game) if args.sites
1369
+ else BA.analyze_scene(args.donor, game=args.game))
1370
+ except (RuntimeError, FileNotFoundError, ValueError) as e:
1371
+ print(str(e), file=sys.stderr)
1372
+ return 2
1373
+ return 0
1374
+
1375
+
1376
+ def _cmd_battle_seq(args: argparse.Namespace) -> int:
1377
+ """Disassemble a battle scene's attack SEQUENCES (btlseq.raw17) -- the read-only 'see the choreography'
1378
+ view: each sub_no (attack index) as named instructions with resolved anim ids. With ``--sites`` lists the
1379
+ patchable operands (offset/value) for ``[[scene.seq_patch]]`` instead of the disassembly."""
1380
+ _safe_console()
1381
+ from .battle import seqdis as SD
1382
+ if args.asm is not None: # assemble a sequence source -> bytes + a re-disasm proof
1383
+ from .battle import seqasm
1384
+ try:
1385
+ instrs = seqasm.assemble(args.asm)
1386
+ except seqasm.SeqAsmError as e:
1387
+ print(f"assemble error: {e}", file=sys.stderr)
1388
+ return 2
1389
+ b = seqasm.assemble_bytes(instrs)
1390
+ print(f"bytes ({len(b)}): {b.hex(' ')}")
1391
+ for ins in instrs: # the re-disassembly proof (canonical form)
1392
+ print(f" {seqasm.to_source([ins])}")
1393
+ return 0
1394
+ if not args.donor:
1395
+ print("a scene name is required (or use --asm to assemble a sequence source)", file=sys.stderr)
1396
+ return 2
1397
+ if args.lint: # lint a scene's sequences offline (anim-code range, etc.)
1398
+ from .battle import seqauthor
1399
+ try:
1400
+ raw17 = SD._scene_raw17(args.donor, game=args.game)
1401
+ except (RuntimeError, FileNotFoundError, ValueError) as e:
1402
+ print(str(e), file=sys.stderr)
1403
+ return 2
1404
+ issues = seqauthor.lint_seq(raw17)
1405
+ if not issues:
1406
+ print(f"# {args.donor} sequences: clean")
1407
+ return 0
1408
+ print(f"# {args.donor} sequences: {len(issues)} issue(s)")
1409
+ for i in issues:
1410
+ print(f" {i}")
1411
+ return 1
1412
+ try:
1413
+ out = (SD.scene_seq_sites(args.donor, game=args.game) if args.sites
1414
+ else SD.analyze_scene_seq(args.donor, game=args.game))
1415
+ except (RuntimeError, FileNotFoundError, ValueError) as e:
1416
+ print(str(e), file=sys.stderr)
1417
+ return 2
1418
+ print(out)
1419
+ return 2 if "<unreadable" in out else 0 # a malformed raw17 disasm exits non-zero (parity w/ --sites)
1420
+
1421
+
1422
+ def _cmd_ability_gems(args: argparse.Namespace) -> int:
1423
+ """List the support abilities + their gem COSTS (AbilityGems.csv, read-live) -- the ``[[ability_gem]]``
1424
+ targets. Re-costing a support ability is the build-economy balance lever."""
1425
+ _safe_console()
1426
+ from .battle import characterdelta as CD
1427
+ cat = CD.ability_gems_catalog(game=args.game)
1428
+ if cat is None:
1429
+ print("needs your FF9 install (StreamingAssets/Data/Characters/Abilities/AbilityGems.csv); set "
1430
+ "FF9_GAME_PATH or run from the game dir.", file=sys.stderr)
1431
+ return 2
1432
+ f = (args.filter or "").lower()
1433
+ rows = [r for r in cat if not f or f in r[0].lower()]
1434
+ for name, aid, gems in rows:
1435
+ print(f" {aid:>2} {name:<16} {gems:>3} gems")
1436
+ print(f"\n{len(rows)} support abilities -- the [[ability_gem]] targets (AbilityGems.csv, partial per-id "
1437
+ "delta).\n[[ability_gem]] ability = \"<name or id>\", gems = N (re-cost it; cheaper = stronger builds).")
1438
+ return 0
1439
+
1440
+
1441
+ def _cmd_ability_features(args: argparse.Namespace) -> int:
1442
+ """Preview the ``AbilityFeatures.txt`` a field.toml's ``[[ability_feature]]`` blocks emit (the no-DLL
1443
+ ability-EFFECT DSL: SA/AA/CMD), or ``--tags`` for the per-kind ``[code=...]`` tag + SA-name reference. The
1444
+ actual file is written + deployed (reversibly) by build/deploy_field; RELAUNCH to apply (startup-loaded)."""
1445
+ _safe_console()
1446
+ from pathlib import Path
1447
+ from .battle import abilityfeatures as AF
1448
+ from .battle import characterdelta as CD
1449
+ if args.tags or not args.toml:
1450
+ print("Supporting abilities (>SA, id 0-63) -- the [[ability_feature]] kind=\"SA\" targets:")
1451
+ names = CD._SA_NAMES
1452
+ for r in range(0, len(names), 4):
1453
+ print(" " + "".join(f"{i:>2} {names[i]:<15}" for i in range(r, min(r + 4, len(names)))))
1454
+ print("\n>SA feature types (the first token of a body line):\n " + " / ".join(AF._SA_FEATURE_KW))
1455
+ print("\n>AA [code=...] tags: " + " ".join(sorted(AF._AA_TAGS)))
1456
+ print(">CMD [code=...] tags: " + " ".join(sorted(AF._CMD_TAGS)))
1457
+ print("\n[[ability_feature]] kind=\"SA\"|\"AA\"|\"CMD\" ability=\"<name/id>\" cumulate=true "
1458
+ "features=\"\"\"...\"\"\"")
1459
+ print(" >AA ability = an Actions.csv id 0-191 (or a name, resolved at build); >CMD = an int command id.")
1460
+ return 0
1461
+ import tomllib
1462
+ try:
1463
+ raw = tomllib.loads(Path(args.toml).read_text(encoding="utf-8"))
1464
+ except (OSError, tomllib.TOMLDecodeError) as e:
1465
+ print(f"failed to read {args.toml}: {e}", file=sys.stderr)
1466
+ return 2
1467
+ feats = raw.get("ability_feature")
1468
+ if not feats:
1469
+ print("no [[ability_feature]] blocks in this toml.", file=sys.stderr)
1470
+ return 0
1471
+ try:
1472
+ lines, warnings = AF.build_lines(feats, game=args.game)
1473
+ except AF.AbilityFeatureError as e:
1474
+ print(str(e), file=sys.stderr)
1475
+ return 2
1476
+ print("\n".join(lines))
1477
+ for w in warnings:
1478
+ print(f"warning: {w}", file=sys.stderr)
1479
+ return 0
1480
+
1481
+
1482
+ def _cmd_battle_patch(args: argparse.Namespace) -> int:
1483
+ """Preview the ``BattlePatch.txt`` a field.toml's ``[[battle_patch]]`` / ``[[battle_enemy]]`` /
1484
+ ``[[battle_attack]]`` blocks emit (offline, no install) -- or, with ``--fields``, the catalog of tunable
1485
+ field names by token. The actual patch is written + deployed (reversibly) by build/deploy_field."""
1486
+ _safe_console()
1487
+ from .battle import battlepatch as BP
1488
+ if args.fields:
1489
+ for title, m in (("enemy (Enemy: / EnemyByName: / AnyEnemyByName:)", BP.ENEMY_FIELDS),
1490
+ ("attack (Attack: / AttackByName: / AnyAttackByName:)", BP.ATTACK_FIELDS),
1491
+ ("pattern (Pattern:)", BP.PATTERN_FIELDS),
1492
+ ("scene (Battle: flags)", BP.SCENE_FLAGS)):
1493
+ print(f"\n{title}")
1494
+ by_engine: dict = {}
1495
+ for k, (eng, _enc, _max) in m.items():
1496
+ by_engine.setdefault(eng, []).append(k)
1497
+ for eng, keys in by_engine.items():
1498
+ print(f" {'/'.join(keys):<30} -> {eng}")
1499
+ print("\nelement/status fields take NAMES (weak = [\"Fire\"], auto_status = [\"Protect\"]); drop/steal "
1500
+ "take 4 item names/ids ('none'=empty); scene flags take true/false; the rest take integers.")
1501
+ return 0
1502
+ if not args.toml:
1503
+ print("give a field.toml to preview, or `--fields` for the tunable-field catalog", file=sys.stderr)
1504
+ return 2
1505
+ import tomllib
1506
+ from pathlib import Path
1507
+ try:
1508
+ raw = tomllib.loads(Path(args.toml).read_text(encoding="utf-8"))
1509
+ except (OSError, tomllib.TOMLDecodeError) as e:
1510
+ print(f"could not read {args.toml}: {e}", file=sys.stderr)
1511
+ return 2
1512
+ try:
1513
+ lines, warns = BP.build_lines(raw.get("battle_patch"), raw.get("battle_enemy"), raw.get("battle_attack"))
1514
+ except BP.BattlePatchError as e:
1515
+ print(f"battle-patch error: {e}", file=sys.stderr)
1516
+ return 1
1517
+ if not lines:
1518
+ print("no [[battle_patch]] / [[battle_enemy]] / [[battle_attack]] blocks in this field.toml")
1519
+ return 0
1520
+ print("# --- BattlePatch.txt (emitted from this field.toml; merged with BGM + deployed reversibly) ---")
1521
+ for ln in lines:
1522
+ print(ln)
1523
+ for w in warns:
1524
+ print(f" ! {w}", file=sys.stderr)
1525
+ return 0
1526
+
1527
+
1528
+ def _item_label(ids) -> str:
1529
+ from . import items as I
1530
+ names = [I.name_of(i) or str(i) for i in ids if i != 255]
1531
+ return "/".join(names) if names else "-"
1532
+
1533
+
1534
+ def _cmd_battle_scene(args: argparse.Namespace) -> int:
1535
+ """Inspect a REAL battle scene's enemy data: read-only fork its raw16 and print every enemy type's
1536
+ stats / affinities / rewards + the attack table. The 'import -> SEE it' step for battle tuning."""
1537
+ _safe_console()
1538
+ from .battle import battlecsv as B, extract as bextract, scene_codec, scenelint
1539
+ try:
1540
+ assets = bextract.read_scene_assets(args.donor, game=args.game)
1541
+ except (RuntimeError, FileNotFoundError, ValueError) as e:
1542
+ print(str(e), file=sys.stderr)
1543
+ return 2
1544
+ scene = scene_codec.parse_scene(assets["raw16"])
1545
+ flags = ([f for f, on in (("back-attack", scene.back_attack), ("preemptive", scene.preemptive),
1546
+ ("no-escape", not scene.can_escape), ("no-EXP", scene.no_exp)) if on])
1547
+ print(f"scene {args.donor} (id {assets['donor_id']}): {scene.pat_count} pattern(s), "
1548
+ f"{scene.typ_count} enemy type(s), {scene.atk_count} attack(s)"
1549
+ + (f" [{', '.join(flags)}]" if flags else ""))
1550
+ for t, m in enumerate(scene.monsters):
1551
+ print(f"\n enemy type {t}: HP {m.hp} MP {m.mp} Lv {m.level} "
1552
+ f"(Spd {m.speed} Str {m.strength} Mag {m.magic} Spr {m.spirit})")
1553
+ print(f" defence: phys {m.phys_def}/{m.phys_evade} mag {m.mag_def}/{m.mag_evade} hit {m.hit_rate}")
1554
+ aff = [f"{lab} {'/'.join(B.decode_elements(mask))}"
1555
+ for lab, mask in (("weak", m.weak_element), ("null", m.guard_element),
1556
+ ("absorb", m.absorb_element), ("half", m.half_element)) if B.decode_elements(mask)]
1557
+ if aff:
1558
+ print(f" elements: {'; '.join(aff)}")
1559
+ st = [f"{lab} {'/'.join(B.decode_status(mask))}"
1560
+ for lab, mask in (("resist", m.resist_status), ("auto", m.auto_status),
1561
+ ("initial", m.initial_status)) if B.decode_status(mask)]
1562
+ if st:
1563
+ print(f" status: {'; '.join(st)}")
1564
+ print(f" rewards: gil {m.gil} EXP {m.exp} card {m.win_card} "
1565
+ f"drop {_item_label(m.drop)} steal {_item_label(m.steal)}")
1566
+ if scene.attacks:
1567
+ print(f"\n attack table ({scene.atk_count}):")
1568
+ for i, a in enumerate(scene.attacks):
1569
+ els = B.decode_elements(a.elements)
1570
+ extra = (" " + "/".join(els) if els else "") \
1571
+ + (f" rate {a.rate}" if a.rate not in (0, 255) else "") + (f" {a.mp} MP" if a.mp else "")
1572
+ print(f" [{i}] {B.script_name(a.script_id)} pow {a.power}{extra}")
1573
+ aps = sorted({p.ap for p in scene.patterns})
1574
+ print(f"\n AP reward (per formation): {', '.join(str(a) for a in aps)}")
1575
+ print()
1576
+ print(scenelint.format_findings(scenelint.lint_scene(scene)))
1577
+ print(f"\n Fork + tune: ff9mapkit battle-import --fork-scene {args.donor} ... "
1578
+ "then [scene]/[[scene.enemy]] in battle.toml")
1579
+ return 0
1580
+
1581
+
1582
+ def _cmd_list_fields(args: argparse.Namespace) -> int:
1583
+ from . import extract
1584
+ if args.players or args.non_zidane:
1585
+ return _list_fields_with_players(args)
1586
+ try:
1587
+ rows = extract.list_fields(args.pattern, game=args.game)
1588
+ except (RuntimeError, FileNotFoundError) as e:
1589
+ print(str(e), file=sys.stderr)
1590
+ return 2
1591
+ for folder, area, mapid in rows:
1592
+ print(f" area {area:>2} {mapid:<28} ({folder})")
1593
+ print(f"{len(rows)} field(s)")
1594
+ return 0
1595
+
1596
+
1597
+ def _cmd_find_field(args: argparse.Namespace) -> int:
1598
+ from . import extract
1599
+ rows = extract.find_fields(args.query, archive_dir=args.archive)
1600
+ if not rows:
1601
+ print(f"no field matches {args.query!r}", file=sys.stderr)
1602
+ return 1
1603
+ for r in rows:
1604
+ label = r["name"] or r["evt"] or r["fbg"] # friendly HW name, else EVT name, else FBG
1605
+ loc = f" {r['folder']}" if r["folder"] else ""
1606
+ print(f"{r['id']:>5} {label:<30} {r['fbg']}{loc}")
1607
+ return 0
1608
+
1609
+
1610
+ def _list_fields_with_players(args: argparse.Namespace) -> int:
1611
+ """`list-fields --players` / `--non-zidane`: enrich the list with WHO you control in each field
1612
+ (id-centric -- an alternate event script on a shared background is its own row). Reads each .eb."""
1613
+ _safe_console()
1614
+ from . import forkreport as FR
1615
+ if not args.pattern:
1616
+ print("Resolving the controlled player across all fields (reads each .eb, ~30s)...", file=sys.stderr)
1617
+ try:
1618
+ rows, scanned = FR.field_players(game=args.game, pattern=args.pattern,
1619
+ non_zidane_only=args.non_zidane)
1620
+ except (RuntimeError, FileNotFoundError) as e:
1621
+ print(str(e), file=sys.stderr)
1622
+ return 2
1623
+ for fp in rows:
1624
+ label = fp.player + (" *" if fp.non_zidane else "")
1625
+ print(f" {fp.field_id:>5} {label:<24} {fp.fbg}")
1626
+ nz = [fp for fp in rows if fp.non_zidane]
1627
+ cast = sum(1 for fp in nz if fp.playable)
1628
+ drivers = len(nz) - cast
1629
+ scope = " non-Zidane" if args.non_zidane else ""
1630
+ # break the non-Zidane rows into real playable-cast DONORS vs GEO cutscene-driver "players"
1631
+ if nz:
1632
+ bd = f"{cast} playable-cast donor(s)" + (f", {drivers} cutscene-driver model(s)" if drivers else "")
1633
+ tail = (f" (* = non-Zidane: {bd}; fork a donor via --verbatim --swap-player)"
1634
+ if not args.non_zidane else f" ({bd}; fork a donor via --verbatim --swap-player)")
1635
+ else:
1636
+ tail = ""
1637
+ print(f"{len(rows)}{scope} field(s) of {scanned} scanned{tail}")
1638
+ return 0
1639
+
1640
+
1641
+ def _cmd_animations(args: argparse.Namespace) -> int:
1642
+ """List a character's cutscene gestures (pick one by name for `animation = "<name>"`)."""
1643
+ from . import animations as A
1644
+ if not args.character:
1645
+ print("Characters with an animation catalog (use the name as the cutscene actor's preset):")
1646
+ for c in sorted(set(A.TOKENS.values())):
1647
+ friendly = next(k for k, v in A.TOKENS.items() if v == c)
1648
+ print(f" {friendly:<10} ({c}) {len(A.catalog(c)):>3} gestures")
1649
+ print("\nThen: ff9mapkit animations <character> (e.g. ff9mapkit animations vivi)")
1650
+ return 0
1651
+ try:
1652
+ acts = A.actions(args.character)
1653
+ except ValueError as e:
1654
+ print(str(e), file=sys.stderr)
1655
+ return 2
1656
+ if args.filter:
1657
+ f = args.filter.lower()
1658
+ acts = [(a, i) for a, i in acts if f in a]
1659
+ print(f"{args.character}: {len(acts)} gesture(s). In a [cutscene] step write animation = \"<name>\".")
1660
+ print(f" core aliases (every character): {' '.join(sorted(set(A.CORE)))}\n")
1661
+ if args.ids:
1662
+ for a, i in acts:
1663
+ print(f" {a:<26} {i}")
1664
+ else:
1665
+ names = [a for a, _ in acts]
1666
+ for r in range(0, len(names), 3):
1667
+ print(" " + "".join(f"{n:<26}" for n in names[r:r + 3]).rstrip())
1668
+ return 0
1669
+
1670
+
1671
+ def _cmd_flags(args: argparse.Namespace) -> int:
1672
+ """Browse the FF9 story-flag registry (named vars, reserved regions, scenario milestones, safe band)."""
1673
+ from . import flags as F
1674
+ rows = F.registry_rows()
1675
+ if args.filter:
1676
+ f = args.filter.lower()
1677
+ rows = [r for r in rows if f in r[1].lower() or f in r[3].lower()]
1678
+ print(f"{len(rows)} registry entr(ies). Author a custom story flag with a [[flag]] table "
1679
+ f"(name + index in [{F.FIRST_SAFE_FLAG}, {F.CHOICE_SCRATCH_FLOOR})), then gate by name "
1680
+ f'(requires_flag = "<name>").\n')
1681
+ for kind, name, loc, meaning, tier in rows:
1682
+ print(f" [{kind:8}] {name:24} {loc:18} ({tier}) {meaning}")
1683
+ return 0
1684
+
1685
+
1686
+ def _cmd_flags_inspect(args: argparse.Namespace) -> int:
1687
+ """Decode + render a save's gEventGlobal story state. Reads an encrypted SavedData_ww.dat (one report
1688
+ per populated slot), a Memoria plaintext extra-save, or an open save JSON / bare Base64 gEventGlobal."""
1689
+ from . import flags as F
1690
+ from . import save as S
1691
+ try:
1692
+ reports = S.inspect(args.save)
1693
+ except Exception as e: # noqa: BLE001
1694
+ print(f"could not read story state: {e}")
1695
+ return 2
1696
+ multi = len(reports) > 1
1697
+ for i, (label, rep) in enumerate(reports):
1698
+ if multi: # label each slot of a multi-save .dat
1699
+ print(("\n" if i else "") + f"=== {label} ===")
1700
+ print(F.render_report(rep, show_bits=args.all))
1701
+ return 0
1702
+
1703
+
1704
+ def _cmd_items_inspect(args: argparse.Namespace) -> int:
1705
+ """Decode + render a save's items / equipment / gil (read-only) from the Memoria extra file -- the
1706
+ load-authoritative store. One report per populated slot of a SavedData_ww.dat, or one for a given extra."""
1707
+ from . import save_items as SI
1708
+ try:
1709
+ reports = SI.inspect(args.save)
1710
+ except Exception as e: # noqa: BLE001
1711
+ print(f"could not read items/equipment: {e}")
1712
+ return 2
1713
+ multi = len(reports) > 1
1714
+ for i, (label, rep) in enumerate(reports):
1715
+ if multi:
1716
+ print(("\n" if i else "") + f"=== {label} ===")
1717
+ print(SI.render_report(rep))
1718
+ return 0
1719
+
1720
+
1721
+ def _cmd_items_set_gil(args: argparse.Namespace) -> int:
1722
+ """Write a save's gil. Given a Memoria extra-save directly -> writes that extra (load-authoritative). Given a
1723
+ SavedData_ww.dat container + a slot -> writes the encrypted MAIN block AND mirrors to the Memoria extra when
1724
+ present (so a vanilla no-extra save is editable too). Dry-run by default; --apply performs it (backup-guarded)."""
1725
+ from . import save_items as SI
1726
+ try:
1727
+ if SI.load_extra_common(args.save)[0] is not None: # a Memoria extra-save directly
1728
+ rep = SI.set_gil(args.save, args.gil, dry_run=not args.apply, backup=not args.no_backup)
1729
+ print(SI.render_gil_write(rep))
1730
+ else: # a SavedData_ww.dat container + slot
1731
+ block = SI._resolve_block(slot=args.slot, save=args.save_no, autosave=args.autosave)
1732
+ res = SI.set_gil_in_save(args.save, block, args.gil, dry_run=not args.apply,
1733
+ backup=not args.no_backup)
1734
+ print(SI.render_gil_dual(res))
1735
+ except Exception as e: # noqa: BLE001
1736
+ print(f"could not set gil: {e}")
1737
+ return 2
1738
+ return 0
1739
+
1740
+
1741
+ def _cmd_items_set_item(args: argparse.Namespace) -> int:
1742
+ """Set an item's inventory count (count 0 removes it). On a container, dual-writes the MAIN block + the
1743
+ Memoria extra mirror (so a vanilla no-extra save is editable too); on an extra-save directly, writes that.
1744
+ Dry-run unless --apply."""
1745
+ from . import save_items as SI
1746
+ try:
1747
+ if SI.load_extra_common(args.save)[0] is not None: # a Memoria extra-save directly
1748
+ rep = SI.set_item(args.save, args.item, args.count, dry_run=not args.apply, backup=not args.no_backup)
1749
+ print(SI.render_item_write(rep))
1750
+ else: # a SavedData_ww.dat container + slot
1751
+ block = SI._resolve_block(slot=args.slot, save=args.save_no, autosave=args.autosave)
1752
+ res = SI.set_item_in_save(args.save, block, args.item, args.count, dry_run=not args.apply,
1753
+ backup=not args.no_backup)
1754
+ print(SI.render_item_dual(res))
1755
+ except Exception as e: # noqa: BLE001
1756
+ print(f"could not set item: {e}")
1757
+ return 2
1758
+ return 0
1759
+
1760
+
1761
+ def _cmd_items_set_equip(args: argparse.Namespace) -> int:
1762
+ """Set one equip slot of one character (item 'empty'/255 unequips). On a container, dual-writes the MAIN
1763
+ block + the Memoria extra mirror (so a vanilla no-extra save is editable too); on an extra-save directly,
1764
+ writes that. Dry-run unless --apply."""
1765
+ from . import save_items as SI
1766
+ try:
1767
+ if SI.load_extra_common(args.save)[0] is not None: # a Memoria extra-save directly
1768
+ rep = SI.set_equip(args.save, args.character, args.equip_slot, args.item,
1769
+ dry_run=not args.apply, backup=not args.no_backup)
1770
+ print(SI.render_equip_write(rep))
1771
+ else: # a SavedData_ww.dat container + slot
1772
+ block = SI._resolve_block(slot=args.slot, save=args.save_no, autosave=args.autosave)
1773
+ res = SI.set_equip_in_save(args.save, block, args.character, args.equip_slot, args.item,
1774
+ dry_run=not args.apply, backup=not args.no_backup)
1775
+ print(SI.render_equip_dual(res))
1776
+ except Exception as e: # noqa: BLE001
1777
+ print(f"could not set equipment: {e}")
1778
+ return 2
1779
+ return 0
1780
+
1781
+
1782
+ def _cmd_items_set_keyitem(args: argparse.Namespace) -> int:
1783
+ """Give / remove a KEY (important) item by name. On a container, dual-writes the MAIN block's rareItems +
1784
+ the Memoria extra's rareItemsEx (so a vanilla no-extra save is editable too); on an extra-save directly,
1785
+ writes that. Default gives it (obtained); --remove removes it, --used marks it used. Dry-run unless --apply."""
1786
+ from . import save_items as SI
1787
+ try:
1788
+ obtained = not args.remove and not args.not_obtained
1789
+ used = args.used and not args.remove
1790
+ if SI.load_extra_common(args.save)[0] is not None: # a Memoria extra-save directly
1791
+ rep = SI.set_keyitem_extra(args.save, args.keyitem, obtained=obtained, used=used,
1792
+ dry_run=not args.apply, backup=not args.no_backup)
1793
+ print(SI.render_keyitem_write(rep))
1794
+ else: # a SavedData_ww.dat container + slot
1795
+ block = SI._resolve_block(slot=args.slot, save=args.save_no, autosave=args.autosave)
1796
+ res = SI.set_keyitem_in_save(args.save, block, args.keyitem, obtained=obtained, used=used,
1797
+ dry_run=not args.apply, backup=not args.no_backup)
1798
+ print(SI.render_keyitem_dual(res))
1799
+ except Exception as e: # noqa: BLE001
1800
+ print(f"could not set key item: {e}")
1801
+ return 2
1802
+ return 0
1803
+
1804
+
1805
+ def _cmd_items_set_stat(args: argparse.Namespace) -> int:
1806
+ """Set a character's permanent growth stat (Speed/Strength/Magic/Spirit) to a target value -- writes both the
1807
+ displayed `basis` and the hidden equipment `bonus` accumulator so the change shows immediately AND holds
1808
+ through level-ups. On a container, dual-writes the MAIN block + the Memoria extra mirror (vanilla saves
1809
+ editable too); on an extra-save directly, writes that. Dry-run unless --apply."""
1810
+ from . import save_items as SI
1811
+ try:
1812
+ if SI.load_extra_common(args.save)[0] is not None: # a Memoria extra-save directly
1813
+ rep = SI.set_stat_extra(args.save, args.character, args.stat, args.value,
1814
+ dry_run=not args.apply, backup=not args.no_backup)
1815
+ print(SI.render_stat_write(rep))
1816
+ else: # a SavedData_ww.dat container + slot
1817
+ block = SI._resolve_block(slot=args.slot, save=args.save_no, autosave=args.autosave)
1818
+ res = SI.set_stat_in_save(args.save, block, args.character, args.stat, args.value,
1819
+ dry_run=not args.apply, backup=not args.no_backup)
1820
+ print(SI.render_stat_dual(res))
1821
+ except Exception as e: # noqa: BLE001
1822
+ print(f"could not set stat: {e}")
1823
+ return 2
1824
+ return 0
1825
+
1826
+
1827
+ def _cmd_items_set_ap(args: argparse.Namespace) -> int:
1828
+ """Set the AP of a character's ability (so it's mastered / usable). `ability` is a name, an AA:X / SA:X token,
1829
+ a numeric abil_id, or 'all'; `value` is master / max / forget / a number. On a container, dual-writes the MAIN
1830
+ block's pa array + the Memoria extra's pa_extended (so a vanilla no-extra save is editable too); on an
1831
+ extra-save directly, writes that. The editor changes abilities ALREADY in the pool. Dry-run unless --apply."""
1832
+ from . import save_items as SI
1833
+ try:
1834
+ if SI.load_extra_common(args.save)[0] is not None: # a Memoria extra-save directly
1835
+ rep = SI.set_ap_extra(args.save, args.character, args.ability, args.value,
1836
+ dry_run=not args.apply, backup=not args.no_backup)
1837
+ print(SI.render_ability_write(rep))
1838
+ else: # a SavedData_ww.dat container + slot
1839
+ block = SI._resolve_block(slot=args.slot, save=args.save_no, autosave=args.autosave)
1840
+ res = SI.set_ap_in_save(args.save, block, args.character, args.ability, args.value,
1841
+ dry_run=not args.apply, backup=not args.no_backup)
1842
+ print(SI.render_ability_dual(res))
1843
+ except Exception as e: # noqa: BLE001
1844
+ print(f"could not set AP: {e}")
1845
+ return 2
1846
+ return 0
1847
+
1848
+
1849
+ def _cmd_flags_diff(args: argparse.Namespace) -> int:
1850
+ """Diff two saves' gEventGlobal story state (A -> B) -- what a beat / session wrote. Each arg reads the
1851
+ same forms as flags-inspect; with one save, --slot-a/--slot-b pick two slots (default: slot 0 -> slot 1)."""
1852
+ from . import flags as F
1853
+ from . import save as S
1854
+ try:
1855
+ reps_a = S.inspect(args.a)
1856
+ reps_b = S.inspect(args.b) if args.b else reps_a
1857
+ except Exception as e: # noqa: BLE001
1858
+ print(f"could not read story state: {e}")
1859
+ return 2
1860
+ sa = args.slot_a if args.slot_a is not None else 0
1861
+ sb = args.slot_b if args.slot_b is not None else (1 if args.b is None else 0)
1862
+ if not 0 <= sa < len(reps_a):
1863
+ print(f"save A has {len(reps_a)} populated slot(s); --slot-a {sa} is out of range")
1864
+ return 2
1865
+ if not 0 <= sb < len(reps_b):
1866
+ print(f"save B has {len(reps_b)} populated slot(s); --slot-b {sb} is out of range "
1867
+ f"(diffing two slots of one save needs >=2 populated slots)")
1868
+ return 2
1869
+ (la, ra), (lb, rb) = reps_a[sa], reps_b[sb]
1870
+ print(f"A: {la}\nB: {lb}\n")
1871
+ print(F.render_diff(F.diff_reports(ra, rb), show_bits=args.all))
1872
+ return 0
1873
+
1874
+
1875
+ def _cmd_save_edit(args: argparse.Namespace) -> int:
1876
+ """Set a real FF9 save's story state (ScenarioCounter + flags) -- the RECREATE verb. Dry-run unless
1877
+ --out or --in-place is given; --in-place backs the original up first. Never mutates other state."""
1878
+ import os
1879
+ import time
1880
+ import tomllib
1881
+ from . import flags as F
1882
+ from . import save as S
1883
+ try:
1884
+ sv = S.FF9Save.load(args.save)
1885
+ except Exception as e: # noqa: BLE001
1886
+ print(f"could not read save: {e}")
1887
+ return 2
1888
+
1889
+ if args.list:
1890
+ rows = sv.populated()
1891
+ print(f"{len(rows)} populated save(s) in {args.save}:\n")
1892
+ for s in rows:
1893
+ who = "autosave" if s.block == 0 else f"slot {s.slot} save {s.save}"
1894
+ print(f" block {s.block:<3} [{who:14}] ScenarioCounter {s.scenario:<6} {s.beat:<20} chests {s.chests}")
1895
+ return 0
1896
+
1897
+ # pick the target block
1898
+ if args.block is not None:
1899
+ n = args.block
1900
+ elif args.autosave:
1901
+ n = 0
1902
+ elif args.slot is not None and args.save_index is not None:
1903
+ n = S.block_index(args.slot, args.save_index)
1904
+ else:
1905
+ print("pick a save: --list to see them, then --slot S --save V (or --autosave, or --block N).")
1906
+ return 2
1907
+
1908
+ # resolve edits
1909
+ name_map = {}
1910
+ if args.names:
1911
+ try:
1912
+ with open(args.names, "rb") as fh:
1913
+ name_map = F.collect_flag_defs(tomllib.load(fh))
1914
+ except Exception as e: # noqa: BLE001
1915
+ print(f"--names: {e}")
1916
+ return 2
1917
+
1918
+ def _bits(spec):
1919
+ out = []
1920
+ for tok in (spec or "").split(","):
1921
+ tok = tok.strip()
1922
+ if tok:
1923
+ out.append(F.resolve(tok, name_map))
1924
+ return out
1925
+
1926
+ extra = S.extra_file_path(args.save, n)
1927
+ extra_exists = bool(extra and os.path.exists(extra))
1928
+ try:
1929
+ scenario = F.resolve_scenario(args.scenario) if args.scenario else None
1930
+ set_bits, clear_bits = _bits(args.set_flags), _bits(args.clear_flags)
1931
+ # Memoria's per-slot extra file holds the AUTHORITATIVE gEventGlobal (it overrides the vanilla main
1932
+ # block on load), so read from it when present; fall back to the main block for a vanilla-only save.
1933
+ src = S.read_extra_gEventGlobal(extra) if extra_exists else None
1934
+ if src is None:
1935
+ src = sv.gEventGlobal(n)
1936
+ geg = bytearray(src)
1937
+ notes = S.edit_story_state(geg, scenario=scenario, set_flags=set_bits, clear_flags=clear_bits)
1938
+ sv.set_gEventGlobal(n, bytes(geg)) # stage the vanilla main-block edit (in memory)
1939
+ except (ValueError, IndexError) as e:
1940
+ print(f"edit failed: {e}")
1941
+ return 2
1942
+
1943
+ if not notes:
1944
+ print("nothing to change (give --scenario / --set / --clear).")
1945
+ return 0
1946
+ who = "autosave" if n == 0 else f"slot {(n - 1) // 15} save {(n - 1) % 15}"
1947
+ print(f"block {n} [{who}] changes:")
1948
+ for note in notes:
1949
+ print(f" - {note}")
1950
+ print(" Memoria extra file: " + ("present (governs the loaded state)" if extra_exists else "none (vanilla save)"))
1951
+
1952
+ def _backup(path):
1953
+ bak = f"{path}.bak.{time.strftime('%Y%m%d-%H%M%S')}"
1954
+ with open(path, "rb") as s, open(bak, "wb") as d:
1955
+ d.write(s.read())
1956
+ return bak
1957
+
1958
+ if getattr(args, "in_place", False):
1959
+ print(f" backed up -> {_backup(args.save)}")
1960
+ sv.write(args.save)
1961
+ if extra_exists:
1962
+ print(f" backed up -> {_backup(extra)}")
1963
+ S.patch_extra_gEventGlobal(extra, bytes(geg))
1964
+ chk = S.read_extra_gEventGlobal(extra)
1965
+ print(f" patched main block + Memoria extra ({os.path.basename(extra)}); "
1966
+ f"verified extra ScenarioCounter now {chk[0] | chk[1] << 8}")
1967
+ else:
1968
+ print(" patched main block")
1969
+ elif args.out:
1970
+ sv.write(args.out)
1971
+ print(f"wrote edited main container -> {args.out}")
1972
+ if extra_exists:
1973
+ print(" NOTE: --out writes only the main container; the Memoria extra file GOVERNS the loaded "
1974
+ "state and is NOT included -- use --in-place to edit a loadable save.")
1975
+ else:
1976
+ print("(dry run -- pass --in-place to edit the real save, or --out FILE for a main-container copy)")
1977
+ return 0
1978
+
1979
+
1980
+ def _safe_console():
1981
+ """Keep dialogue output (which dumps arbitrary FF9 text -- smart quotes, box-drawing, CJK) from crashing
1982
+ a legacy console: replace any char the console encoding can't represent instead of raising. No-op on a
1983
+ UTF-8 console / when stdout can't be reconfigured."""
1984
+ for stream in (sys.stdout, sys.stderr):
1985
+ try:
1986
+ stream.reconfigure(errors="replace") # keep the console's encoding; just don't crash
1987
+ except Exception: # noqa: BLE001 -- redirected/older stream
1988
+ pass
1989
+
1990
+
1991
+ def _cmd_dialogue(args: argparse.Namespace) -> int:
1992
+ """View the authored dialogue of a field.toml -- every NPC line / event message / choice prompt /
1993
+ cutscene 'say', with its FINAL on-screen wrapping (the well-formatted-text check). Read-only. A
1994
+ campaign.toml (a [campaign] manifest) instead reviews EVERY member field's dialogue in one pass."""
1995
+ _safe_console()
1996
+ import tomllib
1997
+ from . import dialogue as DLG
1998
+ from .build import FieldProject
1999
+ try:
2000
+ with open(args.field, "rb") as fh:
2001
+ data = tomllib.load(fh)
2002
+ except (OSError, tomllib.TOMLDecodeError) as e:
2003
+ print(f"failed to load: {e}", file=sys.stderr)
2004
+ return 2
2005
+ # a campaign manifest has a [campaign] table and [[field]] members (a list); a single field has a
2006
+ # [field] TABLE -- so a field.toml never misroutes even if it carries a stray [campaign] key.
2007
+ is_campaign = "campaign" in data and not isinstance(data.get("field"), dict)
2008
+ if is_campaign:
2009
+ return _dialogue_campaign(args, DLG)
2010
+ try:
2011
+ proj = FieldProject.load(args.field)
2012
+ except (OSError, ValueError) as e:
2013
+ print(f"failed to load: {e}", file=sys.stderr)
2014
+ return 2
2015
+ lines = DLG.project_dialogue(proj)
2016
+ if not lines:
2017
+ print(f"{args.field}: no dialogue (no NPC lines / events / choices / cutscene says).")
2018
+ return 0
2019
+ print(f"dialogue: {args.field} ({len(lines)} line(s))\n")
2020
+ print(DLG.format_lines(lines, clean=args.clean))
2021
+ bad = DLG.flag_overflow(lines)
2022
+ if bad:
2023
+ print(f"{len(bad)} line(s) may overflow the window (an unbreakable wide word) -- check in-game:",
2024
+ file=sys.stderr)
2025
+ for ln in bad:
2026
+ print(f" ! {ln.who}", file=sys.stderr)
2027
+ return 0
2028
+
2029
+
2030
+ def _dialogue_campaign(args: argparse.Namespace, DLG) -> int:
2031
+ """Review every member field's authored dialogue in a campaign.toml, in member order, with a roll-up
2032
+ (total lines + which fields may overflow). A member that fails to load is noted and skipped, not fatal."""
2033
+ from pathlib import Path
2034
+ from . import campaign
2035
+ from .build import FieldProject
2036
+ try:
2037
+ plan = campaign.load_campaign(args.field)
2038
+ except (campaign.CampaignError, OSError, ValueError) as e:
2039
+ print(f"failed to load campaign: {e}", file=sys.stderr)
2040
+ return 2
2041
+ base = Path(args.field).parent
2042
+ members = []
2043
+ for m in plan.members:
2044
+ p = (base / m.toml_rel)
2045
+ label = f"{m.name} (id {m.new_id})"
2046
+ if not campaign._within(base, p): # a crafted/stale toml_rel must not read outside the set
2047
+ members.append((label, None, f"field.toml path escapes the campaign folder ({m.toml_rel})"))
2048
+ continue
2049
+ try:
2050
+ members.append((label, FieldProject.load(p), None))
2051
+ except Exception as e: # noqa: BLE001 -- one broken member must not abort the review
2052
+ members.append((label, None, f"{type(e).__name__}: {e}"))
2053
+ fields = DLG.campaign_dialogue(members)
2054
+ print(f"dialogue (campaign): {plan.name} ({len(fields)} member field(s))\n")
2055
+ total, with_dialogue, overflow = 0, 0, []
2056
+ for fd in fields:
2057
+ if fd.error:
2058
+ print(f"=== {fd.label} === (skipped: {fd.error})\n")
2059
+ continue
2060
+ if not fd.lines:
2061
+ print(f"=== {fd.label} === (no dialogue)\n")
2062
+ continue
2063
+ with_dialogue += 1
2064
+ total += len(fd.lines)
2065
+ print(f"=== {fd.label} === ({len(fd.lines)} line(s))")
2066
+ print(DLG.format_lines(fd.lines, clean=args.clean))
2067
+ bad = DLG.flag_overflow(fd.lines)
2068
+ if bad:
2069
+ overflow.append((fd.label, bad))
2070
+ print(f"total: {total} line(s) across {with_dialogue} field(s) with dialogue.")
2071
+ if overflow:
2072
+ print(f"{len(overflow)} field(s) may overflow the window (an unbreakable wide word) -- check in-game:",
2073
+ file=sys.stderr)
2074
+ for label, bad in overflow:
2075
+ for ln in bad:
2076
+ print(f" ! {label}: {ln.who}", file=sys.stderr)
2077
+ return 0
2078
+
2079
+
2080
+ def _cmd_dialogue_import(args: argparse.Namespace) -> int:
2081
+ """Read a REAL FF9 field's dialogue (or a built mod folder's, with --mod) and show 'NPC -> text' --
2082
+ the 'import from the game to prove plausibility' verb. Reading the live install needs UnityPy."""
2083
+ _safe_console()
2084
+ from . import dialogue as DLG
2085
+ try:
2086
+ if args.mod:
2087
+ lines = DLG.read_local_dialogue(args.mod, args.field, lang=args.lang)
2088
+ src = args.mod
2089
+ else:
2090
+ lines = DLG.read_field_dialogue(args.field, lang=args.lang, game=args.game, zone_id=args.zone_id)
2091
+ src = "the game install"
2092
+ except (RuntimeError, FileNotFoundError, ValueError) as e:
2093
+ print(str(e), file=sys.stderr)
2094
+ return 2
2095
+ show_all = args.show_all
2096
+ shown = DLG.present(lines, show_system=show_all, dedupe=not show_all)
2097
+ print(f"dialogue-import: {args.field} (from {src}, lang {args.lang}) -- {len(shown)} line(s)\n")
2098
+ print(DLG.format_lines(lines, clean=args.clean, show_system=show_all, dedupe=not show_all))
2099
+ hidden = len(lines) - len(shown)
2100
+ if hidden and not show_all:
2101
+ print(f"({hidden} system/duplicate window(s) hidden -- pass --all to show them)", file=sys.stderr)
2102
+ unresolved = sum(1 for ln in shown if ln.text is None)
2103
+ if unresolved and not args.mod:
2104
+ status = DLG.text_source_status(game=args.game)
2105
+ if status != "ok":
2106
+ print(f"note: {unresolved} line(s) unresolved -- {status}.", file=sys.stderr)
2107
+ else:
2108
+ print(f"note: {unresolved} line(s) had no resolvable text -- the field's text block didn't "
2109
+ "cover them; pass --zone-id <n> to read a specific <n>.mes block directly.", file=sys.stderr)
2110
+ if args.out:
2111
+ import json
2112
+ recs = [{"source": ln.source, "who": ln.who, "txid": ln.txid, "tail": ln.tail,
2113
+ "pos": list(ln.pos) if ln.pos else None, "text": ln.text} for ln in shown]
2114
+ from pathlib import Path
2115
+ Path(args.out).write_text(json.dumps(recs, indent=2, ensure_ascii=False), encoding="utf-8")
2116
+ print(f"wrote {args.out} (SE-derived view -- keep it gitignored)")
2117
+ return 0
2118
+
2119
+
2120
+ def _cmd_fork_report(args: argparse.Namespace) -> int:
2121
+ """Preview, OFFLINE, what a fork of a real field will and won't reproduce (roster / interaction
2122
+ fidelity, story gating, a suggested [startup] beat) -- the 'before you fork, is it faithful?' verb."""
2123
+ _safe_console()
2124
+ from . import forkreport as FR
2125
+ try:
2126
+ fid = FR.resolve_field_id(args.field, game=args.game)
2127
+ if getattr(args, "explain", False):
2128
+ print(FR.format_explain(FR.explain(fid, game=args.game)))
2129
+ return 0
2130
+ rep = FR.analyze(fid, game=args.game)
2131
+ except (RuntimeError, FileNotFoundError, ValueError) as e:
2132
+ print(str(e), file=sys.stderr)
2133
+ return 2
2134
+ print(FR.format_report(rep))
2135
+ return 0
2136
+
2137
+
2138
+ def _cmd_logic_map(args: argparse.Namespace) -> int:
2139
+ """Build a read-only LOGIC MAP of a real field's whole ``.eb`` -- every entry/routine, the resolved call
2140
+ graph (RunScript edges), and the dialogue/item/flag side-effects each routine performs. The legible,
2141
+ inspectable VIEW of a verbatim fork (whose declarative blocks are empty by design)."""
2142
+ _safe_console()
2143
+ from . import forkreport as FR
2144
+ from . import logic_map as LM
2145
+ try:
2146
+ fid = FR.resolve_field_id(args.field, game=args.game)
2147
+ lm = LM.logic_map(fid, game=args.game)
2148
+ except (RuntimeError, FileNotFoundError, ValueError) as e:
2149
+ print(str(e), file=sys.stderr)
2150
+ return 2
2151
+ if getattr(args, "json", False):
2152
+ import json
2153
+ print(json.dumps(LM.to_dict(lm), indent=2))
2154
+ else:
2155
+ print(LM.format_logic_map(lm))
2156
+ return 0
2157
+
2158
+
2159
+ def _cmd_lint_eb(args: argparse.Namespace) -> int:
2160
+ """Structurally lint a field's ``.eb`` (decode / jump bounds / switch bounds / reachable terminator /
2161
+ dangling RunScript) -- the offline soundness check for a verbatim fork or an in-place edit. Accepts a
2162
+ real field id/name OR a path to a ``.eb`` / verbatim ``.bin``. Exit 1 if any ERROR is found."""
2163
+ _safe_console()
2164
+ from . import eblint
2165
+ import os
2166
+ target = args.field
2167
+ try:
2168
+ if os.path.isfile(target):
2169
+ data = open(target, "rb").read()
2170
+ label = os.path.basename(target)
2171
+ else:
2172
+ from . import forkreport as FR
2173
+ from .extract import EventBundle
2174
+ fid = FR.resolve_field_id(target, game=args.game)
2175
+ data = EventBundle(args.game).eb_for_id(fid)
2176
+ label = f"field {fid}"
2177
+ except (RuntimeError, FileNotFoundError, ValueError) as e:
2178
+ print(str(e), file=sys.stderr)
2179
+ return 2
2180
+ if not data:
2181
+ print(f"no .eb bytes for {target} (not present in this install)", file=sys.stderr)
2182
+ return 2
2183
+ issues = eblint.lint_eb(data)
2184
+ errs = eblint.errors(issues)
2185
+ for i in issues:
2186
+ print(str(i))
2187
+ print(f"\n{label}: {len(errs)} error(s), {len(issues) - len(errs)} warning(s)")
2188
+ return 1 if errs else 0
2189
+
2190
+
2191
+ def _cmd_find_rooms(args: argparse.Namespace) -> int:
2192
+ """Sweep all fields for the best swap/demo TEST ROOMS (single-PC + swap-clean + a close 3/4 camera).
2193
+ The 'where can I cleanly walk as a swapped character / see the model's detail?' verb -- a ~45s offline
2194
+ sweep (a cheap .eb prefilter, then a camera read on the survivors)."""
2195
+ _safe_console()
2196
+ from . import forkreport as FR
2197
+ print(f"Sweeping fields for swap/demo rooms (this takes ~45s)...", file=sys.stderr)
2198
+ try:
2199
+ sweep = FR.find_rooms(game=args.game, limit=args.limit, max_fov=args.max_fov)
2200
+ except (RuntimeError, FileNotFoundError) as e:
2201
+ print(str(e), file=sys.stderr)
2202
+ return 2
2203
+ print(FR.format_room_table(sweep))
2204
+ return 0
2205
+
2206
+
2207
+ def _cmd_items(args: argparse.Namespace) -> int:
2208
+ """List FF9 item names + ids (use a name for `give_item = ["<name>", count]`). With --abilities, list each
2209
+ character's learnable abilities (name / AA:X-SA:X token / AP-to-master) for `items-set-ap` instead."""
2210
+ if getattr(args, "abilities", False):
2211
+ from . import abilities as AB
2212
+ if not AB.available():
2213
+ print("ability names need your FF9 install (set FF9_GAME_PATH or run from the game dir). You can "
2214
+ "still edit AP by AA:X / SA:X token or numeric id without it.")
2215
+ return 0
2216
+ want = args.filter.lower() if args.filter else None
2217
+ for mt, pname in AB.PRESET_NAMES.items():
2218
+ pool = [a for a in AB.pool_for_preset(mt) if a.abil_id != 0]
2219
+ if not pool:
2220
+ continue
2221
+ shown = [a for a in pool if not want or want in (a.name or "").lower() or want in a.token.lower()]
2222
+ if not shown:
2223
+ continue
2224
+ print(f"\n{pname} (preset {mt}):")
2225
+ for a in shown:
2226
+ print(f" {a.token:<10} {a.ap_req:>4} AP {a.name or '(unnamed)'}")
2227
+ print("\n Edit with: items-set-ap <save> <character> <name|AA:X|SA:X|id|all> <master|max|forget|N>")
2228
+ return 0
2229
+ from . import items as I
2230
+ from . import itemstats as S
2231
+ rows = [(i, n) for i, n in I.all_items() if n != "NoItem"]
2232
+ if args.filter:
2233
+ f = args.filter.lower()
2234
+ rows = [(i, n) for i, n in rows if f in n.lower()]
2235
+ print(f'{len(rows)} item(s). In an [[event]]/[[choice]] write give_item = ["<name>", count] '
2236
+ f"(or a numeric id).\n")
2237
+ live = S.available()
2238
+ for i, n in rows:
2239
+ s = S.summary(i) if live else None
2240
+ print(f" {i:>3} {n:<16}{' ' + s if s else ''}")
2241
+ if not live:
2242
+ print("\n (stat detail -- weapon power, armor defence, effects -- needs your FF9 install; "
2243
+ "set FF9_GAME_PATH or run from the game dir.)")
2244
+ return 0
2245
+
2246
+
2247
+ def _print_model_detail(m) -> int:
2248
+ """One model + its animation gestures (the (group, token) join)."""
2249
+ from . import catalog as C
2250
+ formk = C.FORM_KIND.get(m.form[:1], "?")
2251
+ print(f"model {m.id}: {m.name}")
2252
+ print(f" group {m.group} ({m.kind}) | form {m.form} ({formk}) | token {m.token}")
2253
+ acts = C.animation_actions(m.id)
2254
+ if not acts:
2255
+ print(" no animations found for this model's (group, token) "
2256
+ "-- often a numbered battle-only model.")
2257
+ return 0
2258
+ npc = C.npc_anims(m.id)
2259
+ if npc and m.field: # the archetype payoff: ready to drop in
2260
+ slots = " ".join(f"{k}={v}" for k, v in npc.items())
2261
+ print(f' place as a field NPC: [[npc]] model = "{m.name}"')
2262
+ print(f" auto-resolved anims: {slots}")
2263
+ core = ("idle", "walk", "run", "turn_l", "turn_r") # movement gestures first
2264
+ ordered = [(a, i) for a in core for (aa, i) in acts if aa == a]
2265
+ ordered += [(a, i) for a, i in acts if a not in core]
2266
+ print(f"\n {len(acts)} animation(s). Use an id for an NPC anim slot or a cutscene `animation`:\n")
2267
+ for r in range(0, len(ordered), 2):
2268
+ print(" " + "".join(f"{a:<22}{i:<8}" for a, i in ordered[r:r + 2]).rstrip())
2269
+ return 0
2270
+
2271
+
2272
+ def _cmd_models(args: argparse.Namespace) -> int:
2273
+ """Browse actor/field models; naming one exactly shows its animation gestures."""
2274
+ from . import catalog as C
2275
+ if args.pattern is not None: # exact id/name -> detail view
2276
+ m = C.model(args.pattern)
2277
+ if m is not None:
2278
+ return _print_model_detail(m)
2279
+ rows = C.models(args.pattern, group=args.group, field_only=args.field)
2280
+ if not rows:
2281
+ where = f" in group {args.group}" if args.group else ""
2282
+ print(f"no models match {args.pattern!r}{where}.", file=sys.stderr)
2283
+ return 0
2284
+ if len(rows) == 1: # a unique match -> jump to detail
2285
+ return _print_model_detail(rows[0])
2286
+ print(f"{len(rows)} model(s). The id is what SetModel() / an [[npc]] `model` takes.\n")
2287
+ for m in rows:
2288
+ tag = f"{m.kind}/{m.form}"
2289
+ extra = f" {len(C.animations_for_model(m.id))} anims" if args.anims else ""
2290
+ print(f" {m.id:>4} {m.name:<22} {tag:<16}{extra}".rstrip())
2291
+ print(f"\nName one to see its gestures: ff9mapkit models {rows[0].name}")
2292
+ return 0
2293
+
2294
+
2295
+ def _cmd_scenes(args: argparse.Namespace) -> int:
2296
+ """List FF9 battle-scene (encounter) ids -- what an [encounter] points SetRandomBattles at."""
2297
+ from . import catalog as C
2298
+ rows = C.battle_scenes(args.pattern)
2299
+ if not rows:
2300
+ print(f"no battle scenes match {args.pattern!r}.", file=sys.stderr)
2301
+ return 0
2302
+ print(f"{len(rows)} battle scene(s). The id goes in an [encounter] (e.g. scenes = [<id>, ...]).\n")
2303
+ for nm, sid in rows:
2304
+ print(f" {sid:>4} {nm}")
2305
+ return 0
2306
+
2307
+
2308
+ def _cmd_sps(args: argparse.Namespace) -> int:
2309
+ """List / decode / preview a field's SPS particle effects (fire/smoke/magic). Install-gated (UnityPy)."""
2310
+ if getattr(args, "templates", False):
2311
+ from .sps import templates as T
2312
+ print("Tier-2 [[sps]] effect templates (use as: template = \"<name>\"):\n")
2313
+ for name, desc, field, sid in T.list_templates():
2314
+ print(f" {name:<10} {desc:<34} (clones {field} #{sid})")
2315
+ return 0
2316
+ if not args.field:
2317
+ print("give a field token (or --templates). e.g. `ff9mapkit sps 303`", file=sys.stderr)
2318
+ return 2
2319
+ from .sps import catalog as SC
2320
+ rows = SC.list_field_sps(args.field)
2321
+ if not rows:
2322
+ print(f"no SPS effects for field {args.field!r} (needs the FF9 install + UnityPy, and a field that "
2323
+ "carries .sps effects). `ff9mapkit list-fields` lists field tokens.", file=sys.stderr)
2324
+ return 0
2325
+ if args.id is None and not args.png and not args.gif:
2326
+ print(f"{len(rows)} SPS effect(s) in {rows[0].folder}. Each <id> is a RunSPSCode effect:\n")
2327
+ for e in rows:
2328
+ print(f" {e.sps_id:>5} {e.sps_id}.sps")
2329
+ print(f"\nDecode one: ff9mapkit sps {args.field} --id {rows[0].sps_id}"
2330
+ f"\nPreview: ff9mapkit sps {args.field} --id {rows[0].sps_id} --png out.png")
2331
+ return 0
2332
+ target = args.id if args.id is not None else rows[0].sps_id
2333
+ entry = next((e for e in rows if e.sps_id == target), None)
2334
+ if entry is None:
2335
+ print(f"field {args.field} has no SPS effect {target} (have: {[e.sps_id for e in rows]})", file=sys.stderr)
2336
+ return 2
2337
+ sps = SC.load_sps(entry)
2338
+ print(f"SPS effect {target} in {entry.folder}:")
2339
+ for label, value in SC.effect_facts(sps):
2340
+ print(f" {label:<16} {value}")
2341
+ if args.png or args.gif:
2342
+ tcb = SC.load_tcb(args.field)
2343
+ if tcb is None:
2344
+ print(" (no spt.tcb for this field -- cannot render a textured preview)", file=sys.stderr)
2345
+ return 2
2346
+ from .sps import render
2347
+ if args.png:
2348
+ render.save_png(render.render_strip(sps, tcb, scale=args.scale), args.png)
2349
+ print(f" wrote {args.png} (a {sps.frame_count}-frame contact sheet)")
2350
+ if args.gif:
2351
+ render.save_gif(sps, tcb, args.gif, scale=args.scale)
2352
+ print(f" wrote {args.gif} (~15 fps loop)")
2353
+ return 0
2354
+
2355
+
2356
+ def _cmd_archetypes(args: argparse.Namespace) -> int:
2357
+ """List built-in NPC archetypes -- place a common NPC by one name."""
2358
+ from . import archetypes as A
2359
+ from . import catalog as C
2360
+ print('Built-in NPC archetypes -- use as [[npc]] archetype = "<name>" (animations auto-resolve):\n')
2361
+ for name in A.names():
2362
+ model = A.resolve(name)[0]
2363
+ if model is None:
2364
+ print(f" {name:<12} (keeps the cloned player)")
2365
+ else:
2366
+ m = C.model(model)
2367
+ print(f" {name:<12} {m.name if m else model}")
2368
+ print('\nAny other model: [[npc]] model = "GEO_..." (browse: ff9mapkit models)')
2369
+ print('Full reference (roles + where each appears in FF9): ff9mapkit/docs/ARCHETYPES.md')
2370
+ return 0
2371
+
2372
+
2373
+ def _cmd_catalog(args: argparse.Namespace) -> int:
2374
+ """Search every reference catalog by name -- the Info Hub 'grab anything'."""
2375
+ from . import catalog as C
2376
+ res = C.search(args.query)
2377
+ if not any(res.values()):
2378
+ print(f"nothing matches {args.query!r} in models / items / scenes / fields.")
2379
+ return 0
2380
+ lim = args.limit
2381
+ if res["models"]:
2382
+ print(f"models ({len(res['models'])}):")
2383
+ for m in res["models"][:lim]:
2384
+ print(f" {m.id:>4} {m.name:<22} {m.kind}")
2385
+ if len(res["models"]) > lim:
2386
+ print(f" ... +{len(res['models']) - lim} more (ff9mapkit models {args.query})")
2387
+ if res["items"]:
2388
+ print(f"items ({len(res['items'])}):")
2389
+ for i, n in res["items"][:lim]:
2390
+ print(f" {i:>4} {n}")
2391
+ if len(res["items"]) > lim:
2392
+ print(f" ... +{len(res['items']) - lim} more (ff9mapkit items -f {args.query})")
2393
+ if res["scenes"]:
2394
+ print(f"battle scenes ({len(res['scenes'])}):")
2395
+ for nm, sid in res["scenes"][:lim]:
2396
+ print(f" {sid:>4} {nm}")
2397
+ if len(res["scenes"]) > lim:
2398
+ print(f" ... +{len(res['scenes']) - lim} more (ff9mapkit scenes {args.query})")
2399
+ if res["fields"]:
2400
+ print(f"fields ({len(res['fields'])}):")
2401
+ for fbg, fid, evt in res["fields"][:lim]:
2402
+ print(f" {fid:>4} {evt:<26} ({fbg})")
2403
+ if len(res["fields"]) > lim:
2404
+ print(f" ... +{len(res['fields']) - lim} more (ff9mapkit list-fields {args.query})")
2405
+ return 0
2406
+
2407
+
2408
+ def _cmd_edit(args: argparse.Namespace) -> int:
2409
+ """Launch the form-based field-logic editor (Tkinter)."""
2410
+ try:
2411
+ from .editor import app
2412
+ except Exception as e: # noqa: BLE001 - e.g. tkinter missing on a headless box
2413
+ print(f"could not start the editor UI (is tkinter installed?): {e}", file=sys.stderr)
2414
+ return 2
2415
+ app.main(args.field)
2416
+ return 0
2417
+
2418
+
2419
+ def _not_yet(phase: str):
2420
+ def _run(args: argparse.Namespace) -> int:
2421
+ print(f"'{args._cmd}' is not implemented yet (coming in {phase}).", file=sys.stderr)
2422
+ return 3
2423
+ return _run
2424
+
2425
+
2426
+ def build_parser() -> argparse.ArgumentParser:
2427
+ p = argparse.ArgumentParser(prog="ff9mapkit", description="Author custom FF9 field maps.")
2428
+ p.add_argument("--version", action="version", version=f"ff9mapkit {__version__}")
2429
+ p.add_argument("--game", default=None, help="path to the FF9 install (overrides $FF9_GAME_PATH and config)")
2430
+ p.add_argument("--mod-folder", default="FF9CustomMap", help="mod folder name inside the install")
2431
+ sub = p.add_subparsers(dest="_cmd", required=True)
2432
+
2433
+ d = sub.add_parser("doctor", help="show resolved paths and sanity-check the install")
2434
+ d.set_defaults(func=_cmd_doctor)
2435
+
2436
+ ds = sub.add_parser("disasm", help="disassemble a .eb field script")
2437
+ ds.add_argument("file", help="path to a .eb / .eb.bytes file")
2438
+ ds.add_argument("-e", "--entry", type=int, default=None, help="only this entry index")
2439
+ ds.add_argument("-a", "--all", action="store_true", help="also list empty entry slots")
2440
+ ds.set_defaults(func=_cmd_disasm)
2441
+
2442
+ cm = sub.add_parser("camera", help="inspect / regenerate a .bgx camera")
2443
+ cm.add_argument("bgx", help="path to a .bgx scene")
2444
+ cm.add_argument("--regen", metavar="OUT.bgx", help="rewrite with a re-synthesized camera (round-trip check)")
2445
+ cm.set_defaults(func=_cmd_camera)
2446
+
2447
+ wm = sub.add_parser("walkmesh", help="convert/repair/verify a walkmesh")
2448
+ wm.add_argument("action", choices=["obj", "fix", "verify"],
2449
+ help="obj: .obj->.bgi ; fix: rebuild neighbor links ; verify: run the checks")
2450
+ wm.add_argument("input", help="input .obj (obj), .bgi (fix), or .bgi/.field.toml (verify)")
2451
+ wm.add_argument("output", nargs="?", help="output path (.bgi); for fix defaults to input")
2452
+ wm.set_defaults(func=_cmd_walkmesh)
2453
+
2454
+ gd = sub.add_parser("guide", help="emit a paint guide/template for a flat floor")
2455
+ gd.add_argument("--from-bgx", help="use an existing camera .bgx (e.g. the Blender export) "
2456
+ "instead of --pitch/--distance/--fov")
2457
+ gd.add_argument("--pitch", type=float, default=48.0, help="downward pitch in degrees (if not --from-bgx)")
2458
+ gd.add_argument("--distance", type=float, default=4500, help="camera distance from origin")
2459
+ gd.add_argument("--fov", type=float, default=42.2, help="horizontal FOV in degrees")
2460
+ gd.add_argument("--back", type=float, default=205, help="canvas Y of the floor back edge")
2461
+ gd.add_argument("--front", type=float, default=432, help="canvas Y of the floor front edge")
2462
+ gd.add_argument("--png", help="write a PNG here (checkerboard guide, or template with --template)")
2463
+ gd.add_argument("--template", action="store_true",
2464
+ help="write a TRANSPARENT trace-over paint template (paint your room under it)")
2465
+ gd.add_argument("--template-layers", action="store_true",
2466
+ help="with --template: write SEPARATE per-layer PNGs (grid / outline / height) + a "
2467
+ "<name>.manifest.json instead of one combined PNG, so you can toggle each "
2468
+ "guide in your paint app")
2469
+ gd.set_defaults(func=_cmd_guide)
2470
+
2471
+ bd = sub.add_parser("build", help="compile field.toml project(s) into a Memoria mod")
2472
+ bd.add_argument("field", nargs="+", help="one or more field.toml files")
2473
+ bd.add_argument("--out", default="dist", help="output mod folder (default: ./dist)")
2474
+ bd.add_argument("--mod-name", default="FF9CustomMap", help="mod name / InstallationPath")
2475
+ bd.add_argument("--author", default="", help="mod author")
2476
+ bd.add_argument("--description", default="", help="mod description")
2477
+ bd.set_defaults(func=_cmd_build)
2478
+
2479
+ ln = sub.add_parser("lint", help="check a field.toml without building -- one pass over every offline "
2480
+ "validator (schema, story/flag logic, reserved flag bands, walkmesh geometry + "
2481
+ "content placement, layer art, camera pitch)")
2482
+ ln.add_argument("field", help="path to a .field.toml")
2483
+ ln.set_defaults(func=_cmd_lint)
2484
+
2485
+ pt = sub.add_parser("paint-template", help="project a field.toml's floor + content onto per-layer "
2486
+ "trace-over PNGs + a legend (camera-aware; covers every content type)")
2487
+ pt.add_argument("field", help="path to a .field.toml")
2488
+ pt.add_argument("--out", default=None, help="output dir (default: the field.toml's dir)")
2489
+ pt.add_argument("--basename", default="paint_template", help="output filename stem (default paint_template)")
2490
+ pt.set_defaults(func=_cmd_paint_template)
2491
+
2492
+ nw = sub.add_parser("new", help="scaffold a new field project directory")
2493
+ nw.add_argument("name", help="field name (e.g. MY_ROOM)")
2494
+ nw.add_argument("--dest", default=".", help="where to create the project dir")
2495
+ nw.add_argument("--id", type=int, default=None, help="custom field id (default: suggested)")
2496
+ nw.add_argument("--area", type=int, default=11, help="area id (>= 10)")
2497
+ nw.add_argument("--pitch", type=float, default=48.0, help="camera pitch for the template")
2498
+ nw.set_defaults(func=_cmd_new)
2499
+
2500
+ pk = sub.add_parser("pack", help="zip a built mod for distribution")
2501
+ pk.add_argument("mod_root", help="path to a built mod folder")
2502
+ pk.add_argument("--out", default=None, help="output .zip (default: <modname>.zip)")
2503
+ pk.set_defaults(func=_cmd_pack)
2504
+
2505
+ gh = sub.add_parser("gen-hub", help="generate a World-Hub field.toml from a journeys.toml registry "
2506
+ "(a journey selector: pick a journey -> warp into it) (P6)")
2507
+ gh.add_argument("journeys", help="path to a journeys.toml ([hub] + [[journey]] rows)")
2508
+ gh.add_argument("--out", default=None,
2509
+ help="output field.toml (default: hub.field.toml beside the journeys.toml)")
2510
+ gh.add_argument("--extract-camera", dest="extract_camera", action="store_true",
2511
+ help="pull the borrowed room's camera ([hub] borrow_field) into the workspace cache and "
2512
+ "wire the emitted toml to it (needs the install + UnityPy)")
2513
+ gh.add_argument("--force", action="store_true", help="re-extract the camera even if already cached")
2514
+ gh.set_defaults(func=_cmd_gen_hub)
2515
+
2516
+ lj = sub.add_parser("lint-journey", help="validate a multi-campaign journeys.toml offline (id/flag "
2517
+ "disjointness, links resolve, entries valid) -- the assembler's namespace guarantee")
2518
+ lj.add_argument("journeys", help="path to a journeys.toml ([hub] + [[journey]] rows, bare or multi-campaign)")
2519
+ lj.add_argument("--graph", action="store_true",
2520
+ help="also print the resolved namespace (entry ids, campaign id bands, flag windows, links)")
2521
+ lj.set_defaults(func=_cmd_lint_journey)
2522
+
2523
+ aj = sub.add_parser("assemble-journey", help="assemble a multi-campaign journeys.toml: lint + emit the "
2524
+ "World-Hub field.toml (resolves BOTH bare and multi-campaign journeys)")
2525
+ aj.add_argument("journeys", help="path to a journeys.toml ([hub] + [[journey]] rows)")
2526
+ aj.add_argument("--out", default=None,
2527
+ help="output hub field.toml (default: hub.field.toml beside the journeys.toml)")
2528
+ aj.add_argument("--graph", action="store_true", help="print the resolved namespace before emitting")
2529
+ aj.add_argument("--dry-run", dest="dry_run", action="store_true",
2530
+ help="lint + print the resolved plan, but DON'T write the hub field.toml")
2531
+ aj.add_argument("--extract-camera", dest="extract_camera", action="store_true",
2532
+ help="pull the hub's [hub] borrow_field camera into the workspace cache + wire the emitted "
2533
+ "[camera] borrow to it (needs the install + UnityPy)")
2534
+ aj.add_argument("--force", action="store_true", help="re-extract the camera even if already cached")
2535
+ aj.set_defaults(func=_cmd_assemble_journey)
2536
+
2537
+ ra = sub.add_parser("reference-arcs", help="FF9 reference-arc scaffold: list the curated arc->seed table, "
2538
+ "print the fork playbook, or emit a chained journeys.toml (the north-star harness)")
2539
+ ra.add_argument("--table", default=None,
2540
+ help="a custom reference-arc table (default: the packaged FF9 disc-1 spine)")
2541
+ ra.add_argument("--emit", default=None, metavar="DIR",
2542
+ help="WRITE a journeys.toml scaffold (the arcs as a chained journey + the fork playbook) into DIR")
2543
+ ra.add_argument("--playbook", action="store_true", help="print ONLY the import-chain fork commands")
2544
+ ra.add_argument("--reconcile", default=None, metavar="JOURNEYS_TOML",
2545
+ help="STEP 2: fill an emitted journeys.toml's ENTRY placeholder from the campaigns forked "
2546
+ "beside it + clear the obsolete link templates (cross-campaign warps auto-wire at "
2547
+ "deploy). Run after forking; writes in place. Ignores --table.")
2548
+ ra.add_argument("--regen", action="store_true",
2549
+ help="REGENERATE the region PICKER's catalog (every forkable zone -> its entry seed) from the "
2550
+ "game's real field->zone data, into the shipped data/region_catalog.toml")
2551
+ ra.add_argument("--out", default=None, help="with --regen: write the catalog here (default: the shipped data file)")
2552
+ ra.add_argument("--pattern", default=None,
2553
+ help="with --regen: only zones whose token/area matches (e.g. 'dali', 'alex')")
2554
+ ra.add_argument("--no-split-visits", action="store_true", dest="no_split_visits",
2555
+ help="with --regen: one region per WHOLE zone (the old behavior) instead of one per "
2556
+ "story-state visit -- a region then forks every revisit screen (--whole-zone)")
2557
+ ra.add_argument("--gap", type=int, default=None,
2558
+ help="with --regen: the field-id gap that separates story-state visits (default 120; "
2559
+ "smaller = split more finely, larger = merge nearby visits)")
2560
+ ra.add_argument("--force", action="store_true", help="with --emit, overwrite an existing journeys.toml")
2561
+ ra.add_argument("--hub-name", default="FF9 Disc 1", dest="hub_name", help="hub field display name (--emit)")
2562
+ ra.add_argument("--hub-id", type=int, default=4600, dest="hub_id", help="hub field id, >=4000 (--emit)")
2563
+ ra.add_argument("--borrow-bg", default=None, dest="borrow_bg",
2564
+ help="hub art borrow field (--emit; default: Mognet Central, FF9's journey nexus)")
2565
+ ra.add_argument("--id-base", type=int, default=6000, dest="id_base",
2566
+ help="first arc's campaign id base; arc i gets id_base + i*100 (default 6000)")
2567
+ ra.set_defaults(func=_cmd_reference_arcs)
2568
+
2569
+ ef = sub.add_parser("extract-field", help="cache a real field's camera+walkmesh in the gitignored "
2570
+ "workspace cache (reused by BG-borrow tomls / gen-hub --extract-camera)")
2571
+ ef.add_argument("ids", nargs="+", help="real field id(s) to cache (e.g. 950)")
2572
+ ef.add_argument("--force", action="store_true", help="re-extract even if already cached")
2573
+ ef.set_defaults(func=_cmd_extract_field)
2574
+
2575
+ ea = sub.add_parser("export-art", help="assemble a field's per-overlay background PNGs OFFLINE -- our own "
2576
+ "[Export] Field=1 (no in-game hang); needs UnityPy")
2577
+ ea.add_argument("target", nargs="?", default=None,
2578
+ help="a field (FBG / mapid / unique substring), OR a campaign.toml (export every member's "
2579
+ "donor field). Omit with --all.")
2580
+ ea.add_argument("--all", action="store_true",
2581
+ help="export EVERY real field (the full drop-in for the in-game startup dump)")
2582
+ ea.add_argument("--pattern", default=None,
2583
+ help="with --all: only fields whose FBG folder contains this substring (e.g. a zone: dali, iccv)")
2584
+ ea.add_argument("--composite", action="store_true",
2585
+ help="write ONE composited background PNG per field (clean opaque art, no walkmesh "
2586
+ "footprint) into a FLAT folder -- a browsable whole-game gallery to scroll through "
2587
+ "while planning journeys, instead of the raw per-overlay layers")
2588
+ ea.add_argument("--out", default=None,
2589
+ help="output root (default: <install>/StreamingAssets/FieldMaps, the engine's own "
2590
+ "location -- a true drop-in). Raw: each field -> <out>/<FBG>/Overlay{i}.png; "
2591
+ "--composite: each field -> <out>/<FBG>.png. For a gallery use --out reference/all-fields-export.")
2592
+ ea.add_argument("--no-atlas", action="store_true", help="(raw mode) don't also dump the source atlas.png")
2593
+ ea.set_defaults(func=_cmd_export_art)
2594
+
2595
+ rp = sub.add_parser("repaint-native", help="repaint a native fork's background: unpack its tile-packed "
2596
+ "atlas into spatial layers, then --pack the edited layers back (seamless HD, no game)")
2597
+ rp.add_argument("project", help="a native fork project dir (has scene.bgs.bytes + atlas.png + a *.field.toml)")
2598
+ rp.add_argument("--pack", action="store_true",
2599
+ help="blit the (edited) repaint/Overlay*.png layers BACK into atlas.png (else: unpack them)")
2600
+ rp.add_argument("--out", default=None,
2601
+ help="(unpack) where to write the spatial layers + manifest (default: <project>/repaint/)")
2602
+ rp.add_argument("--from", dest="from_dir", default=None,
2603
+ help="(--pack) the repaint dir holding the edited layers (default: <project>/repaint/)")
2604
+ rp.add_argument("--no-backup", action="store_true", help="(--pack) don't back up the current atlas.png first")
2605
+ rp.set_defaults(func=_cmd_repaint_native)
2606
+
2607
+ iaa = sub.add_parser("import-all", help="bulk-import a foldered, Blender-ready archive of fields -- whole "
2608
+ "game / a zone / a campaign (lightweight by default; needs UnityPy)")
2609
+ iaa.add_argument("target", nargs="?", default=None,
2610
+ help="a campaign.toml (fold its members under <out>/<CAMPAIGN>/<MEMBER>/). Omit and use "
2611
+ "--all / --pattern for the whole game.")
2612
+ iaa.add_argument("--all", action="store_true", help="import every real field")
2613
+ iaa.add_argument("--pattern", default=None,
2614
+ help="only fields whose FBG folder contains this substring (a zone, e.g. iccv / dali / trno)")
2615
+ iaa.add_argument("--out", required=True,
2616
+ help="archive root. Whole game -> <out>/<ZONE>/<FBG>/; campaign -> <out>/<CAMPAIGN>/<MEMBER>/. "
2617
+ "Use a GITIGNORED path -- this is SE-derived art (e.g. reference/all-fields-import).")
2618
+ iaa.add_argument("--editable", action="store_true",
2619
+ help="full editable custom scene per field (repaintable per-depth layers, reshapeable) "
2620
+ "instead of the lightweight model-against project -- bigger + slower, for art-modding "
2621
+ "a whole set at once. Default = lightweight; promote single fields with `import --editable`.")
2622
+ iaa.set_defaults(func=_cmd_import_all)
2623
+
2624
+ im = sub.add_parser("import", help="fork a REAL FF9 field into an editable field.toml (needs UnityPy)")
2625
+ im.add_argument("field", help="field name: full FBG, bare mapid, or a unique substring (e.g. grgr_map420)")
2626
+ im.add_argument("--out", default=".", help="project dir to write into (default: .)")
2627
+ im.add_argument("--name", default=None, help="custom field/script id (default: <MAPID-first-token>_FORK/_EDIT)")
2628
+ im.add_argument("--id", type=int, default=4003, help="custom field id (default: 4003)")
2629
+ im.add_argument("--editable", action="store_true",
2630
+ help="fork as a full editable CUSTOM SCENE (re-exported walkmesh + the real art split "
2631
+ "into one repaintable layer per depth, occlusion preserved) instead of BG-borrow; "
2632
+ "art is assembled OFFLINE from the atlas now -- no in-game [Export] step needed")
2633
+ im.add_argument("--native", action="store_true",
2634
+ help="fork as a NATIVE custom scene: ship the real atlas.png + .bgs (per-tile depth) + "
2635
+ "custom walkmesh, NO .bgx -- renders via the engine's seamless native path (no tile "
2636
+ "seams, faithful occlusion), exactly how Moguri ships. Also forks area<10 fields that "
2637
+ "BG-borrow can't. Needs no in-game export.")
2638
+ im.add_argument("--verbatim", action="store_true",
2639
+ help="MOST FAITHFUL: fork over a native scene AND ship the field's REAL event script WHOLE "
2640
+ "(entry-0 + every object + every gateway, layout intact) instead of re-synthesizing -- "
2641
+ "the field runs its own logic (story gating, rotating cast, real doors). Implies "
2642
+ "--native; pair with a [startup] block to boot a chosen beat. (docs/FORK_FIDELITY.md)")
2643
+ im.add_argument("--swap-player", metavar="WHO", default=None,
2644
+ help="SWAP who you WALK as to a playable (zidane/vivi/steiner/garnet/freya/quina/eiko/"
2645
+ "amarant; aliases dagger, salamander) OR ANY model -- a GEO name or numeric id (a "
2646
+ "moogle 199, GEO_NPC_F0_BMG, ...; `ff9mapkit models`). Patches the player entry's "
2647
+ "SetModel + movement anims to that rig. Implies --verbatim (needs the donor player "
2648
+ "entry); party/menu state is unchanged. CLEAN on free-roam fields; on a cutscene-heavy "
2649
+ "field the player's scripted GESTURES glitch (warned) -- only movement clips are swapped. "
2650
+ "(memory project-ff9-pc-party-system)")
2651
+ im.add_argument("--neutralize-gestures", action="store_true",
2652
+ help="with --swap-player: rewrite the player's scripted cutscene GESTURES to the new rig's "
2653
+ "idle so it STANDS cleanly instead of glitching (the character won't emote -- for story "
2654
+ "fidelity use a verbatim fork at the right beat instead). Requires --swap-player.")
2655
+ im.add_argument("--atlas", action="store_true", help="also extract the raw atlas.png (BG-borrow mode only)")
2656
+ im.add_argument("--dialogue", action="store_true",
2657
+ help="also append the real field's NPC dialogue as editable [[npc]] stubs (commented) "
2658
+ "for re-authoring -- the words become kit-authored content, not a faithful graft")
2659
+ im.add_argument("--graft-player-funcs", action="store_true",
2660
+ help="also carry the donor PLAYER functions a carried object interacts with, onto the fork "
2661
+ "player, so the interactions FIRE (a chest/cask turns to face you on examine, boxes "
2662
+ "gesture) -- the objects carry their interactive funcs WHOLE instead of init_only. "
2663
+ "Clean gesture funcs only; text/exotic/non-Zidane interactions stay dropped. (docs/PLAYER_GRAFT.md)")
2664
+ im.add_argument("--carry-text", action="store_true",
2665
+ help="FAITHFULLY carry the donor field's referenced dialogue text (per language, VERBATIM) "
2666
+ "and remap the grafted windows to it, so a carried NPC's talk + grafted text "
2667
+ "interactions show the REAL words (vs --dialogue's editable stubs you re-author). "
2668
+ "Implies --graft-player-funcs; the words are SE-derived (gitignored sidecar). (docs/TEXT_CARRY.md)")
2669
+ im.add_argument("--save-moogle", action="store_true",
2670
+ help="carry the donor field's SAVE POINT (the hidden save Moogle + its book/feather/tent + "
2671
+ "pose surgery) VERBATIM as a faithful FF9 save point -- the Moogle pops out of its barrel "
2672
+ "+ the full save flourish, exactly as the original. Implies --graft-player-funcs; emits a "
2673
+ "[[save_moogle]] block. Only fires on a field that actually has one. (docs/SAVEPOINT.md)")
2674
+ im.set_defaults(func=_cmd_import)
2675
+
2676
+ ic = sub.add_parser("import-chain",
2677
+ help="walk a connected region of REAL fields from a seed (read-only door graph; P1)")
2678
+ ic.add_argument("seed", help="seed field id (e.g. 300) OR an FBG substring (e.g. iccv = seed every Ice "
2679
+ "Cavern screen). COMMA-SEPARATED for several (e.g. 50,100 or tshp,alxt) -> "
2680
+ "with --whole-zone forks multiple zones as ONE campaign (cross-zone warps "
2681
+ "auto-retarget in-fork); the first token stays the entry.")
2682
+ ic.add_argument("--zones", default=None,
2683
+ help="comma-separated zone tokens to span (e.g. iccv,vgdl); default = stay in the seed's zone")
2684
+ ic.add_argument("--max-hops", type=int, default=20, dest="max_hops",
2685
+ help="BFS depth cap (default 20; within --zones, --max-fields is the real bound)")
2686
+ ic.add_argument("--max-fields", type=int, default=25, dest="max_fields",
2687
+ help="hard field cap; aborts LOUDLY if exceeded (default 25)")
2688
+ ic.add_argument("--stop-at", default=None, dest="stop_at", help="comma-separated field ids to not cross")
2689
+ ic.add_argument("--follow-scripted", action="store_true", dest="follow_scripted",
2690
+ help="also follow scripted/teleport warps (default: list them as seams, don't recurse)")
2691
+ ic.add_argument("--cross-zones", action="store_true", dest="cross_zones",
2692
+ help="don't stop at zone boundaries (follow into any zone, bounded by --max-hops/--max-fields)")
2693
+ ic.add_argument("--whole-zone", action="store_true", dest="whole_zone",
2694
+ help="fork EVERY forkable field in the seed's zone(s), not just those door-reachable from the "
2695
+ "seed -- captures cutscene-only / non-door-connected screens the walk misses (the seed "
2696
+ "stays the entry). Raises --max-fields to fit the zone. Same as seeding an FBG substring.")
2697
+ ic.add_argument("--ids", default=None, dest="ids",
2698
+ help="fork EXACTLY this set of field ids (a compact range string, e.g. 100-117 or "
2699
+ "100-117,150-167) instead of a whole zone -- scopes the fork to ONE story-state cluster "
2700
+ "(e.g. Alexandria's disc-1 opening, not all 48 revisit screens). The seed stays the "
2701
+ "entry; raises --max-fields to fit. Mutually exclusive with --whole-zone.")
2702
+ ic.add_argument("--dry-run", action="store_true", dest="dry_run",
2703
+ help="just print the discovered graph (the default when --out is omitted)")
2704
+ # P2 write mode: --out flips import-chain from the read-only dry-run to forking the chain.
2705
+ ic.add_argument("--out", default=None,
2706
+ help="WRITE the chain: emit campaign.toml + per-member field.tomls into this dir (P2)")
2707
+ ic.add_argument("--id-base", type=int, default=None, dest="id_base",
2708
+ help="member i gets id_base+i (default: .ff9deploy.toml campaign_id_base, else 6000; >=4000)")
2709
+ ic.add_argument("--fresh-ids", action="store_true", dest="fresh_ids",
2710
+ help="ignore any existing <out>/campaign.toml and re-allocate every id from id_base (the old "
2711
+ "index-based behavior). A re-fork then SHIFTS ids -> any in-fork SAVE goes stale. Default: "
2712
+ "STABLE ids -- reuse the prior donor->id+name map, append net-new donors above the max, so "
2713
+ "saves survive re-forking into the SAME --out.")
2714
+ ic.add_argument("--flag-base", type=int, default=FIRST_SAFE_FLAG, dest="flag_base",
2715
+ help=f"campaign flag band start recorded in campaign.toml (default {FIRST_SAFE_FLAG}, "
2716
+ f"the safe floor clear of real-FF9 chest flags)")
2717
+ ic.add_argument("--flags-per-field", type=int, default=64, dest="flags_per_field",
2718
+ help="reserved GLOB block width per field (recorded for P5; default 64)")
2719
+ ic.add_argument("--campaign-name", default=None, dest="campaign_name",
2720
+ help="campaign/mod name (default <SEED-ZONE>_CAMPAIGN)")
2721
+ ic.add_argument("--name-prefix", default=None, dest="name_prefix",
2722
+ help="prefix every member's deployed FBG/EVT name (e.g. DC -> DC_DL_ENT) so two "
2723
+ "campaigns/worktrees forking the SAME source field don't collide on the by-name, "
2724
+ "highest-folder-wins scene/.eb resolution. Use a short unique tag per campaign.")
2725
+ ic.add_argument("--mod-folder", default=None, dest="mod_folder",
2726
+ help="target mod folder in campaign.toml (default: .ff9deploy.toml, else FF9CustomMap-ow)")
2727
+ ic.add_argument("--live-seams", action="store_true", dest="live_seams",
2728
+ help="emit out-of-chain gateways as LIVE doors into the real game (default: comment as seams)")
2729
+ ic.add_argument("--verbatim", action="store_true",
2730
+ help="MOST FAITHFUL: fork every member NATIVE + VERBATIM (ship each donor's whole .eb + "
2731
+ ".mes, run the real logic; in-chain doors retargeted to sibling forks)")
2732
+ ic.add_argument("--swap-player", metavar="WHO", default=None,
2733
+ help="play as one character/model across the WHOLE chain: a playable (zidane/vivi/steiner/"
2734
+ "garnet/freya/quina/eiko/amarant; aliases dagger, salamander) OR any model (a GEO name "
2735
+ "or id, e.g. a moogle 199). Swaps every member's player rig (SetModel + movement anims). "
2736
+ "Implies --verbatim; party/menu unchanged; cutscene-gesture members warned. "
2737
+ "(see import --swap-player)")
2738
+ ic.add_argument("--neutralize-gestures", action="store_true",
2739
+ help="with --swap-player: stand cleanly through cutscene gestures across the chain "
2740
+ "(see import --neutralize-gestures). Requires --swap-player.")
2741
+ ic.set_defaults(func=_cmd_import_chain)
2742
+
2743
+ ba = sub.add_parser("build-all", help="compile a campaign.toml (all member fields) into one Memoria mod (P3)")
2744
+ ba.add_argument("campaign", help="path to the campaign.toml manifest (from import-chain --out)")
2745
+ ba.add_argument("--out", default=None, help="output mod folder (default: <campaign-dir>/dist)")
2746
+ ba.add_argument("--author", default=None, help="ModDescription author (optional)")
2747
+ ba.add_argument("--description", default=None, help="ModDescription description (optional)")
2748
+ ba.add_argument("--allow-artless", action="store_true", dest="allow_artless",
2749
+ help="build editable members that lack exported art (they render with NO background)")
2750
+ ba.set_defaults(func=_cmd_build_all)
2751
+
2752
+ lc = sub.add_parser("lint-campaign",
2753
+ help="validate a campaign.toml (edges/entry/seams/ids/flags) without building (P5)")
2754
+ lc.add_argument("campaign", help="path to the campaign.toml manifest")
2755
+ lc.add_argument("--graph", action="store_true",
2756
+ help="also print the resolved member graph (doors/seams/dead-ends/unreachable)")
2757
+ lc.set_defaults(func=_cmd_lint_campaign)
2758
+
2759
+ nc = sub.add_parser("new-campaign", help="create an EMPTY campaign manifest to author by hand (P6)")
2760
+ nc.add_argument("dir", help="directory to create campaign.toml in")
2761
+ nc.add_argument("--name", required=True, help="campaign / mod display name")
2762
+ nc.add_argument("--mod-folder", default=None, dest="mod_folder",
2763
+ help="Memoria mod folder (default: .ff9deploy.toml / FF9CustomMap)")
2764
+ nc.add_argument("--id-base", type=int, default=None, dest="id_base",
2765
+ help="first member field id (default: deploy cfg / 4000)")
2766
+ nc.add_argument("--flag-base", type=int, default=FIRST_SAFE_FLAG, dest="flag_base")
2767
+ nc.add_argument("--flags-per-field", type=int, default=64, dest="flags_per_field")
2768
+ nc.set_defaults(func=_cmd_new_campaign)
2769
+
2770
+ af = sub.add_parser("add-field", help="add a member to a campaign: a blank room, or fork a real field (P6)")
2771
+ af.add_argument("campaign", help="path to the campaign.toml manifest")
2772
+ af.add_argument("--name", required=True, help="member name (unique; e.g. HUB)")
2773
+ af.add_argument("--source", default=None,
2774
+ help="a real field id or unique FBG name to FORK (needs the game); omit for a blank room")
2775
+ af.set_defaults(func=_cmd_add_field)
2776
+
2777
+ lf = sub.add_parser("list-fields", help="list real FF9 fields available to import (needs UnityPy)")
2778
+ lf.add_argument("pattern", nargs="?", default=None, help="substring filter (e.g. alex, treno, grgr)")
2779
+ lf.add_argument("--players", action="store_true",
2780
+ help="also show WHO you control in each field (reads each .eb; a full sweep is ~30s)")
2781
+ lf.add_argument("--non-zidane", action="store_true",
2782
+ help="only fields you play as someone other than Zidane (the verbatim-fork donors; implies --players)")
2783
+ lf.set_defaults(func=_cmd_list_fields)
2784
+
2785
+ ff = sub.add_parser("find-field",
2786
+ help="resolve a field id / name / FBG substring -> id + friendly name + archive folder")
2787
+ ff.add_argument("query", help="a field id (exact match), or an FBG/EVT/friendly-name substring "
2788
+ "(e.g. 2934, cysw, \"Cargo Room\")")
2789
+ ff.add_argument("--archive", default=None,
2790
+ help="an import-all archive dir to show each match's folder "
2791
+ "(default: reference/all-fields-import if present). Pure lookup needs no install.")
2792
+ ff.set_defaults(func=_cmd_find_field)
2793
+
2794
+ bi = sub.add_parser("battle-import",
2795
+ help="fork a REAL FF9 battle background (BBG) into an editable battle.toml (needs UnityPy)")
2796
+ bi.add_argument("bbg", help="battle-bg name to fork GEOMETRY from, e.g. BBG_B013 (see `battle-list`)")
2797
+ bi.add_argument("--out", default=".", help="dir to write into (default: .)")
2798
+ bi.add_argument("--name", default=None, help="scene name for a minted scene (default: <BBG>_FORK)")
2799
+ bi.add_argument("--id", type=int, default=5000, help="scene id for a minted scene (default 5000)")
2800
+ bi.add_argument("--fork-scene", default=None, metavar="DONOR",
2801
+ help="ALSO fork a battle scene's gameplay/camera/text (a tier-c MINT), e.g. EF_R007 "
2802
+ "(see `battle-list --scenes`). Yields a brand-new, independently-triggerable battle.")
2803
+ bi.add_argument("--ship-as", default=None, metavar="BBG_B###",
2804
+ help="ship the geometry under a NEW bbg number (e.g. BBG_B200) = a wholly original map "
2805
+ "(the kit authors a static INB for it), instead of overriding the forked slot.")
2806
+ bi.set_defaults(func=_cmd_battle_import)
2807
+
2808
+ bb = sub.add_parser("battle-build", help="compile a battle.toml into a Memoria mod (custom battle map)")
2809
+ bb.add_argument("battle", nargs="+", help="one or more battle.toml files")
2810
+ bb.add_argument("--out", default="dist", help="output mod folder (default: ./dist)")
2811
+ bb.add_argument("--mod-name", default="FF9CustomMap", help="mod name / InstallationPath")
2812
+ bb.add_argument("--author", default="", help="mod author")
2813
+ bb.add_argument("--description", default="", help="mod description")
2814
+ bb.add_argument("--game", default=None,
2815
+ help="FF9 install dir (only needed for an enemy re-skin `[[scene.enemy]] model =`, which "
2816
+ "reads a donor model from the install; default: $FF9_GAME_PATH / common Steam paths)")
2817
+ bb.set_defaults(func=_cmd_battle_build)
2818
+
2819
+ bl = sub.add_parser("battle-list",
2820
+ help="list real FF9 battle backgrounds available to fork (needs UnityPy)")
2821
+ bl.add_argument("pattern", nargs="?", default=None, help="substring filter (e.g. b013)")
2822
+ bl.add_argument("--scenes", action="store_true",
2823
+ help="list battle SCENE names (mint donors, e.g. EF_R007) instead of map names")
2824
+ bl.set_defaults(func=_cmd_battle_list)
2825
+
2826
+ bac = sub.add_parser("battle-actions",
2827
+ help="list the shared PLAYER abilities (Actions.csv) + the scriptId formula catalog")
2828
+ bac.add_argument("-f", "--filter", help="only show actions whose name contains this")
2829
+ bac.add_argument("--script-ids", action="store_true",
2830
+ help="dump the scriptId->formula catalog (the data-vs-DLL boundary)")
2831
+ bac.set_defaults(func=_cmd_battle_actions)
2832
+
2833
+ bsc = sub.add_parser("battle-scene",
2834
+ help="inspect a real battle scene's enemy data (stats/affinities/rewards/attacks)")
2835
+ bsc.add_argument("donor", help="battle scene name to inspect, e.g. EF_R007 (see `battle-list --scenes`)")
2836
+ bsc.set_defaults(func=_cmd_battle_scene)
2837
+
2838
+ bai = sub.add_parser("battle-ai",
2839
+ help="disassemble a battle scene's enemy AI (EVT_BATTLE_<scene>.eb) -- read-only")
2840
+ bai.add_argument("donor", nargs="?", help="battle scene name, e.g. EF_R007 (see `battle-list --scenes`)")
2841
+ bai.add_argument("--sites", action="store_true",
2842
+ help="list patchable AI constants (offset/value) for [[scene.ai_patch]] instead of the disasm")
2843
+ bai.add_argument("--asm", metavar="EXPR",
2844
+ help="assemble an AI expression (e.g. \"{B_CURHP const(50) B_LT B_EXPR_END}\") -> its bytes; "
2845
+ "the inverse of the disassembled expression form -- no scene needed")
2846
+ bai.add_argument("--asm-block", metavar="SRC", dest="asm_block",
2847
+ help="assemble an AI COMMAND block -> its bytes + a re-disasm proof; ';' separates lines "
2848
+ "(e.g. \"JMP_IF(end); SET({B_CURHP const(1) B_LT B_EXPR_END}); end:; RET()\") -- no scene")
2849
+ bai.add_argument("--lint", action="store_true",
2850
+ help="lint the scene's enemy AI offline (decode / jump bounds / reachable RET / Attack-index "
2851
+ "range); exit 1 if any issue is found")
2852
+ bai.set_defaults(func=_cmd_battle_ai)
2853
+
2854
+ bsq = sub.add_parser("battle-seq",
2855
+ help="disassemble / lint / assemble a battle scene's attack sequences (btlseq.raw17)")
2856
+ bsq.add_argument("donor", nargs="?", help="battle scene name, e.g. EF_R007 (see `battle-list --scenes`)")
2857
+ bsq.add_argument("--sites", action="store_true",
2858
+ help="list patchable sequence operands (offset/value) for [[scene.seq_patch]] instead of the "
2859
+ "disasm")
2860
+ bsq.add_argument("--asm", metavar="SRC",
2861
+ help="assemble a sequence source (e.g. \"WaitAnim; Anim(anim_code=0); Calc; End\") -> its "
2862
+ "bytes + a re-disasm proof; the inverse of the disassembly -- no scene needed")
2863
+ bsq.add_argument("--lint", action="store_true",
2864
+ help="lint the scene's sequences offline (Anim-code range etc.); exit 1 if any issue is found")
2865
+ bsq.set_defaults(func=_cmd_battle_seq)
2866
+
2867
+ ch = sub.add_parser("characters",
2868
+ help="list the playable characters' base stats (the [[character]] / [[leveling]] targets)")
2869
+ ch.set_defaults(func=_cmd_characters)
2870
+
2871
+ ag = sub.add_parser("ability-gems",
2872
+ help="list support abilities + gem costs (the [[ability_gem]] targets)")
2873
+ ag.add_argument("-f", "--filter", help="only show abilities whose name contains this")
2874
+ ag.set_defaults(func=_cmd_ability_gems)
2875
+
2876
+ afp = sub.add_parser("ability-features",
2877
+ help="preview the AbilityFeatures.txt a field.toml emits (SA/AA/CMD ability-effect DSL)")
2878
+ afp.add_argument("toml", nargs="?", default=None, help="field.toml to preview (omit for the tag/name reference)")
2879
+ afp.add_argument("--tags", action="store_true", help="list the SA names + the legal [code=...] tags per kind")
2880
+ afp.add_argument("--game", default=None, help="FF9 install dir (only needed to resolve an >AA ability by NAME)")
2881
+ afp.set_defaults(func=_cmd_ability_features)
2882
+
2883
+ bp = sub.add_parser("battle-patch",
2884
+ help="preview the BattlePatch.txt a field.toml emits (enemy/attack/scene tuning by name)")
2885
+ bp.add_argument("toml", nargs="?", default=None, help="field.toml to preview (omit when using --fields)")
2886
+ bp.add_argument("--fields", action="store_true",
2887
+ help="list the tunable [PatchableField] names by token instead of previewing a toml")
2888
+ bp.set_defaults(func=_cmd_battle_patch)
2889
+
2890
+ an = sub.add_parser("animations", help="list a character's cutscene gestures (pick by name)")
2891
+ an.add_argument("character", nargs="?", help="vivi / zidane / garnet / steiner / freya / quina / eiko / amarant")
2892
+ an.add_argument("-f", "--filter", help="only show gestures whose name contains this")
2893
+ an.add_argument("--ids", action="store_true", help="also print each gesture's numeric anim id")
2894
+ an.set_defaults(func=_cmd_animations)
2895
+
2896
+ it = sub.add_parser("items", help="list FF9 item names + ids (give_item by name); --abilities lists "
2897
+ "ability names for items-set-ap")
2898
+ it.add_argument("-f", "--filter", help="only show items/abilities whose name (or token) contains this")
2899
+ it.add_argument("--abilities", action="store_true",
2900
+ help="list each character's learnable abilities (name / AA:X-SA:X token / AP) instead of items")
2901
+ it.set_defaults(func=_cmd_items)
2902
+
2903
+ ar = sub.add_parser("archetypes", help="list built-in NPC archetypes (place a common NPC by name)")
2904
+ ar.set_defaults(func=_cmd_archetypes)
2905
+
2906
+ md = sub.add_parser("models", help="browse actor/field models; name one to see its animations")
2907
+ md.add_argument("pattern", nargs="?", default=None,
2908
+ help="name/token substring to filter, or an exact model name/id for detail")
2909
+ md.add_argument("-g", "--group", help="filter by group (MAIN/NPC/MON/ACC/SUB/WEP) or kind (npc/playable/...)")
2910
+ md.add_argument("--field", action="store_true", help="only field-form models (the ones you place as NPCs)")
2911
+ md.add_argument("--anims", action="store_true", help="also show each model's gesture count")
2912
+ md.set_defaults(func=_cmd_models)
2913
+
2914
+ sc = sub.add_parser("scenes", help="list FF9 battle-scene (encounter) ids by name")
2915
+ sc.add_argument("pattern", nargs="?", default=None, help="name substring (e.g. alex, evil, b3)")
2916
+ sc.set_defaults(func=_cmd_scenes)
2917
+
2918
+ sp = sub.add_parser("sps", help="list/decode/preview a field's SPS particle effects (fire/smoke/magic); needs UnityPy")
2919
+ sp.add_argument("field", nargs="?", default=None, help="a field id or FBG/mapid token (see `ff9mapkit list-fields`)")
2920
+ sp.add_argument("--templates", action="store_true", help="list the [[sps]] creator templates (fire/smoke/...)")
2921
+ sp.add_argument("--id", type=int, default=None, help="decode ONE effect by id (full facts)")
2922
+ sp.add_argument("--png", metavar="OUT", help="render the effect's frames to a contact-sheet PNG")
2923
+ sp.add_argument("--gif", metavar="OUT", help="render the effect to an animated GIF (~15 fps)")
2924
+ sp.add_argument("--scale", type=int, default=3, help="preview pixel scale (default 3)")
2925
+ sp.set_defaults(func=_cmd_sps)
2926
+
2927
+ ct = sub.add_parser("catalog", help="search every reference catalog (models/items/scenes/fields) by name")
2928
+ ct.add_argument("query", help="substring to search across all catalogs")
2929
+ ct.add_argument("--limit", type=int, default=15, help="max rows per kind (default 15)")
2930
+ ct.set_defaults(func=_cmd_catalog)
2931
+
2932
+ fl = sub.add_parser("flags", help="browse the FF9 story-flag registry (named vars / reserved regions / milestones)")
2933
+ fl.add_argument("filter", nargs="?", default=None, help="substring to filter by name or meaning")
2934
+ fl.set_defaults(func=_cmd_flags)
2935
+
2936
+ fi = sub.add_parser("flags-inspect",
2937
+ help="decode a save's story state (SavedData_ww.dat per slot, or a save JSON / Base64)")
2938
+ fi.add_argument("save", help="path to SavedData_ww.dat (per slot), a Memoria extra-save, a save JSON "
2939
+ "file / text, or a bare Base64 gEventGlobal blob")
2940
+ fi.add_argument("--all", action="store_true", help="also list the unmapped set bits")
2941
+ fi.set_defaults(func=_cmd_flags_inspect)
2942
+
2943
+ ii = sub.add_parser("items-inspect",
2944
+ help="decode a save's items / equipment / gil (read-only; from the Memoria extra file)")
2945
+ ii.add_argument("save", help="path to SavedData_ww.dat (per slot) or a Memoria extra-save file")
2946
+ ii.set_defaults(func=_cmd_items_inspect)
2947
+
2948
+ sg = sub.add_parser("items-set-gil",
2949
+ help="write a save's gil into the Memoria extra file (dry-run unless --apply)")
2950
+ sg.add_argument("save", help="a SavedData_ww_Memoria_*.dat extra file, OR a SavedData_ww.dat container "
2951
+ "(then pass --slot/--save-no or --autosave)")
2952
+ sg.add_argument("gil", type=int, help="the new gil value (0..9,999,999, the in-game cap)")
2953
+ sg.add_argument("--slot", type=int, default=None, help="0-indexed slot (container only; menu shows it +1)")
2954
+ sg.add_argument("--save-no", type=int, default=None, help="0-indexed save within the slot (container only)")
2955
+ sg.add_argument("--autosave", action="store_true", help="edit the autosave (container only)")
2956
+ sg.add_argument("--apply", action="store_true", help="actually write (default is a dry-run preview)")
2957
+ sg.add_argument("--no-backup", action="store_true", help="skip writing the <file>.bak backup on --apply")
2958
+ sg.set_defaults(func=_cmd_items_set_gil)
2959
+
2960
+ def _add_save_target(p): # the shared save-target flags
2961
+ p.add_argument("save", help="a SavedData_ww_Memoria_*.dat extra file, OR a SavedData_ww.dat container "
2962
+ "(then pass --slot/--save-no or --autosave)")
2963
+ p.add_argument("--slot", type=int, default=None, help="0-indexed slot (container only; menu shows it +1)")
2964
+ p.add_argument("--save-no", type=int, default=None, help="0-indexed save within the slot (container only)")
2965
+ p.add_argument("--autosave", action="store_true", help="edit the autosave (container only)")
2966
+ p.add_argument("--apply", action="store_true", help="actually write (default is a dry-run preview)")
2967
+ p.add_argument("--no-backup", action="store_true", help="skip the <file>.bak backup on --apply")
2968
+
2969
+ si = sub.add_parser("items-set-item",
2970
+ help="set an item's inventory count in the Memoria extra file (0 removes; dry-run "
2971
+ "unless --apply)")
2972
+ _add_save_target(si)
2973
+ si.add_argument("item", help="item name or 0-254 id (e.g. Potion, 'Phoenix Down', 236)")
2974
+ si.add_argument("count", type=int, help="the new stack count (0 removes the item; clamps to 99)")
2975
+ si.set_defaults(func=_cmd_items_set_item)
2976
+
2977
+ se = sub.add_parser("items-set-equip",
2978
+ help="set one equip slot of one character in the Memoria extra file (dry-run unless "
2979
+ "--apply)")
2980
+ _add_save_target(se)
2981
+ se.add_argument("character", help="CharacterId 0-11 or a name (Zidane..Beatrix, Dagger, Salamander)")
2982
+ se.add_argument("equip_slot", metavar="slot", help="weapon | head | wrist | armor | accessory (aliases "
2983
+ "body, acc)")
2984
+ se.add_argument("item", help="item name/id to equip, or 'empty'/255 to unequip")
2985
+ se.set_defaults(func=_cmd_items_set_equip)
2986
+
2987
+ sk = sub.add_parser("items-set-keyitem",
2988
+ help="give / remove a KEY (important) item by name in the Memoria extra file (dry-run "
2989
+ "unless --apply)")
2990
+ _add_save_target(sk)
2991
+ sk.add_argument("keyitem", help="key-item name (live from the install) or a 0-255 id")
2992
+ sk.add_argument("--remove", action="store_true", help="remove the key item (clear obtained + used)")
2993
+ sk.add_argument("--used", action="store_true", help="also mark it used (default: obtained, not used)")
2994
+ sk.add_argument("--not-obtained", action="store_true", help="mark known-but-not-obtained (rare)")
2995
+ sk.set_defaults(func=_cmd_items_set_keyitem)
2996
+
2997
+ ss = sub.add_parser("items-set-stat",
2998
+ help="set a character's permanent stat (Speed/Strength/Magic/Spirit) in the Memoria "
2999
+ "extra file (dry-run unless --apply)")
3000
+ _add_save_target(ss)
3001
+ ss.add_argument("character", help="CharacterId 0-11 or a name (Zidane..Beatrix)")
3002
+ ss.add_argument("stat", help="Speed | Strength | Magic | Spirit")
3003
+ ss.add_argument("value", type=int, help="target value (Speed/Spirit cap 50, Strength/Magic cap 99)")
3004
+ ss.set_defaults(func=_cmd_items_set_stat)
3005
+
3006
+ sa = sub.add_parser("items-set-ap",
3007
+ help="set a character's ability AP / mastery in the Memoria extra file (dry-run unless "
3008
+ "--apply)")
3009
+ _add_save_target(sa)
3010
+ sa.add_argument("character", help="CharacterId 0-11 or a name (Zidane..Beatrix)")
3011
+ sa.add_argument("ability", help="ability name, AA:X / SA:X token, numeric abil_id, or 'all'")
3012
+ sa.add_argument("value", help="master | max | forget | a number (0-255)")
3013
+ sa.set_defaults(func=_cmd_items_set_ap)
3014
+
3015
+ fd = sub.add_parser("flags-diff",
3016
+ help="diff two saves' story state (A -> B): what scenario/flags a beat changed")
3017
+ fd.add_argument("a", help="save A: SavedData_ww.dat / a Memoria extra-save / a save JSON file-or-text "
3018
+ "/ a bare Base64 gEventGlobal blob")
3019
+ fd.add_argument("b", nargs="?", default=None,
3020
+ help="save B (default: same source as A -- diff two slots of one save)")
3021
+ fd.add_argument("--slot-a", type=int, default=None, help="A's populated-slot index (default 0)")
3022
+ fd.add_argument("--slot-b", type=int, default=None,
3023
+ help="B's populated-slot index (default 1 when B is omitted, else 0)")
3024
+ fd.add_argument("--all", action="store_true", help="also list the raw unmapped bit indices")
3025
+ fd.set_defaults(func=_cmd_flags_diff)
3026
+
3027
+ se = sub.add_parser("save-edit",
3028
+ help="set a real FF9 save's story state (ScenarioCounter + flags) -- the 'recreate' verb")
3029
+ se.add_argument("save", help="path to SavedData_ww.dat (or a copy of it)")
3030
+ se.add_argument("--list", action="store_true", help="list the populated saves (slot/save, scenario, chests) and exit")
3031
+ se.add_argument("--slot", type=int, help="save slot 0-9")
3032
+ se.add_argument("--save", dest="save_index", type=int, help="save 0-14 within the slot")
3033
+ se.add_argument("--block", type=int, help="raw data-block index (alternative to --slot/--save; 0 = autosave)")
3034
+ se.add_argument("--autosave", action="store_true", help="target the autosave block")
3035
+ se.add_argument("--scenario", help="set ScenarioCounter: a value (2500) or an area name (\"Ice Cavern\")")
3036
+ se.add_argument("--set", dest="set_flags", help="comma-separated flag indices (or [[flag]] names with --names) to SET")
3037
+ se.add_argument("--clear", dest="clear_flags", help="comma-separated flag indices to CLEAR")
3038
+ se.add_argument("--names", help="a field.toml/campaign.toml whose [[flag]] table names --set/--clear flags")
3039
+ se.add_argument("--out", help="write the edited save to this path (safe; leaves the original untouched)")
3040
+ se.add_argument("--in-place", action="store_true", help="overwrite the save (a timestamped .bak is made first)")
3041
+ se.set_defaults(func=_cmd_save_edit)
3042
+
3043
+ ed = sub.add_parser("edit", help="open the form-based field-logic editor (no TOML hand-editing)")
3044
+ ed.add_argument("field", nargs="?", default=None, help="a .field.toml to open (optional)")
3045
+ ed.set_defaults(func=_cmd_edit)
3046
+
3047
+ dl = sub.add_parser("dialogue", help="view a field.toml's authored dialogue + how each line wraps on "
3048
+ "screen (or a campaign.toml: review every member field at once)")
3049
+ dl.add_argument("field", help="path to a .field.toml (or a campaign.toml to review the whole set)")
3050
+ dl.add_argument("--clean", action="store_true", help="strip FF9 control tags for a plain read")
3051
+ dl.set_defaults(func=_cmd_dialogue)
3052
+
3053
+ di = sub.add_parser("dialogue-import",
3054
+ help="read a REAL FF9 field's dialogue (or a built mod's, with --mod) -- 'NPC -> text'")
3055
+ di.add_argument("field", help="real field id or FBG name (e.g. 100, alexandria); or a name/id in the --mod")
3056
+ di.add_argument("--lang", default="us", help="language block to read (default us)")
3057
+ di.add_argument("--mod", default=None,
3058
+ help="read from a BUILT mod folder on disk instead of the install (no UnityPy needed); "
3059
+ "e.g. --mod release/FF9CustomMap")
3060
+ di.add_argument("--zone-id", type=int, default=None, dest="zone_id",
3061
+ help="the field's text-block id -> read <zone-id>.mes directly (else auto-detect by txid)")
3062
+ di.add_argument("--clean", action="store_true", help="strip FF9 control tags for a plain read")
3063
+ di.add_argument("--all", action="store_true", dest="show_all",
3064
+ help="show ALL window calls incl. system/notification windows + repeated call sites "
3065
+ "(default hides them: only real dialogue, de-duplicated)")
3066
+ di.add_argument("--out", default=None,
3067
+ help="also write a JSON view here (use a .dialogue.json suffix -- SE-derived, gitignored)")
3068
+ di.set_defaults(func=_cmd_dialogue_import)
3069
+
3070
+ fr = sub.add_parser("fork-report",
3071
+ help="preview what a fork of a REAL field will/won't reproduce, offline (fidelity report)")
3072
+ fr.add_argument("field", help="real field id or FBG name (e.g. 354, dl_shp, lb_tmp) -- see `list-fields`")
3073
+ fr.add_argument("--explain", action="store_true",
3074
+ help="decode each NPC's talk routine into readable English (dialogue + items + the funcs "
3075
+ "it runs) -- shows WHY a render-only NPC needs --verbatim")
3076
+ fr.set_defaults(func=_cmd_fork_report)
3077
+
3078
+ lmp = sub.add_parser("logic-map",
3079
+ help="read-only legible map of a REAL field's whole .eb (entries/routines, the resolved "
3080
+ "call graph, dialogue/item/flag effects) -- the inspectable view of a verbatim fork")
3081
+ lmp.add_argument("field", help="real field id or FBG name (e.g. 354, dl_shp) -- see `list-fields`")
3082
+ lmp.add_argument("--json", action="store_true",
3083
+ help="emit the map as JSON (the generated [view]) instead of the readable transcript")
3084
+ lmp.set_defaults(func=_cmd_logic_map)
3085
+
3086
+ le = sub.add_parser("lint-eb",
3087
+ help="structurally lint a field's .eb (decode / jump+switch bounds / reachable terminator / "
3088
+ "dangling RunScript) -- the offline soundness check for a verbatim fork or an edit")
3089
+ le.add_argument("field", help="a real field id/name OR a path to a .eb / verbatim .bin")
3090
+ le.set_defaults(func=_cmd_lint_eb)
3091
+
3092
+ fdr = sub.add_parser("find-rooms",
3093
+ help="sweep ALL fields for the best swap/demo test rooms (single-PC + swap-clean + close camera)")
3094
+ fdr.add_argument("--limit", type=int, default=20, help="max rooms to show (default 20)")
3095
+ fdr.add_argument("--max-fov", type=float, default=45.0,
3096
+ help="upper FOV bound, degrees (default 45 = exclude wide establishing lenses; raise to widen)")
3097
+ fdr.set_defaults(func=_cmd_find_rooms)
3098
+
3099
+ xt = sub.add_parser("extract-templates",
3100
+ help="regenerate the kit's base assets from YOUR FF9 install (ships no game data)")
3101
+ xt.add_argument("--no-fixtures", action="store_true", help="skip the test fixtures (templates only)")
3102
+ xt.set_defaults(func=_cmd_extract_templates)
3103
+
3104
+ return p
3105
+
3106
+
3107
+ def main(argv: list[str] | None = None) -> int:
3108
+ parser = build_parser()
3109
+ args = parser.parse_args(argv)
3110
+ return args.func(args)
3111
+
3112
+
3113
+ if __name__ == "__main__":
3114
+ raise SystemExit(main())