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.
- ff9mapkit/__init__.py +18 -0
- ff9mapkit/__main__.py +36 -0
- ff9mapkit/_animdb.py +2994 -0
- ff9mapkit/_animdb_all.py +14125 -0
- ff9mapkit/_fieldtable.py +1516 -0
- ff9mapkit/_fieldtext.py +845 -0
- ff9mapkit/_held_poses.py +44 -0
- ff9mapkit/_itemdb.py +65 -0
- ff9mapkit/_modeldb.py +725 -0
- ff9mapkit/_narrowmap_data.py +10 -0
- ff9mapkit/_npcparams.py +634 -0
- ff9mapkit/_regen_animdb.py +72 -0
- ff9mapkit/_regen_animdb_all.py +66 -0
- ff9mapkit/_regen_fieldtable.py +95 -0
- ff9mapkit/_regen_fieldtext.py +66 -0
- ff9mapkit/_regen_modeldb.py +67 -0
- ff9mapkit/_regen_npcparams.py +123 -0
- ff9mapkit/_regen_scenedb.py +57 -0
- ff9mapkit/_scenedb.py +869 -0
- ff9mapkit/abilities.py +225 -0
- ff9mapkit/animations.py +120 -0
- ff9mapkit/archetypes.py +218 -0
- ff9mapkit/areatitle.py +76 -0
- ff9mapkit/battle/__init__.py +21 -0
- ff9mapkit/battle/abilityfeatures.py +294 -0
- ff9mapkit/battle/actiondelta.py +441 -0
- ff9mapkit/battle/aiauthor.py +305 -0
- ff9mapkit/battle/ailint.py +140 -0
- ff9mapkit/battle/aipatch.py +175 -0
- ff9mapkit/battle/battleai.py +148 -0
- ff9mapkit/battle/battlecsv.py +390 -0
- ff9mapkit/battle/battlepatch.py +395 -0
- ff9mapkit/battle/build.py +558 -0
- ff9mapkit/battle/camera_codec.py +332 -0
- ff9mapkit/battle/camera_data.py +128 -0
- ff9mapkit/battle/characterdelta.py +789 -0
- ff9mapkit/battle/event_data.py +72 -0
- ff9mapkit/battle/extract.py +540 -0
- ff9mapkit/battle/fbx.py +223 -0
- ff9mapkit/battle/reskin.py +149 -0
- ff9mapkit/battle/scene_codec.py +314 -0
- ff9mapkit/battle/scene_data.py +369 -0
- ff9mapkit/battle/scenelint.py +125 -0
- ff9mapkit/battle/seqasm.py +131 -0
- ff9mapkit/battle/seqauthor.py +220 -0
- ff9mapkit/battle/seqcodec.py +300 -0
- ff9mapkit/battle/seqdis.py +106 -0
- ff9mapkit/battle/seqpatch.py +137 -0
- ff9mapkit/battle_bgm.py +133 -0
- ff9mapkit/binutils.py +60 -0
- ff9mapkit/build.py +5445 -0
- ff9mapkit/campaign.py +1276 -0
- ff9mapkit/catalog.py +316 -0
- ff9mapkit/chain.py +358 -0
- ff9mapkit/cli.py +3114 -0
- ff9mapkit/config.py +360 -0
- ff9mapkit/content/__init__.py +13 -0
- ff9mapkit/content/areatitle.py +36 -0
- ff9mapkit/content/ate.py +118 -0
- ff9mapkit/content/camera.py +123 -0
- ff9mapkit/content/chest.py +186 -0
- ff9mapkit/content/choice.py +163 -0
- ff9mapkit/content/conductor.py +217 -0
- ff9mapkit/content/cutscene.py +290 -0
- ff9mapkit/content/encounter.py +41 -0
- ff9mapkit/content/entry_settle.py +50 -0
- ff9mapkit/content/equipment.py +93 -0
- ff9mapkit/content/event.py +191 -0
- ff9mapkit/content/gateway.py +101 -0
- ff9mapkit/content/inventory.py +59 -0
- ff9mapkit/content/itemdata.py +644 -0
- ff9mapkit/content/itemtext.py +168 -0
- ff9mapkit/content/jump.py +114 -0
- ff9mapkit/content/ladder.py +633 -0
- ff9mapkit/content/movement.py +53 -0
- ff9mapkit/content/music.py +97 -0
- ff9mapkit/content/npc.py +348 -0
- ff9mapkit/content/object.py +340 -0
- ff9mapkit/content/onentry.py +135 -0
- ff9mapkit/content/party.py +111 -0
- ff9mapkit/content/pathfind.py +138 -0
- ff9mapkit/content/platform.py +314 -0
- ff9mapkit/content/player.py +168 -0
- ff9mapkit/content/prop.py +75 -0
- ff9mapkit/content/region.py +340 -0
- ff9mapkit/content/reinit.py +59 -0
- ff9mapkit/content/savepoint.py +90 -0
- ff9mapkit/content/shop.py +178 -0
- ff9mapkit/content/sps_trigger.py +66 -0
- ff9mapkit/content/startup.py +71 -0
- ff9mapkit/content/synthesis.py +106 -0
- ff9mapkit/content/text.py +183 -0
- ff9mapkit/content/textcarry.py +290 -0
- ff9mapkit/content/verbatim.py +86 -0
- ff9mapkit/content/walkmesh_hotfix.py +38 -0
- ff9mapkit/data/__init__.py +48 -0
- ff9mapkit/data/_regen_provenance.py +142 -0
- ff9mapkit/data/provenance/blank.es.patch +1 -0
- ff9mapkit/data/provenance/blank.fr.patch +1 -0
- ff9mapkit/data/provenance/blank.gr.patch +1 -0
- ff9mapkit/data/provenance/blank.it.patch +1 -0
- ff9mapkit/data/provenance/blank.jp.patch +1 -0
- ff9mapkit/data/provenance/blank.uk.patch +1 -0
- ff9mapkit/data/provenance/blank.us.patch +1 -0
- ff9mapkit/data/provenance/manifest.json +65 -0
- ff9mapkit/data/provenance/region_template.patch +1 -0
- ff9mapkit/data/reference_arcs.toml +89 -0
- ff9mapkit/data/region_catalog.toml +593 -0
- ff9mapkit/deploystack.py +358 -0
- ff9mapkit/dialogue.py +803 -0
- ff9mapkit/eb/__init__.py +12 -0
- ff9mapkit/eb/_exprtable.py +59 -0
- ff9mapkit/eb/_membertable.py +38 -0
- ff9mapkit/eb/_optables.py +537 -0
- ff9mapkit/eb/_regen_optables.py +76 -0
- ff9mapkit/eb/cmdasm.py +323 -0
- ff9mapkit/eb/disasm.py +332 -0
- ff9mapkit/eb/edit.py +439 -0
- ff9mapkit/eb/exprasm.py +158 -0
- ff9mapkit/eb/model.py +178 -0
- ff9mapkit/eb/opcodes.py +463 -0
- ff9mapkit/eblint.py +177 -0
- ff9mapkit/editor/__init__.py +20 -0
- ff9mapkit/editor/app.py +950 -0
- ff9mapkit/editor/battle_forms.py +240 -0
- ff9mapkit/editor/breadcrumb.py +89 -0
- ff9mapkit/editor/dialogs.py +116 -0
- ff9mapkit/editor/feedback.py +208 -0
- ff9mapkit/editor/forms.py +632 -0
- ff9mapkit/editor/graphview.py +350 -0
- ff9mapkit/editor/jobs.py +342 -0
- ff9mapkit/editor/model.py +243 -0
- ff9mapkit/editor/picker.py +120 -0
- ff9mapkit/editor/theme.py +212 -0
- ff9mapkit/eventscan.py +1441 -0
- ff9mapkit/extract.py +2279 -0
- ff9mapkit/flags.py +693 -0
- ff9mapkit/forkreport.py +1383 -0
- ff9mapkit/hub.py +477 -0
- ff9mapkit/idgated.py +101 -0
- ff9mapkit/infohub.py +580 -0
- ff9mapkit/items.py +63 -0
- ff9mapkit/itemstats.py +346 -0
- ff9mapkit/journey.py +1902 -0
- ff9mapkit/keyitems.py +93 -0
- ff9mapkit/logic_add.py +632 -0
- ff9mapkit/logic_edit.py +728 -0
- ff9mapkit/logic_map.py +526 -0
- ff9mapkit/pack.py +175 -0
- ff9mapkit/playerswap.py +231 -0
- ff9mapkit/prop_archetypes.py +228 -0
- ff9mapkit/provision.py +282 -0
- ff9mapkit/refarc.py +825 -0
- ff9mapkit/save.py +337 -0
- ff9mapkit/save_items.py +1673 -0
- ff9mapkit/scene/__init__.py +11 -0
- ff9mapkit/scene/arena.py +63 -0
- ff9mapkit/scene/bgart.py +140 -0
- ff9mapkit/scene/bgi.py +732 -0
- ff9mapkit/scene/bgs.py +174 -0
- ff9mapkit/scene/bgx.py +185 -0
- ff9mapkit/scene/cam.py +345 -0
- ff9mapkit/scene/guide.py +311 -0
- ff9mapkit/scene/paint.py +506 -0
- ff9mapkit/scene/placeholder.py +107 -0
- ff9mapkit/sjbinary.py +285 -0
- ff9mapkit/sps/__init__.py +17 -0
- ff9mapkit/sps/author.py +294 -0
- ff9mapkit/sps/catalog.py +88 -0
- ff9mapkit/sps/codec.py +264 -0
- ff9mapkit/sps/edit.py +184 -0
- ff9mapkit/sps/lint.py +58 -0
- ff9mapkit/sps/render.py +116 -0
- ff9mapkit/sps/templates.py +47 -0
- ff9mapkit/sps/texture.py +131 -0
- ff9mapkit/walkmesh_hotfixes.py +163 -0
- ff9mapkit/workspace/__init__.py +18 -0
- ff9mapkit/workspace/battledoc.py +985 -0
- ff9mapkit/workspace/builddoc.py +607 -0
- ff9mapkit/workspace/forms_qt.py +586 -0
- ff9mapkit/workspace/importdoc.py +665 -0
- ff9mapkit/workspace/mapview.py +131 -0
- ff9mapkit/workspace/palette.py +85 -0
- ff9mapkit/workspace/savedoc.py +664 -0
- ff9mapkit/workspace/shell.py +6907 -0
- ff9mapkit/workspace/style.py +105 -0
- ff9mapkit/workspace/tuningdialog.py +223 -0
- ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
- ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
- ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
- ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
- ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
- 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())
|