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