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
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
"""The Battle document for the Workspace -- author a battle.toml ENCOUNTER-FIRST.
|
|
2
|
+
|
|
3
|
+
Open a battle.toml and tune it as an encounter: its ``[battlemap]`` identity, its ``[scene]`` FORMATION, and
|
|
4
|
+
each ``[[scene.enemy]]`` slot, edited as forms (the :mod:`ff9mapkit.editor.battle_forms` specs over the shared
|
|
5
|
+
``forms_qt`` builder -- the same machinery the field editor uses). ``Check`` runs ``validate_battle`` into the
|
|
6
|
+
Problems dock; deploying is the existing **Build & Deploy** battle path (open the same battle.toml there).
|
|
7
|
+
|
|
8
|
+
Modeled on :class:`~ff9mapkit.workspace.savedoc.ItemEquipDoc`: a self-contained document with a left NODE list
|
|
9
|
+
(Map / Formation / one per enemy slot) + a right form, over tk-free backends. A battle.toml is read with
|
|
10
|
+
``tomllib`` and written back with :func:`ff9mapkit.editor.model.dumps` (round-trip-safe for the battle schema).
|
|
11
|
+
Creating a battle.toml is the ``ff9mapkit battle-import`` CLI's job (like forking a field is the Import tab's);
|
|
12
|
+
this document TUNES one.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
import tomllib
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from PySide6.QtCore import Qt
|
|
21
|
+
from PySide6.QtGui import QKeySequence, QShortcut
|
|
22
|
+
from PySide6.QtWidgets import (
|
|
23
|
+
QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QFrame, QGridLayout, QGroupBox,
|
|
24
|
+
QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMessageBox, QPushButton, QScrollArea,
|
|
25
|
+
QSplitter, QVBoxLayout, QWidget,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from ..editor import battle_forms as bf
|
|
29
|
+
from ..editor import feedback as fb
|
|
30
|
+
from ..editor import forms
|
|
31
|
+
from ..editor import model as _model
|
|
32
|
+
from .forms_qt import build_form, read
|
|
33
|
+
|
|
34
|
+
_MAP, _SCENE, _ENEMY, _AIPHASE = "battlemap", "scene", "enemy", "ai_phase"
|
|
35
|
+
_AIPATCH, _SEQPATCH = "ai_patch", "seq_patch"
|
|
36
|
+
|
|
37
|
+
# MonParm attribute -> compact label, for the read-only DONOR BASELINE shown above an enemy form: the forked
|
|
38
|
+
# enemy's CURRENT scalar stats, so an override reads against what it's changing FROM. Scalars only (the element
|
|
39
|
+
# / status masks need decoding -- a later pass); the keys line up with ENEMY_SPEC so the panel sits next to the
|
|
40
|
+
# matching form rows.
|
|
41
|
+
_BASELINE_FIELDS = [
|
|
42
|
+
("hp", "HP"), ("mp", "MP"), ("strength", "Str"), ("magic", "Mag"), ("speed", "Spd"), ("spirit", "Spr"),
|
|
43
|
+
("level", "Lv"), ("phys_def", "P.def"), ("phys_evade", "P.eva"), ("mag_def", "M.def"),
|
|
44
|
+
("mag_evade", "M.eva"), ("hit_rate", "Hit"), ("category", "Cat"), ("blue_magic", "Blue"),
|
|
45
|
+
("gil", "Gil"), ("exp", "EXP"), ("win_card", "Card"),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def donor_baseline(raw16: bytes, enemy: dict):
|
|
50
|
+
"""``(type_no, [(label, value)...])`` for an enemy slot's TYPE from a forked scene's raw16, or None when the
|
|
51
|
+
type can't be resolved / the bytes don't parse. PURE (no I/O) so it unit-tests without Qt; the document
|
|
52
|
+
wraps it with the file read. The type is the slot's explicit ``type``, else pattern-0's put at that slot."""
|
|
53
|
+
try:
|
|
54
|
+
from ..battle import scene_codec as _sc
|
|
55
|
+
scene = _sc.parse_scene(raw16)
|
|
56
|
+
except Exception: # noqa: BLE001 -- a truncated / non-scene raw16
|
|
57
|
+
return None
|
|
58
|
+
t = enemy.get("type")
|
|
59
|
+
if t is None:
|
|
60
|
+
slot = enemy.get("slot")
|
|
61
|
+
if scene.patterns and isinstance(slot, int) and 0 <= slot < 4:
|
|
62
|
+
t = scene.patterns[0].puts[slot].type_no
|
|
63
|
+
if not isinstance(t, int) or not (0 <= t < len(scene.monsters)):
|
|
64
|
+
return None
|
|
65
|
+
mon = scene.monsters[t]
|
|
66
|
+
return t, [(label, getattr(mon, attr)) for attr, label in _BASELINE_FIELDS]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
import re as _re
|
|
70
|
+
|
|
71
|
+
_STRT_RE = _re.compile(r"^\[STRT=[^\]]*\]")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _mes_strings(mes_bytes: bytes):
|
|
75
|
+
"""The battle ``.mes`` strings in order (index = the AA_DATA name id), the ``[STRT=..]`` prefix stripped."""
|
|
76
|
+
out = []
|
|
77
|
+
for chunk in mes_bytes.decode("utf-8", "replace").split("[ENDN]"):
|
|
78
|
+
if chunk.strip():
|
|
79
|
+
out.append(_STRT_RE.sub("", chunk).strip())
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def donor_ai_facts(eb_bytes: bytes, raw16_bytes: bytes = None, mes_bytes: bytes = None):
|
|
84
|
+
"""``(attacks, ai_funcs)`` for the forked scene -- the indices/entries the AI-phase form needs, or None if the
|
|
85
|
+
``.eb`` doesn't parse. ``attacks`` = ``[(index, name)]`` (the ``then``/``else`` values; names resolved from the
|
|
86
|
+
``.mes`` when given). ``ai_funcs`` = ``[(entry, type, tag, role, n_attacks)]`` per enemy-AI function -- a function
|
|
87
|
+
with EXACTLY ONE ``Attack`` is the ``ai_phase``-able target. Pure (no I/O) so it unit-tests without Qt."""
|
|
88
|
+
from ..battle.battleai import _decode_func_pretty, _tag_role
|
|
89
|
+
from ..eb.model import EbScript
|
|
90
|
+
try:
|
|
91
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
92
|
+
except Exception: # noqa: BLE001 -- a truncated / non-battle eb
|
|
93
|
+
return None
|
|
94
|
+
ai_funcs = []
|
|
95
|
+
for e in eb.entries:
|
|
96
|
+
if e.empty or e.index == 0: # entry 0 = Main_Init (spawn binding, not an enrage target)
|
|
97
|
+
continue
|
|
98
|
+
for f in e.funcs:
|
|
99
|
+
try:
|
|
100
|
+
n_atk = sum(1 for _o, mn, _ops in _decode_func_pretty(eb.data, f.abs_start, min(f.abs_end, len(eb.data)))
|
|
101
|
+
if mn == "Attack")
|
|
102
|
+
except Exception: # noqa: BLE001 -- malformed bytecode in one func
|
|
103
|
+
n_atk = -1
|
|
104
|
+
ai_funcs.append((e.index, e.index - 1, f.tag, _tag_role(f.tag), n_atk))
|
|
105
|
+
attacks = []
|
|
106
|
+
if raw16_bytes:
|
|
107
|
+
try:
|
|
108
|
+
from ..battle import scene_codec as _sc
|
|
109
|
+
scene = _sc.parse_scene(raw16_bytes)
|
|
110
|
+
# the battle .mes lists the `typ_count` enemy-TYPE names first, then the `atk_count` ATTACK names
|
|
111
|
+
# (AA_DATA.name is 0/unused for the display) -> attack i is string[typ_count + i].
|
|
112
|
+
strings = _mes_strings(mes_bytes) if mes_bytes else []
|
|
113
|
+
base = scene.typ_count
|
|
114
|
+
for i in range(len(scene.attacks)):
|
|
115
|
+
attacks.append((i, strings[base + i] if 0 <= base + i < len(strings) else "?"))
|
|
116
|
+
except Exception: # noqa: BLE001
|
|
117
|
+
attacks = []
|
|
118
|
+
return attacks, ai_funcs
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def ai_patch_sites(eb_bytes: bytes):
|
|
122
|
+
"""``[(offset, value, where, lo, hi)]`` for every patchable AI constant in a forked scene's ``.eb`` (the
|
|
123
|
+
sites an ``[[scene.ai_patch]]`` cites), or None if the bytes don't parse. ``lo``/``hi`` bound a same-length
|
|
124
|
+
``new``. Pure (no I/O) so it unit-tests without Qt; the document wraps it with the file read + the picker."""
|
|
125
|
+
from ..battle import aipatch as _ap
|
|
126
|
+
try:
|
|
127
|
+
sites = _ap.constant_sites(eb_bytes)
|
|
128
|
+
except _ap.AiPatchError:
|
|
129
|
+
return None
|
|
130
|
+
return [(s.offset, s.value, s.where, 0, s.vmax) for s in sites]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def seq_patch_sites(raw17_bytes: bytes):
|
|
134
|
+
"""``[(offset, value, where, lo, hi, seq)]`` for every patchable raw17 sequence operand (the sites a
|
|
135
|
+
``[[scene.seq_patch]]`` cites), or None if the bytes don't parse. ``seq`` = the canonical owning attack/sub.
|
|
136
|
+
Pure (no I/O) so it unit-tests without Qt."""
|
|
137
|
+
from ..battle import seqpatch as _sp
|
|
138
|
+
try:
|
|
139
|
+
sites = _sp.constant_sites(raw17_bytes)
|
|
140
|
+
except _sp.SeqPatchError:
|
|
141
|
+
return None
|
|
142
|
+
return [(s.offset, s.value, s.where, s.vmin, s.vmax, s.sub_no) for s in sites]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def donor_scene_facts(raw16: bytes):
|
|
146
|
+
"""[(label, value)...] of the forked scene's CURRENT encounter rules (flags decoded to names) + its
|
|
147
|
+
pattern/type/attack counts, for a read-only hint above the Formation form. None if the bytes don't parse.
|
|
148
|
+
Pure (no I/O). The decoded flag names match the `[scene] flags` vocabulary so the user can edit against them."""
|
|
149
|
+
try:
|
|
150
|
+
from ..battle import scene_codec as _sc
|
|
151
|
+
scene = _sc.parse_scene(raw16)
|
|
152
|
+
except Exception: # noqa: BLE001
|
|
153
|
+
return None
|
|
154
|
+
on = [name for name, active in (("back_attack", scene.back_attack), ("preemptive", scene.preemptive),
|
|
155
|
+
("no_escape", not scene.can_escape), ("no_exp", scene.no_exp)) if active]
|
|
156
|
+
return [("Current flags", ", ".join(on) or "(none)"), ("Patterns", scene.pat_count),
|
|
157
|
+
("Enemy types", scene.typ_count), ("Attacks", scene.atk_count)]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class BattleDoc(QWidget):
|
|
161
|
+
"""Author a battle.toml. ``output`` streams text to the bottom Output dock; ``problems`` posts the Check
|
|
162
|
+
verdict + rows to the Problems dock (the same callbacks :class:`BuildDoc` takes)."""
|
|
163
|
+
|
|
164
|
+
def __init__(self, palette, *, output=None, problems=None, run=None, kit_root=None, on_open=None):
|
|
165
|
+
super().__init__()
|
|
166
|
+
self.pal = palette
|
|
167
|
+
self._output = output
|
|
168
|
+
self._problems = problems
|
|
169
|
+
self._run = run # shell.run_job: streams a CLI job (battle-import) to the Output dock
|
|
170
|
+
self.kit = Path(kit_root) if kit_root else None # `-m ff9mapkit` cwd (so the local pkg shadows)
|
|
171
|
+
self._on_open = on_open # called with the battle.toml path on open/fork -> shell pre-aims Build & Deploy
|
|
172
|
+
self.path = None # Path of the open battle.toml
|
|
173
|
+
self.data = {} # the loaded dict (battlemap / scene / scene.enemy[])
|
|
174
|
+
self._nodes = [] # [(kind, idx)] parallel to the node-list rows
|
|
175
|
+
self._ctx = None # {kind, idx, spec, getters} for the mounted form's Save
|
|
176
|
+
self._install_lists = {} # cache: install-gated BBG / scene lists (read p0data once per session)
|
|
177
|
+
self._build_ui()
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------------ UI
|
|
180
|
+
def _build_ui(self):
|
|
181
|
+
outer = QVBoxLayout(self)
|
|
182
|
+
outer.setContentsMargins(0, 0, 0, 0)
|
|
183
|
+
top = QHBoxLayout()
|
|
184
|
+
self.open_btn = QPushButton("Open battle.toml…")
|
|
185
|
+
self.open_btn.clicked.connect(self.browse)
|
|
186
|
+
top.addWidget(self.open_btn)
|
|
187
|
+
self.fork_btn = QPushButton("Fork battle…")
|
|
188
|
+
self.fork_btn.setToolTip("Fork a real FF9 battle background into a new editable battle.toml, then open it")
|
|
189
|
+
self.fork_btn.clicked.connect(self._fork_dialog)
|
|
190
|
+
self.fork_btn.setEnabled(self._run is not None and self.kit is not None)
|
|
191
|
+
top.addWidget(self.fork_btn)
|
|
192
|
+
self.path_lbl = QLabel("No battle map open — Open a battle.toml, or Fork one from a real FF9 "
|
|
193
|
+
"battle background.")
|
|
194
|
+
self.path_lbl.setStyleSheet(f"color:{self.pal['muted']};")
|
|
195
|
+
top.addWidget(self.path_lbl, 1)
|
|
196
|
+
outer.addLayout(top)
|
|
197
|
+
|
|
198
|
+
split = QSplitter()
|
|
199
|
+
left = QWidget()
|
|
200
|
+
lv = QVBoxLayout(left)
|
|
201
|
+
lv.setContentsMargins(0, 0, 0, 0)
|
|
202
|
+
self.nodes = QListWidget()
|
|
203
|
+
self.nodes.currentRowChanged.connect(self._on_node)
|
|
204
|
+
lv.addWidget(self.nodes, 1)
|
|
205
|
+
self.del_btn = QPushButton("Remove selected")
|
|
206
|
+
self.del_btn.setToolTip("Remove the selected enemy slot / AI phase / patch / party-mod row "
|
|
207
|
+
"(the [battlemap] and [scene] tables can't be removed; applied on Save)")
|
|
208
|
+
self.del_btn.clicked.connect(self._delete_selected)
|
|
209
|
+
self.del_btn.setEnabled(False)
|
|
210
|
+
lv.addWidget(self.del_btn)
|
|
211
|
+
del_sc = QShortcut(QKeySequence(Qt.Key.Key_Delete), self.nodes, activated=self._delete_selected)
|
|
212
|
+
del_sc.setContext(Qt.ShortcutContext.WidgetShortcut) # Delete only when the node list has focus
|
|
213
|
+
self.add_enemy_btn = QPushButton("Add enemy slot")
|
|
214
|
+
self.add_enemy_btn.clicked.connect(self._add_enemy)
|
|
215
|
+
self.add_enemy_btn.setEnabled(False)
|
|
216
|
+
lv.addWidget(self.add_enemy_btn)
|
|
217
|
+
self.add_aiphase_btn = QPushButton("Add AI phase")
|
|
218
|
+
self.add_aiphase_btn.setToolTip("Add a boss-enrage AI branch: switch the enemy's attack when a stat "
|
|
219
|
+
"drops below a fraction (mint-only)")
|
|
220
|
+
self.add_aiphase_btn.clicked.connect(self._add_ai_phase)
|
|
221
|
+
self.add_aiphase_btn.setEnabled(False)
|
|
222
|
+
lv.addWidget(self.add_aiphase_btn)
|
|
223
|
+
self.add_patch_btn = QPushButton("Add AI / sequence patch…")
|
|
224
|
+
self.add_patch_btn.setToolTip("Add a SAME-LENGTH constant patch: rewrite one AI literal (an HP threshold "
|
|
225
|
+
"/ attack index) or one choreography operand (a Wait/Anim/Camera value) in "
|
|
226
|
+
"place — cite an offset with 'Browse sites…' (mint-only)")
|
|
227
|
+
self.add_patch_btn.clicked.connect(self._add_patch)
|
|
228
|
+
self.add_patch_btn.setEnabled(False)
|
|
229
|
+
lv.addWidget(self.add_patch_btn)
|
|
230
|
+
self.add_player_btn = QPushButton("Add party/ability tuning…")
|
|
231
|
+
self.add_player_btn.setToolTip("Tune a PLAYER-side table (stats / abilities / status / leveling) — "
|
|
232
|
+
"mod-global, deployed with this battle")
|
|
233
|
+
self.add_player_btn.clicked.connect(self._add_player)
|
|
234
|
+
self.add_player_btn.setEnabled(False)
|
|
235
|
+
lv.addWidget(self.add_player_btn)
|
|
236
|
+
split.addWidget(left)
|
|
237
|
+
|
|
238
|
+
right = QWidget()
|
|
239
|
+
rv = QVBoxLayout(right)
|
|
240
|
+
self.host_scroll = QScrollArea()
|
|
241
|
+
self.host_scroll.setWidgetResizable(True)
|
|
242
|
+
self.host_scroll.setFrameShape(QFrame.Shape.NoFrame)
|
|
243
|
+
self.host = QWidget()
|
|
244
|
+
self.host_lay = QVBoxLayout(self.host)
|
|
245
|
+
self.host_scroll.setWidget(self.host)
|
|
246
|
+
rv.addWidget(self.host_scroll, 1)
|
|
247
|
+
btns = QHBoxLayout()
|
|
248
|
+
self.save_btn = QPushButton("Save")
|
|
249
|
+
self.save_btn.clicked.connect(self._save)
|
|
250
|
+
self.save_btn.setEnabled(False)
|
|
251
|
+
self.check_btn = QPushButton("Check")
|
|
252
|
+
self.check_btn.clicked.connect(self._check)
|
|
253
|
+
self.check_btn.setEnabled(False)
|
|
254
|
+
btns.addWidget(self.save_btn)
|
|
255
|
+
btns.addWidget(self.check_btn)
|
|
256
|
+
btns.addStretch(1)
|
|
257
|
+
rv.addLayout(btns)
|
|
258
|
+
hint = QLabel("→ deploy on the Build & Deploy tab (open this same battle.toml there).")
|
|
259
|
+
hint.setStyleSheet(f"color:{self.pal['muted']};")
|
|
260
|
+
rv.addWidget(hint)
|
|
261
|
+
split.addWidget(right)
|
|
262
|
+
split.setSizes([200, 520])
|
|
263
|
+
outer.addWidget(split, 1)
|
|
264
|
+
self._placeholder("Open a battle.toml to tune its encounter.")
|
|
265
|
+
|
|
266
|
+
def _clear(self):
|
|
267
|
+
while self.host_lay.count():
|
|
268
|
+
it = self.host_lay.takeAt(0)
|
|
269
|
+
w = it.widget()
|
|
270
|
+
if w is not None:
|
|
271
|
+
w.deleteLater()
|
|
272
|
+
|
|
273
|
+
def _placeholder(self, text):
|
|
274
|
+
self._clear()
|
|
275
|
+
lbl = QLabel(text)
|
|
276
|
+
lbl.setStyleSheet(f"color:{self.pal['muted']};")
|
|
277
|
+
lbl.setWordWrap(True)
|
|
278
|
+
self.host_lay.addWidget(lbl)
|
|
279
|
+
self.host_lay.addStretch(1)
|
|
280
|
+
|
|
281
|
+
def open_scene_id(self):
|
|
282
|
+
"""The minted battle-scene id of the open battle.toml (``[battlemap] scene_id``), or None. Lets the
|
|
283
|
+
shell answer 'is the field's encounter scene the one open here?' for the encounter->Battle jump."""
|
|
284
|
+
return ((self.data or {}).get("battlemap") or {}).get("scene_id")
|
|
285
|
+
|
|
286
|
+
def crumb_label(self):
|
|
287
|
+
"""A short 'you are editing X' label for the breadcrumb when the Battle tab is active."""
|
|
288
|
+
if self.path is None:
|
|
289
|
+
return "no battle map open"
|
|
290
|
+
sid = self.open_scene_id()
|
|
291
|
+
return Path(self.path).stem + (f" — scene {sid}" if sid is not None else "")
|
|
292
|
+
|
|
293
|
+
# ------------------------------------------------------------------ load
|
|
294
|
+
def browse(self):
|
|
295
|
+
f, _ = QFileDialog.getOpenFileName(self, "Open a battle.toml", "", "TOML (*.toml)")
|
|
296
|
+
if f:
|
|
297
|
+
self.load(f)
|
|
298
|
+
|
|
299
|
+
def load(self, path) -> bool:
|
|
300
|
+
try:
|
|
301
|
+
with open(path, "rb") as fh:
|
|
302
|
+
data = tomllib.load(fh)
|
|
303
|
+
except Exception as e: # noqa: BLE001
|
|
304
|
+
QMessageBox.warning(self, "Couldn't open", f"{Path(path).name}: {e}")
|
|
305
|
+
return False
|
|
306
|
+
if "battlemap" not in data:
|
|
307
|
+
QMessageBox.warning(self, "Not a battle map", f"{Path(path).name} has no [battlemap] table.")
|
|
308
|
+
return False
|
|
309
|
+
shape = self._shape_problem(data) # a hand-corrupted list section would crash node mounting
|
|
310
|
+
if shape:
|
|
311
|
+
QMessageBox.warning(self, "Can't open this battle.toml", f"{Path(path).name}: {shape}")
|
|
312
|
+
return False
|
|
313
|
+
self.path = Path(path)
|
|
314
|
+
self.data = data
|
|
315
|
+
bm = data.get("battlemap", {})
|
|
316
|
+
is_mint = bm.get("scene_id") is not None and bool(bm.get("scene_name"))
|
|
317
|
+
mode = (f"minted scene {bm.get('scene_id')} — [scene] tuning applies" if is_mint
|
|
318
|
+
else "MAP-ONLY override — [scene] tuning (stats/camera/flags) needs a Fork scene to apply")
|
|
319
|
+
self.path_lbl.setText(f"{self.path} · {mode}")
|
|
320
|
+
self.path_lbl.setStyleSheet(f"color:{self.pal['muted' if is_mint else 'warn']};")
|
|
321
|
+
self._ctx = None
|
|
322
|
+
self._rebuild_nodes()
|
|
323
|
+
self.add_enemy_btn.setEnabled(True)
|
|
324
|
+
self.add_aiphase_btn.setEnabled(True)
|
|
325
|
+
self.add_patch_btn.setEnabled(True)
|
|
326
|
+
self.add_player_btn.setEnabled(True)
|
|
327
|
+
self.check_btn.setEnabled(True)
|
|
328
|
+
if self.nodes.count():
|
|
329
|
+
self.nodes.setCurrentRow(0)
|
|
330
|
+
if self._on_open:
|
|
331
|
+
self._on_open(self.path) # pre-aim Build & Deploy at this battle.toml
|
|
332
|
+
return True
|
|
333
|
+
|
|
334
|
+
@staticmethod
|
|
335
|
+
def _shape_problem(data):
|
|
336
|
+
"""A message if a known list section isn't a list of tables (so a hand-corrupted battle.toml is rejected
|
|
337
|
+
cleanly instead of crashing _rebuild_nodes / form mounting), else None. battle-import output is well-formed;
|
|
338
|
+
this only catches hand edits. The accessors below then trust the shape (they return the REAL list to mutate)."""
|
|
339
|
+
scene = data.get("scene")
|
|
340
|
+
if scene is not None and not isinstance(scene, dict):
|
|
341
|
+
return f"[scene] must be a table (got {type(scene).__name__})"
|
|
342
|
+
scene = scene or {}
|
|
343
|
+
pairs = [(f"scene.{k}", scene.get(k)) for k in ("enemy", "ai_phase", "ai_patch", "seq_patch")]
|
|
344
|
+
pairs += [(k, data.get(k)) for k in bf.PLAYER_SPECS]
|
|
345
|
+
for name, v in pairs:
|
|
346
|
+
if v is not None and (not isinstance(v, list) or not all(isinstance(e, dict) for e in v)):
|
|
347
|
+
return f"[[{name}]] must be a list of tables (got {type(v).__name__})"
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
def _enemies(self):
|
|
351
|
+
return (self.data.get("scene") or {}).get("enemy", []) or []
|
|
352
|
+
|
|
353
|
+
def _ai_phases(self):
|
|
354
|
+
return (self.data.get("scene") or {}).get("ai_phase", []) or []
|
|
355
|
+
|
|
356
|
+
def _ai_patches(self):
|
|
357
|
+
return (self.data.get("scene") or {}).get("ai_patch", []) or []
|
|
358
|
+
|
|
359
|
+
def _seq_patches(self):
|
|
360
|
+
return (self.data.get("scene") or {}).get("seq_patch", []) or []
|
|
361
|
+
|
|
362
|
+
def _add_header(self, text):
|
|
363
|
+
"""A non-selectable separator row in the node list (a tree-section header)."""
|
|
364
|
+
item = QListWidgetItem(text)
|
|
365
|
+
item.setFlags(Qt.ItemFlag.NoItemFlags)
|
|
366
|
+
self.nodes.addItem(item)
|
|
367
|
+
self._nodes.append((None, None)) # keep _nodes parallel to the list rows
|
|
368
|
+
|
|
369
|
+
def _player_rows(self):
|
|
370
|
+
"""[(table_key, index, entry)] for every player/ability tuning entry the battle.toml carries."""
|
|
371
|
+
return [(key, i, e) for key in bf.PLAYER_SPECS for i, e in enumerate(self.data.get(key) or [])]
|
|
372
|
+
|
|
373
|
+
def _rebuild_nodes(self):
|
|
374
|
+
self.nodes.blockSignals(True)
|
|
375
|
+
self.nodes.clear()
|
|
376
|
+
self._nodes = []
|
|
377
|
+
self.nodes.addItem("Map · [battlemap]")
|
|
378
|
+
self._nodes.append((_MAP, None))
|
|
379
|
+
self.nodes.addItem("Formation · [scene]")
|
|
380
|
+
self._nodes.append((_SCENE, None))
|
|
381
|
+
for i, e in enumerate(self._enemies()):
|
|
382
|
+
self.nodes.addItem(f"Enemy slot {e.get('slot', i)}")
|
|
383
|
+
self._nodes.append((_ENEMY, i))
|
|
384
|
+
phases = self._ai_phases()
|
|
385
|
+
if phases: # boss-enrage AI branches (per-scene, mint-only)
|
|
386
|
+
self._add_header("— AI phases (boss enrage) —")
|
|
387
|
+
for i, p in enumerate(phases):
|
|
388
|
+
self.nodes.addItem(f"AI phase · entry {p.get('entry', '?')} "
|
|
389
|
+
f"{p.get('stat', 'hp')}<{p.get('below', 0.5)}")
|
|
390
|
+
self._nodes.append((_AIPHASE, i))
|
|
391
|
+
ai_patches = self._ai_patches()
|
|
392
|
+
if ai_patches: # same-length AI constant patches (cite-an-offset)
|
|
393
|
+
self._add_header("— AI constant patches —")
|
|
394
|
+
for i, p in enumerate(ai_patches):
|
|
395
|
+
self.nodes.addItem(f"AI patch · @{p.get('at', '?')} {p.get('old', '?')}→{p.get('new', '?')}")
|
|
396
|
+
self._nodes.append((_AIPATCH, i))
|
|
397
|
+
seq_patches = self._seq_patches()
|
|
398
|
+
if seq_patches: # same-length raw17 choreography operand patches
|
|
399
|
+
self._add_header("— Sequence patches (choreography) —")
|
|
400
|
+
for i, p in enumerate(seq_patches):
|
|
401
|
+
self.nodes.addItem(f"Seq patch · @{p.get('at', '?')} {p.get('old', '?')}→{p.get('new', '?')}")
|
|
402
|
+
self._nodes.append((_SEQPATCH, i))
|
|
403
|
+
player = self._player_rows()
|
|
404
|
+
if player: # the mod-global PLAYER side, under its own header
|
|
405
|
+
self._add_header("— Party & abilities (mod-global) —")
|
|
406
|
+
for key, i, e in player:
|
|
407
|
+
self.nodes.addItem(f"{bf.PLAYER_LABEL[key]} · {e.get(bf.PLAYER_SELECTOR[key], i)}")
|
|
408
|
+
self._nodes.append((key, i))
|
|
409
|
+
self.nodes.blockSignals(False)
|
|
410
|
+
|
|
411
|
+
# ------------------------------------------------------------------ node -> form
|
|
412
|
+
@staticmethod
|
|
413
|
+
def _deletable(kind):
|
|
414
|
+
"""A list-row node (enemy / ai_phase / ai_patch / seq_patch / a player-table row) can be removed; the
|
|
415
|
+
[battlemap] / [scene] singletons + section headers cannot."""
|
|
416
|
+
return kind in (_ENEMY, _AIPHASE, _AIPATCH, _SEQPATCH) or kind in bf.PLAYER_SPECS
|
|
417
|
+
|
|
418
|
+
def _on_node(self, row):
|
|
419
|
+
if not (0 <= row < len(self._nodes)):
|
|
420
|
+
self.del_btn.setEnabled(False)
|
|
421
|
+
return
|
|
422
|
+
self._commit_active() # fold any pending edit before switching
|
|
423
|
+
kind, idx = self._nodes[row]
|
|
424
|
+
self.del_btn.setEnabled(self._deletable(kind)) # Remove targets list rows, not Map/Formation/headers
|
|
425
|
+
if kind == _MAP:
|
|
426
|
+
self._mount(_MAP, None, bf.BATTLEMAP_SPEC, self.data.setdefault("battlemap", {}))
|
|
427
|
+
elif kind == _SCENE:
|
|
428
|
+
self._mount(_SCENE, None, bf.SCENE_SPEC, self.data.setdefault("scene", {}))
|
|
429
|
+
elif kind == _ENEMY:
|
|
430
|
+
if 0 <= idx < len(self._enemies()):
|
|
431
|
+
self._mount(_ENEMY, idx, bf.ENEMY_SPEC, self._enemies()[idx])
|
|
432
|
+
elif kind == _AIPHASE:
|
|
433
|
+
if 0 <= idx < len(self._ai_phases()):
|
|
434
|
+
self._mount(_AIPHASE, idx, bf.AI_PHASE_SPEC, self._ai_phases()[idx])
|
|
435
|
+
elif kind == _AIPATCH:
|
|
436
|
+
if 0 <= idx < len(self._ai_patches()):
|
|
437
|
+
self._mount(_AIPATCH, idx, bf.AI_PATCH_SPEC, self._ai_patches()[idx])
|
|
438
|
+
elif kind == _SEQPATCH:
|
|
439
|
+
if 0 <= idx < len(self._seq_patches()):
|
|
440
|
+
self._mount(_SEQPATCH, idx, bf.SEQ_PATCH_SPEC, self._seq_patches()[idx])
|
|
441
|
+
elif kind in bf.PLAYER_SPECS: # a player/ability tuning row
|
|
442
|
+
lst = self.data.get(kind) or []
|
|
443
|
+
if 0 <= idx < len(lst):
|
|
444
|
+
self._mount(kind, idx, bf.PLAYER_SPECS[kind], lst[idx])
|
|
445
|
+
# kind is None -> a separator header: nothing to mount
|
|
446
|
+
|
|
447
|
+
def _mount(self, kind, idx, spec, entity):
|
|
448
|
+
self._clear()
|
|
449
|
+
if kind == _ENEMY:
|
|
450
|
+
base = self._donor_baseline(entity) # read-only "what you're tuning from" panel
|
|
451
|
+
if base is not None:
|
|
452
|
+
self.host_lay.addWidget(self._baseline_panel(*base))
|
|
453
|
+
elif kind == _SCENE:
|
|
454
|
+
facts = self._donor_scene_facts() # the donor's current rules + counts
|
|
455
|
+
if facts is not None:
|
|
456
|
+
self.host_lay.addWidget(self._facts_panel("Donor scene (the fork you're tuning)", facts))
|
|
457
|
+
elif kind == _AIPHASE:
|
|
458
|
+
ai = self._donor_ai_facts() # the entry/tag + attack indices the form needs
|
|
459
|
+
if ai is not None:
|
|
460
|
+
self.host_lay.addWidget(self._ai_facts_panel(*ai))
|
|
461
|
+
elif kind in (_AIPATCH, _SEQPATCH): # a "Browse sites…" picker fills the offset + guard
|
|
462
|
+
self.host_lay.addWidget(self._sites_panel(kind))
|
|
463
|
+
form, getters = build_form(spec, forms.entity_to_values(spec, entity), self.pal)
|
|
464
|
+
self.host_lay.addWidget(form)
|
|
465
|
+
self.host_lay.addStretch(1)
|
|
466
|
+
self._ctx = {"kind": kind, "idx": idx, "spec": spec, "getters": getters}
|
|
467
|
+
self.save_btn.setEnabled(True)
|
|
468
|
+
|
|
469
|
+
# ------------------------------------------------------------------ donor baseline (read-only)
|
|
470
|
+
def _donor_scene_path(self):
|
|
471
|
+
"""The forked scene's raw16 (the donor enemy stats), or None for an override/repoint battle.toml that
|
|
472
|
+
has no forked ``scene/`` dir. Mirrors ``BattleProject.scene_dir`` = the toml's folder / ``scene``."""
|
|
473
|
+
if not self.path:
|
|
474
|
+
return None
|
|
475
|
+
return self.path.parent / "scene" / "dbfile0000.raw16.bytes"
|
|
476
|
+
|
|
477
|
+
def _donor_baseline(self, enemy):
|
|
478
|
+
p = self._donor_scene_path()
|
|
479
|
+
if not p or not p.is_file():
|
|
480
|
+
return None
|
|
481
|
+
try:
|
|
482
|
+
return donor_baseline(p.read_bytes(), enemy)
|
|
483
|
+
except OSError:
|
|
484
|
+
return None
|
|
485
|
+
|
|
486
|
+
def _donor_scene_facts(self):
|
|
487
|
+
p = self._donor_scene_path()
|
|
488
|
+
if not p or not p.is_file():
|
|
489
|
+
return None
|
|
490
|
+
try:
|
|
491
|
+
return donor_scene_facts(p.read_bytes())
|
|
492
|
+
except OSError:
|
|
493
|
+
return None
|
|
494
|
+
|
|
495
|
+
def _donor_ai_facts(self):
|
|
496
|
+
p = self._donor_scene_path() # scene/dbfile0000.raw16.bytes -> the scene/ dir
|
|
497
|
+
if not p or not p.is_file():
|
|
498
|
+
return None
|
|
499
|
+
eb = p.parent / "eb" / "us.eb.bytes"
|
|
500
|
+
if not eb.is_file():
|
|
501
|
+
return None
|
|
502
|
+
mes = p.parent / "mes" / "us.mes"
|
|
503
|
+
try:
|
|
504
|
+
return donor_ai_facts(eb.read_bytes(), p.read_bytes(), mes.read_bytes() if mes.is_file() else None)
|
|
505
|
+
except OSError:
|
|
506
|
+
return None
|
|
507
|
+
|
|
508
|
+
# ------------------------------------------------------------------ same-length patch sites (read-only picker)
|
|
509
|
+
def _donor_patch_blob(self, kind):
|
|
510
|
+
"""The forked-scene source a same-length patch ADDRESSES: the AI ``.eb`` (ai_patch) / the raw17
|
|
511
|
+
(seq_patch). None for a non-mint override (no forked ``scene/`` dir)."""
|
|
512
|
+
sp = self._donor_scene_path() # scene/dbfile0000.raw16.bytes -> the scene/ dir
|
|
513
|
+
if not sp:
|
|
514
|
+
return None
|
|
515
|
+
sd = sp.parent
|
|
516
|
+
return sd / "eb" / "us.eb.bytes" if kind == _AIPATCH else sd / "btlseq.raw17.bytes"
|
|
517
|
+
|
|
518
|
+
def _donor_patch_sites(self, kind):
|
|
519
|
+
"""``[(offset, value, where, lo, hi[, seq])]`` the patch form can cite, or None (no forked scene /
|
|
520
|
+
unparsable bytes). Wraps the pure :func:`ai_patch_sites` / :func:`seq_patch_sites` with the file read."""
|
|
521
|
+
p = self._donor_patch_blob(kind)
|
|
522
|
+
if not p or not p.is_file():
|
|
523
|
+
return None
|
|
524
|
+
try:
|
|
525
|
+
blob = p.read_bytes()
|
|
526
|
+
except OSError:
|
|
527
|
+
return None
|
|
528
|
+
return ai_patch_sites(blob) if kind == _AIPATCH else seq_patch_sites(blob)
|
|
529
|
+
|
|
530
|
+
def _sites_panel(self, kind):
|
|
531
|
+
"""A read-only header above the patch form: how many sites the fork exposes + a 'Browse sites…' button
|
|
532
|
+
that fills Offset + Current value (so the user never needs `battle-ai`/`battle-seq --sites`)."""
|
|
533
|
+
which = "AI constants" if kind == _AIPATCH else "sequence operands"
|
|
534
|
+
box = QGroupBox("Donor sites — pick an offset to patch")
|
|
535
|
+
v = QVBoxLayout(box)
|
|
536
|
+
v.setContentsMargins(8, 4, 8, 4)
|
|
537
|
+
v.setSpacing(4)
|
|
538
|
+
sites = self._donor_patch_sites(kind)
|
|
539
|
+
if sites is None:
|
|
540
|
+
note = QLabel("No forked scene to read — a same-length patch only applies to a MINTED fork (re-fork "
|
|
541
|
+
"WITH a Fork scene). You can still type an offset by hand, but it won't take effect here.")
|
|
542
|
+
note.setWordWrap(True)
|
|
543
|
+
note.setStyleSheet(f"color:{self.pal['warn']};")
|
|
544
|
+
v.addWidget(note)
|
|
545
|
+
return box
|
|
546
|
+
row = QHBoxLayout()
|
|
547
|
+
lbl = QLabel(f"{len(sites)} patchable {which} in this fork.")
|
|
548
|
+
lbl.setStyleSheet(f"color:{self.pal['muted']};")
|
|
549
|
+
row.addWidget(lbl, 1)
|
|
550
|
+
btn = QPushButton("Browse sites…")
|
|
551
|
+
btn.setEnabled(bool(sites))
|
|
552
|
+
btn.clicked.connect(lambda: self._browse_sites(kind))
|
|
553
|
+
row.addWidget(btn)
|
|
554
|
+
v.addLayout(row)
|
|
555
|
+
return box
|
|
556
|
+
|
|
557
|
+
def _browse_sites(self, kind):
|
|
558
|
+
"""Pick a donor site → fill the current patch form's Offset (``at``) + Current value (``old``) guard
|
|
559
|
+
(and, for seq, the owning ``seq``). Commits the user's typed ``new`` first, then remounts the form."""
|
|
560
|
+
if not self._ctx or self._ctx["kind"] != kind:
|
|
561
|
+
return
|
|
562
|
+
sites = self._donor_patch_sites(kind)
|
|
563
|
+
if not sites:
|
|
564
|
+
return
|
|
565
|
+
rows, by_disp = [], {}
|
|
566
|
+
for s in sites:
|
|
567
|
+
offset, value, where, lo, hi = s[0], s[1], s[2], s[3], s[4]
|
|
568
|
+
disp = f"@{offset} · now {value} · {where} · {lo}–{hi}"
|
|
569
|
+
rows.append(disp)
|
|
570
|
+
by_disp[disp] = s
|
|
571
|
+
chosen = self._choose("Patchable sites", rows)
|
|
572
|
+
if not chosen:
|
|
573
|
+
return
|
|
574
|
+
s = by_disp[chosen]
|
|
575
|
+
idx = self._ctx["idx"]
|
|
576
|
+
if not self._commit_active(): # fold the user's typed `new` first -- but if it's
|
|
577
|
+
self._post(["Fix the highlighted value before browsing sites."], [], "Browse sites")
|
|
578
|
+
return # invalid, bail (don't silently revert it to default)
|
|
579
|
+
tgt = self._target(kind, idx)
|
|
580
|
+
if tgt is None:
|
|
581
|
+
return
|
|
582
|
+
tgt["at"], tgt["old"] = int(s[0]), int(s[1])
|
|
583
|
+
if kind == _SEQPATCH and len(s) > 5: # default the owning-attack cross-check
|
|
584
|
+
tgt["seq"] = int(s[5])
|
|
585
|
+
self._rebuild_nodes() # the node label shows at / old
|
|
586
|
+
self.nodes.blockSignals(True) # restore the highlight WITHOUT re-committing the
|
|
587
|
+
self._select_node(kind, idx) # (now stale) old form's widgets over our new values
|
|
588
|
+
self.nodes.blockSignals(False)
|
|
589
|
+
spec = bf.AI_PATCH_SPEC if kind == _AIPATCH else bf.SEQ_PATCH_SPEC
|
|
590
|
+
self._mount(kind, idx, spec, tgt) # remount fresh so the form shows the filled offset/old
|
|
591
|
+
|
|
592
|
+
def _ai_facts_panel(self, attacks, ai_funcs):
|
|
593
|
+
import html
|
|
594
|
+
box = QGroupBox("Donor AI (this fork) — indices for the form below")
|
|
595
|
+
v = QVBoxLayout(box)
|
|
596
|
+
v.setContentsMargins(8, 4, 8, 4)
|
|
597
|
+
v.setSpacing(3)
|
|
598
|
+
enrage = [f"entry {e}, function {t}" for (e, _ty, t, _r, n) in ai_funcs if n == 1] # exactly one Attack
|
|
599
|
+
e_txt = " · ".join(enrage) if enrage else "none — no AI function has exactly one Attack (use ai_insert)"
|
|
600
|
+
e_lbl = QLabel(f"<b>Enrage-able</b> → set <b>Enemy AI entry</b> / <b>AI function</b> to: {html.escape(e_txt)}")
|
|
601
|
+
e_lbl.setWordWrap(True)
|
|
602
|
+
e_lbl.setStyleSheet(f"color:{self.pal['muted']};")
|
|
603
|
+
v.addWidget(e_lbl)
|
|
604
|
+
if attacks:
|
|
605
|
+
atk = " · ".join(f"{i}={html.escape(str(nm))}" for i, nm in attacks)
|
|
606
|
+
a_lbl = QLabel(f"<b>Attacks</b> (then / else): {atk}")
|
|
607
|
+
a_lbl.setWordWrap(True)
|
|
608
|
+
a_lbl.setStyleSheet(f"color:{self.pal['muted']};")
|
|
609
|
+
v.addWidget(a_lbl)
|
|
610
|
+
other = [f"entry {e} fn {t} ({n} atk)" for (e, _ty, t, _r, n) in ai_funcs if n != 1]
|
|
611
|
+
if other:
|
|
612
|
+
o_lbl = QLabel(f"other AI funcs: {html.escape(' · '.join(other))}")
|
|
613
|
+
o_lbl.setWordWrap(True)
|
|
614
|
+
o_lbl.setStyleSheet(f"color:{self.pal['muted']};font-size:11px;")
|
|
615
|
+
v.addWidget(o_lbl)
|
|
616
|
+
return box
|
|
617
|
+
|
|
618
|
+
def _baseline_panel(self, type_no, pairs):
|
|
619
|
+
return self._facts_panel(f"Donor baseline — enemy type {type_no} (the forked stats you're tuning from)", pairs)
|
|
620
|
+
|
|
621
|
+
def _facts_panel(self, title, pairs, per_row=6):
|
|
622
|
+
"""A read-only grid of (label, value) facts in a titled box (the donor enemy baseline / scene rules)."""
|
|
623
|
+
box = QGroupBox(title)
|
|
624
|
+
grid = QGridLayout(box)
|
|
625
|
+
grid.setContentsMargins(8, 4, 8, 4)
|
|
626
|
+
grid.setHorizontalSpacing(16)
|
|
627
|
+
grid.setVerticalSpacing(2)
|
|
628
|
+
for i, (label, val) in enumerate(pairs):
|
|
629
|
+
r, c = divmod(i, per_row)
|
|
630
|
+
cell = QLabel(f"{label} <b>{val}</b>")
|
|
631
|
+
cell.setStyleSheet(f"color:{self.pal['muted']};")
|
|
632
|
+
grid.addWidget(cell, r, c)
|
|
633
|
+
return box
|
|
634
|
+
|
|
635
|
+
def _target(self, kind, idx):
|
|
636
|
+
"""The dict a (kind, idx) node edits, or None if the index is out of range (a stale _ctx after its row
|
|
637
|
+
was removed) -- so the shared commit primitive can no-op instead of raising."""
|
|
638
|
+
if kind == _MAP:
|
|
639
|
+
return self.data.setdefault("battlemap", {})
|
|
640
|
+
if kind == _SCENE:
|
|
641
|
+
return self.data.setdefault("scene", {})
|
|
642
|
+
getter = {_ENEMY: self._enemies, _AIPHASE: self._ai_phases,
|
|
643
|
+
_AIPATCH: self._ai_patches, _SEQPATCH: self._seq_patches}.get(kind)
|
|
644
|
+
lst = getter() if getter else (self.data.get(kind) if kind in bf.PLAYER_SPECS else None)
|
|
645
|
+
return lst[idx] if isinstance(lst, list) and 0 <= idx < len(lst) else None
|
|
646
|
+
|
|
647
|
+
def _fold(self, ctx) -> bool:
|
|
648
|
+
"""Apply the form's values to its target dict in place (pop the spec keys, keep any non-spec keys --
|
|
649
|
+
e.g. the [scene] form must not drop the enemy list). Returns False on an invalid value / stale target."""
|
|
650
|
+
try:
|
|
651
|
+
entity = forms.build_entity(ctx["spec"], read(ctx["getters"]))
|
|
652
|
+
except ValueError:
|
|
653
|
+
return False
|
|
654
|
+
tgt = self._target(ctx["kind"], ctx["idx"])
|
|
655
|
+
if tgt is None: # the row this form pointed at is gone -> nothing to commit
|
|
656
|
+
return False
|
|
657
|
+
for f in ctx["spec"]:
|
|
658
|
+
tgt.pop(f.key, None)
|
|
659
|
+
tgt.update(entity)
|
|
660
|
+
return True
|
|
661
|
+
|
|
662
|
+
def _commit_active(self) -> bool:
|
|
663
|
+
return self._fold(self._ctx) if self._ctx else True
|
|
664
|
+
|
|
665
|
+
# ------------------------------------------------------------------ save / add / check
|
|
666
|
+
def _save(self):
|
|
667
|
+
if not self._ctx:
|
|
668
|
+
return
|
|
669
|
+
if not self._fold(self._ctx):
|
|
670
|
+
self._post(["Invalid value — not saved (fix the highlighted field)."], [], "Save")
|
|
671
|
+
return
|
|
672
|
+
if not self._write():
|
|
673
|
+
return
|
|
674
|
+
self._rebuild_nodes() # a slot's number may have changed
|
|
675
|
+
if self._ctx: # re-highlight the saved row + re-arm Remove (clear()
|
|
676
|
+
self._select_node(self._ctx["kind"], self._ctx["idx"]) # left currentRow at -1 with del_btn stale)
|
|
677
|
+
self._post([], [], "Save", clean=f"Saved {self.path.name}")
|
|
678
|
+
|
|
679
|
+
def _add_enemy(self):
|
|
680
|
+
if not self.data:
|
|
681
|
+
return
|
|
682
|
+
self._commit_active()
|
|
683
|
+
enemies = self.data.setdefault("scene", {}).setdefault("enemy", [])
|
|
684
|
+
used = {e.get("slot") for e in enemies}
|
|
685
|
+
enemies.append({"slot": next((s for s in range(4) if s not in used), len(enemies))})
|
|
686
|
+
self._rebuild_nodes()
|
|
687
|
+
# land on the new enemy's form (the last ENEMY row, before any player header/rows)
|
|
688
|
+
self._select_node(_ENEMY, len(enemies) - 1)
|
|
689
|
+
|
|
690
|
+
def _add_ai_phase(self):
|
|
691
|
+
if not self.data:
|
|
692
|
+
return
|
|
693
|
+
self._commit_active()
|
|
694
|
+
phases = self.data.setdefault("scene", {}).setdefault("ai_phase", [])
|
|
695
|
+
phases.append({"entry": 1, "tag": 5, "stat": "hp", "below": 0.5, "then": 1, "else": 0})
|
|
696
|
+
self._rebuild_nodes()
|
|
697
|
+
self._select_node(_AIPHASE, len(phases) - 1)
|
|
698
|
+
|
|
699
|
+
def _add_patch(self):
|
|
700
|
+
if not self.data:
|
|
701
|
+
return
|
|
702
|
+
self._commit_active()
|
|
703
|
+
kind = self._pick_patch_kind()
|
|
704
|
+
if not kind:
|
|
705
|
+
return
|
|
706
|
+
lst = self.data.setdefault("scene", {}).setdefault(kind, [])
|
|
707
|
+
lst.append({"at": 0, "old": 0, "new": 0}) # Browse sites… fills at/old against the donor
|
|
708
|
+
self._rebuild_nodes()
|
|
709
|
+
self._select_node(kind, len(lst) - 1) # land on the new patch's form (+ its Browse panel)
|
|
710
|
+
|
|
711
|
+
def _pick_patch_kind(self):
|
|
712
|
+
"""A small dialog to choose AI-constant vs sequence patch. Returns the kind key, or None."""
|
|
713
|
+
dlg = QDialog(self)
|
|
714
|
+
dlg.setWindowTitle("Add a same-length patch")
|
|
715
|
+
lay = QVBoxLayout(dlg)
|
|
716
|
+
lbl = QLabel("Which same-length patch? Both cite a byte offset (use 'Browse sites…' on the form) and "
|
|
717
|
+
"guard on the value there now — mint-only, applied to the forked scene.")
|
|
718
|
+
lbl.setWordWrap(True)
|
|
719
|
+
lay.addWidget(lbl)
|
|
720
|
+
combo = QComboBox()
|
|
721
|
+
combo.addItem("AI constant · [[scene.ai_patch]] (HP threshold / attack index / Wait)", _AIPATCH)
|
|
722
|
+
combo.addItem("Choreography · [[scene.seq_patch]] (a Wait / Anim / Camera operand)", _SEQPATCH)
|
|
723
|
+
lay.addWidget(combo)
|
|
724
|
+
bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
|
725
|
+
bb.accepted.connect(dlg.accept)
|
|
726
|
+
bb.rejected.connect(dlg.reject)
|
|
727
|
+
lay.addWidget(bb)
|
|
728
|
+
if dlg.exec() != QDialog.DialogCode.Accepted:
|
|
729
|
+
return None
|
|
730
|
+
return combo.currentData()
|
|
731
|
+
|
|
732
|
+
def _add_player(self):
|
|
733
|
+
if not self.data:
|
|
734
|
+
return
|
|
735
|
+
self._commit_active()
|
|
736
|
+
key = self._pick_player_table()
|
|
737
|
+
if not key:
|
|
738
|
+
return
|
|
739
|
+
self.data.setdefault(key, []).append(dict(bf.PLAYER_DEFAULT[key]))
|
|
740
|
+
self._rebuild_nodes()
|
|
741
|
+
self._select_node(key, len(self.data[key]) - 1) # land on the new row's form
|
|
742
|
+
|
|
743
|
+
def _select_node(self, kind, idx):
|
|
744
|
+
for r, node in enumerate(self._nodes):
|
|
745
|
+
if node == (kind, idx):
|
|
746
|
+
self.nodes.setCurrentRow(r)
|
|
747
|
+
return
|
|
748
|
+
|
|
749
|
+
# ------------------------------------------------------------------ delete a list row
|
|
750
|
+
def _delete_selected(self):
|
|
751
|
+
"""Remove the selected list row (enemy / ai_phase / ai_patch / seq_patch / a player-table row) after a
|
|
752
|
+
confirm. In-memory like the Add actions — persisted on Save. Map / Formation / headers are not removable."""
|
|
753
|
+
row = self.nodes.currentRow()
|
|
754
|
+
if not (0 <= row < len(self._nodes)):
|
|
755
|
+
return
|
|
756
|
+
kind, idx = self._nodes[row]
|
|
757
|
+
if not self._deletable(kind):
|
|
758
|
+
return
|
|
759
|
+
if not self._confirm_delete(self.nodes.item(row).text()):
|
|
760
|
+
return
|
|
761
|
+
if not self._delete_node(kind, idx): # bad index (parallel lists drifted) -> keep the form
|
|
762
|
+
return
|
|
763
|
+
self._ctx = None # success: the mounted form's row is gone -> don't commit it
|
|
764
|
+
self._rebuild_nodes()
|
|
765
|
+
siblings = [r for r, (k, _i) in enumerate(self._nodes) if k == kind]
|
|
766
|
+
target = siblings[min(idx, len(siblings) - 1)] if siblings else 0 # a remaining sibling, else Map
|
|
767
|
+
if 0 <= target < self.nodes.count():
|
|
768
|
+
self.nodes.setCurrentRow(target) # -> _on_node mounts it (or Map) + re-arms del_btn
|
|
769
|
+
|
|
770
|
+
def _delete_node(self, kind, idx) -> bool:
|
|
771
|
+
"""Drop ``(kind, idx)`` from its backing list, popping an emptied container key so the saved TOML stays
|
|
772
|
+
clean (no ``ai_phase = []``). Returns False on a bad kind/index."""
|
|
773
|
+
if kind == _ENEMY:
|
|
774
|
+
lst, scene_key = self._enemies(), "enemy"
|
|
775
|
+
elif kind == _AIPHASE:
|
|
776
|
+
lst, scene_key = self._ai_phases(), "ai_phase"
|
|
777
|
+
elif kind == _AIPATCH:
|
|
778
|
+
lst, scene_key = self._ai_patches(), "ai_patch"
|
|
779
|
+
elif kind == _SEQPATCH:
|
|
780
|
+
lst, scene_key = self._seq_patches(), "seq_patch"
|
|
781
|
+
elif kind in bf.PLAYER_SPECS:
|
|
782
|
+
lst, scene_key = (self.data.get(kind) or []), None
|
|
783
|
+
else:
|
|
784
|
+
return False
|
|
785
|
+
if not (0 <= idx < len(lst)):
|
|
786
|
+
return False
|
|
787
|
+
del lst[idx]
|
|
788
|
+
if scene_key is not None: # a [scene] sub-table: pop it from scene when emptied
|
|
789
|
+
scene = self.data.get("scene") or {}
|
|
790
|
+
if scene_key in scene and not scene[scene_key]:
|
|
791
|
+
scene.pop(scene_key, None)
|
|
792
|
+
if isinstance(scene, dict) and not scene: # the last sub-table gone + no scalars -> drop empty [scene]
|
|
793
|
+
self.data.pop("scene", None)
|
|
794
|
+
elif kind in self.data and not self.data[kind]: # a top-level player table: pop the [[<kind>]] array
|
|
795
|
+
self.data.pop(kind, None)
|
|
796
|
+
return True
|
|
797
|
+
|
|
798
|
+
def _confirm_delete(self, label) -> bool:
|
|
799
|
+
r = QMessageBox.question(self, "Remove", f"Remove “{label.strip()}”?\n\n(Applied when you Save.)",
|
|
800
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
801
|
+
QMessageBox.StandardButton.No)
|
|
802
|
+
return r == QMessageBox.StandardButton.Yes
|
|
803
|
+
|
|
804
|
+
def _pick_player_table(self):
|
|
805
|
+
"""A small dialog to pick which player/ability table to add a row to. Returns its key, or None."""
|
|
806
|
+
dlg = QDialog(self)
|
|
807
|
+
dlg.setWindowTitle("Add party / ability tuning")
|
|
808
|
+
lay = QVBoxLayout(dlg)
|
|
809
|
+
lbl = QLabel("Tune which player-side table? It's mod-GLOBAL — deployed with this battle and applied "
|
|
810
|
+
"to the whole game.")
|
|
811
|
+
lbl.setWordWrap(True)
|
|
812
|
+
lay.addWidget(lbl)
|
|
813
|
+
combo = QComboBox()
|
|
814
|
+
for key, label, *_ in bf.PLAYER_TABLES:
|
|
815
|
+
combo.addItem(label, key)
|
|
816
|
+
lay.addWidget(combo)
|
|
817
|
+
bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
|
818
|
+
bb.accepted.connect(dlg.accept)
|
|
819
|
+
bb.rejected.connect(dlg.reject)
|
|
820
|
+
lay.addWidget(bb)
|
|
821
|
+
if dlg.exec() != QDialog.DialogCode.Accepted:
|
|
822
|
+
return None
|
|
823
|
+
return combo.currentData()
|
|
824
|
+
|
|
825
|
+
def _write(self) -> bool:
|
|
826
|
+
try: # a battle.toml: [scene] is a big FORMATION table, NOT
|
|
827
|
+
text = _model.dumps(self.data, inline_table_keys=frozenset(), # the field.toml inline Blender-ref --
|
|
828
|
+
root_order=("battlemap", "scene")) # so emit real [scene]/[[scene.enemy]]
|
|
829
|
+
self.path.write_text(text, encoding="utf-8", newline="\n") # sections (+ lead with the map id)
|
|
830
|
+
return True
|
|
831
|
+
except Exception as e: # noqa: BLE001
|
|
832
|
+
self._post([f"Save failed: {e}"], [], "Save")
|
|
833
|
+
return False
|
|
834
|
+
|
|
835
|
+
def _check(self):
|
|
836
|
+
if not self.path:
|
|
837
|
+
return
|
|
838
|
+
self._commit_active() # validate WHAT'S SHOWN; Check does NOT persist (Save is
|
|
839
|
+
errs = [] # the only writer -- so "applied on Save" stays true)
|
|
840
|
+
try:
|
|
841
|
+
from ..battle.build import BattleProject, validate_battle
|
|
842
|
+
errs = list(validate_battle(BattleProject(self.data, self.path.parent))) # the in-memory dict
|
|
843
|
+
except Exception as e: # noqa: BLE001
|
|
844
|
+
errs = [f"{type(e).__name__}: {e}"]
|
|
845
|
+
self._post(errs, [], f"Check {self.path.name}", clean=f"{self.path.name} — no problems")
|
|
846
|
+
|
|
847
|
+
def _post(self, errs, warns, subject, clean=None):
|
|
848
|
+
"""Route a Check/Save result to the Problems dock (verdict + rows), or the Output console if undocked."""
|
|
849
|
+
errs, warns = list(errs), list(warns)
|
|
850
|
+
if self._problems is not None:
|
|
851
|
+
v = fb.classify(errs, warns, subject=subject, clean_headline=clean or f"{subject} — OK")
|
|
852
|
+
self._problems(v, fb.problems(errs, warns))
|
|
853
|
+
elif self._output is not None:
|
|
854
|
+
body = "\n".join(errs + warns)
|
|
855
|
+
self._output(f"{clean or subject}{(chr(10) + body) if body else ''}\n")
|
|
856
|
+
|
|
857
|
+
# ------------------------------------------------------------------ fork a real battle background
|
|
858
|
+
def _fork_argv(self, bbg, out, fork_scene=None):
|
|
859
|
+
"""The ``ff9mapkit battle-import`` argv that forks a real BBG's geometry into an editable battle.toml."""
|
|
860
|
+
a = [sys.executable, "-m", "ff9mapkit", "battle-import", str(bbg), "--out", str(out)]
|
|
861
|
+
if fork_scene:
|
|
862
|
+
a += ["--fork-scene", str(fork_scene)]
|
|
863
|
+
return a
|
|
864
|
+
|
|
865
|
+
def _run_fork(self, bbg, out, fork_scene=None):
|
|
866
|
+
"""Shell out battle-import (streams to the Output dock) and AUTO-OPEN the result on success."""
|
|
867
|
+
if not self._run or not self.kit:
|
|
868
|
+
return
|
|
869
|
+
Path(out).mkdir(parents=True, exist_ok=True)
|
|
870
|
+
self._run(self._fork_argv(bbg, out, fork_scene), cwd=self.kit, subject=f"Fork battle {bbg}",
|
|
871
|
+
ok_headline=f"Forked {bbg} → {out}", ok_next="Opening the new battle.toml…",
|
|
872
|
+
fail_hint="Forking a battle needs UnityPy + your FF9 install (like forking a field).",
|
|
873
|
+
on_finished=lambda code: self._after_fork(code, out))
|
|
874
|
+
|
|
875
|
+
def _after_fork(self, code, out):
|
|
876
|
+
"""battle-import done -> open the battle.toml it wrote (only on a clean exit)."""
|
|
877
|
+
toml = Path(out) / "battle.toml"
|
|
878
|
+
if code == 0 and toml.is_file():
|
|
879
|
+
self.load(str(toml))
|
|
880
|
+
|
|
881
|
+
def _pick_out(self, line_edit):
|
|
882
|
+
d = QFileDialog.getExistingDirectory(self, "Folder to write the battle into")
|
|
883
|
+
if d:
|
|
884
|
+
line_edit.setText(d)
|
|
885
|
+
|
|
886
|
+
@staticmethod
|
|
887
|
+
def _browse_row(line_edit, on_browse):
|
|
888
|
+
"""A line edit + a 'Browse…' button in one row (for the install-gated BBG / scene pickers)."""
|
|
889
|
+
row = QWidget()
|
|
890
|
+
h = QHBoxLayout(row)
|
|
891
|
+
h.setContentsMargins(0, 0, 0, 0)
|
|
892
|
+
h.addWidget(line_edit, 1)
|
|
893
|
+
b = QPushButton("Browse…")
|
|
894
|
+
b.clicked.connect(on_browse)
|
|
895
|
+
h.addWidget(b)
|
|
896
|
+
return row
|
|
897
|
+
|
|
898
|
+
def _pick_install_list(self, title, loader, target, cache_key):
|
|
899
|
+
"""Browse an INSTALL-gated list (BBGs / battle scenes, read from p0data via UnityPy) into ``target``.
|
|
900
|
+
Reads the install on first use (a brief wait), cached per session; a clean warning if the install /
|
|
901
|
+
UnityPy is absent (so a no-install workstation degrades gracefully instead of tracing back)."""
|
|
902
|
+
from PySide6.QtCore import Qt
|
|
903
|
+
from PySide6.QtWidgets import QApplication
|
|
904
|
+
rows = self._install_lists.get(cache_key)
|
|
905
|
+
if rows is None:
|
|
906
|
+
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
|
907
|
+
try:
|
|
908
|
+
rows = list(loader())
|
|
909
|
+
except Exception as e: # noqa: BLE001 -- no install / no UnityPy / read error
|
|
910
|
+
QApplication.restoreOverrideCursor()
|
|
911
|
+
return QMessageBox.warning(self, title, f"Couldn't read {title.lower()} — forking a battle "
|
|
912
|
+
f"needs UnityPy + your FF9 install.\n\n{type(e).__name__}: {e}")
|
|
913
|
+
finally:
|
|
914
|
+
QApplication.restoreOverrideCursor()
|
|
915
|
+
self._install_lists[cache_key] = rows
|
|
916
|
+
if not rows:
|
|
917
|
+
return QMessageBox.information(self, title, f"No {title.lower()} found in your install.")
|
|
918
|
+
name = self._choose(title, rows)
|
|
919
|
+
if name:
|
|
920
|
+
target.setText(name)
|
|
921
|
+
|
|
922
|
+
def _choose(self, title, rows):
|
|
923
|
+
"""A simple searchable single-pick list dialog over ``rows`` (names); the chosen name, or None."""
|
|
924
|
+
dlg = QDialog(self)
|
|
925
|
+
dlg.setWindowTitle(title)
|
|
926
|
+
dlg.resize(360, 460)
|
|
927
|
+
lay = QVBoxLayout(dlg)
|
|
928
|
+
q = QLineEdit()
|
|
929
|
+
q.setPlaceholderText("Filter…")
|
|
930
|
+
lay.addWidget(q)
|
|
931
|
+
lst = QListWidget()
|
|
932
|
+
lst.addItems(rows)
|
|
933
|
+
lay.addWidget(lst, 1)
|
|
934
|
+
q.textChanged.connect(lambda t: (lst.clear(), lst.addItems([r for r in rows if t.lower() in r.lower()])))
|
|
935
|
+
lst.itemDoubleClicked.connect(lambda _i: dlg.accept())
|
|
936
|
+
bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
|
937
|
+
bb.accepted.connect(dlg.accept)
|
|
938
|
+
bb.rejected.connect(dlg.reject)
|
|
939
|
+
lay.addWidget(bb)
|
|
940
|
+
if dlg.exec() != QDialog.DialogCode.Accepted:
|
|
941
|
+
return None
|
|
942
|
+
it = lst.currentItem()
|
|
943
|
+
return it.text() if it else None
|
|
944
|
+
|
|
945
|
+
def _fork_dialog(self):
|
|
946
|
+
if not self._run or not self.kit:
|
|
947
|
+
return
|
|
948
|
+
from ..battle import extract as _ex
|
|
949
|
+
dlg = QDialog(self)
|
|
950
|
+
dlg.setWindowTitle("Fork a battle background")
|
|
951
|
+
form = QFormLayout(dlg)
|
|
952
|
+
bbg = QLineEdit()
|
|
953
|
+
bbg.setPlaceholderText("BBG_B013 (Browse… to pick from your install)")
|
|
954
|
+
form.addRow("Background (BBG)", self._browse_row(
|
|
955
|
+
bbg, lambda: self._pick_install_list("Battle backgrounds", _ex.list_battle_maps, bbg, "bbg")))
|
|
956
|
+
donor = QLineEdit()
|
|
957
|
+
donor.setPlaceholderText("optional — e.g. EF_R007 (mints a brand-new, separately-triggerable scene)")
|
|
958
|
+
form.addRow("Fork scene", self._browse_row(
|
|
959
|
+
donor, lambda: self._pick_install_list("Battle scenes", _ex.list_battle_scenes, donor, "scene")))
|
|
960
|
+
outrow = QWidget()
|
|
961
|
+
oh = QHBoxLayout(outrow)
|
|
962
|
+
oh.setContentsMargins(0, 0, 0, 0)
|
|
963
|
+
out = QLineEdit(str(Path.home() / "ff9field" / "fight"))
|
|
964
|
+
browse = QPushButton("Browse…")
|
|
965
|
+
browse.clicked.connect(lambda: self._pick_out(out))
|
|
966
|
+
oh.addWidget(out, 1)
|
|
967
|
+
oh.addWidget(browse)
|
|
968
|
+
form.addRow("Write to", outrow)
|
|
969
|
+
hint = QLabel("Forks the real BBG's geometry into an editable battle.toml (needs UnityPy + your FF9 "
|
|
970
|
+
"install). A bare BBG OVERRIDES that real map; add a Fork scene to mint a new one. The "
|
|
971
|
+
"result opens here when it's done.")
|
|
972
|
+
hint.setWordWrap(True)
|
|
973
|
+
hint.setStyleSheet(f"color:{self.pal['muted']};")
|
|
974
|
+
form.addRow(hint)
|
|
975
|
+
bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
|
976
|
+
bb.accepted.connect(dlg.accept)
|
|
977
|
+
bb.rejected.connect(dlg.reject)
|
|
978
|
+
form.addRow(bb)
|
|
979
|
+
if dlg.exec() != QDialog.DialogCode.Accepted:
|
|
980
|
+
return
|
|
981
|
+
b, o = bbg.text().strip(), out.text().strip()
|
|
982
|
+
if not b or not o:
|
|
983
|
+
QMessageBox.warning(self, "Fork battle", "Enter a BBG name and an output folder.")
|
|
984
|
+
return
|
|
985
|
+
self._run_fork(b, o, donor.text().strip() or None)
|