ff9mapkit 1.0.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. ff9mapkit/__init__.py +18 -0
  2. ff9mapkit/__main__.py +36 -0
  3. ff9mapkit/_animdb.py +2994 -0
  4. ff9mapkit/_animdb_all.py +14125 -0
  5. ff9mapkit/_fieldtable.py +1516 -0
  6. ff9mapkit/_fieldtext.py +845 -0
  7. ff9mapkit/_held_poses.py +44 -0
  8. ff9mapkit/_itemdb.py +65 -0
  9. ff9mapkit/_modeldb.py +725 -0
  10. ff9mapkit/_narrowmap_data.py +10 -0
  11. ff9mapkit/_npcparams.py +634 -0
  12. ff9mapkit/_regen_animdb.py +72 -0
  13. ff9mapkit/_regen_animdb_all.py +66 -0
  14. ff9mapkit/_regen_fieldtable.py +95 -0
  15. ff9mapkit/_regen_fieldtext.py +66 -0
  16. ff9mapkit/_regen_modeldb.py +67 -0
  17. ff9mapkit/_regen_npcparams.py +123 -0
  18. ff9mapkit/_regen_scenedb.py +57 -0
  19. ff9mapkit/_scenedb.py +869 -0
  20. ff9mapkit/abilities.py +225 -0
  21. ff9mapkit/animations.py +120 -0
  22. ff9mapkit/archetypes.py +218 -0
  23. ff9mapkit/areatitle.py +76 -0
  24. ff9mapkit/battle/__init__.py +21 -0
  25. ff9mapkit/battle/abilityfeatures.py +294 -0
  26. ff9mapkit/battle/actiondelta.py +441 -0
  27. ff9mapkit/battle/aiauthor.py +305 -0
  28. ff9mapkit/battle/ailint.py +140 -0
  29. ff9mapkit/battle/aipatch.py +175 -0
  30. ff9mapkit/battle/battleai.py +148 -0
  31. ff9mapkit/battle/battlecsv.py +390 -0
  32. ff9mapkit/battle/battlepatch.py +395 -0
  33. ff9mapkit/battle/build.py +558 -0
  34. ff9mapkit/battle/camera_codec.py +332 -0
  35. ff9mapkit/battle/camera_data.py +128 -0
  36. ff9mapkit/battle/characterdelta.py +789 -0
  37. ff9mapkit/battle/event_data.py +72 -0
  38. ff9mapkit/battle/extract.py +540 -0
  39. ff9mapkit/battle/fbx.py +223 -0
  40. ff9mapkit/battle/reskin.py +149 -0
  41. ff9mapkit/battle/scene_codec.py +314 -0
  42. ff9mapkit/battle/scene_data.py +369 -0
  43. ff9mapkit/battle/scenelint.py +125 -0
  44. ff9mapkit/battle/seqasm.py +131 -0
  45. ff9mapkit/battle/seqauthor.py +220 -0
  46. ff9mapkit/battle/seqcodec.py +300 -0
  47. ff9mapkit/battle/seqdis.py +106 -0
  48. ff9mapkit/battle/seqpatch.py +137 -0
  49. ff9mapkit/battle_bgm.py +133 -0
  50. ff9mapkit/binutils.py +60 -0
  51. ff9mapkit/build.py +5445 -0
  52. ff9mapkit/campaign.py +1276 -0
  53. ff9mapkit/catalog.py +316 -0
  54. ff9mapkit/chain.py +358 -0
  55. ff9mapkit/cli.py +3114 -0
  56. ff9mapkit/config.py +360 -0
  57. ff9mapkit/content/__init__.py +13 -0
  58. ff9mapkit/content/areatitle.py +36 -0
  59. ff9mapkit/content/ate.py +118 -0
  60. ff9mapkit/content/camera.py +123 -0
  61. ff9mapkit/content/chest.py +186 -0
  62. ff9mapkit/content/choice.py +163 -0
  63. ff9mapkit/content/conductor.py +217 -0
  64. ff9mapkit/content/cutscene.py +290 -0
  65. ff9mapkit/content/encounter.py +41 -0
  66. ff9mapkit/content/entry_settle.py +50 -0
  67. ff9mapkit/content/equipment.py +93 -0
  68. ff9mapkit/content/event.py +191 -0
  69. ff9mapkit/content/gateway.py +101 -0
  70. ff9mapkit/content/inventory.py +59 -0
  71. ff9mapkit/content/itemdata.py +644 -0
  72. ff9mapkit/content/itemtext.py +168 -0
  73. ff9mapkit/content/jump.py +114 -0
  74. ff9mapkit/content/ladder.py +633 -0
  75. ff9mapkit/content/movement.py +53 -0
  76. ff9mapkit/content/music.py +97 -0
  77. ff9mapkit/content/npc.py +348 -0
  78. ff9mapkit/content/object.py +340 -0
  79. ff9mapkit/content/onentry.py +135 -0
  80. ff9mapkit/content/party.py +111 -0
  81. ff9mapkit/content/pathfind.py +138 -0
  82. ff9mapkit/content/platform.py +314 -0
  83. ff9mapkit/content/player.py +168 -0
  84. ff9mapkit/content/prop.py +75 -0
  85. ff9mapkit/content/region.py +340 -0
  86. ff9mapkit/content/reinit.py +59 -0
  87. ff9mapkit/content/savepoint.py +90 -0
  88. ff9mapkit/content/shop.py +178 -0
  89. ff9mapkit/content/sps_trigger.py +66 -0
  90. ff9mapkit/content/startup.py +71 -0
  91. ff9mapkit/content/synthesis.py +106 -0
  92. ff9mapkit/content/text.py +183 -0
  93. ff9mapkit/content/textcarry.py +290 -0
  94. ff9mapkit/content/verbatim.py +86 -0
  95. ff9mapkit/content/walkmesh_hotfix.py +38 -0
  96. ff9mapkit/data/__init__.py +48 -0
  97. ff9mapkit/data/_regen_provenance.py +142 -0
  98. ff9mapkit/data/provenance/blank.es.patch +1 -0
  99. ff9mapkit/data/provenance/blank.fr.patch +1 -0
  100. ff9mapkit/data/provenance/blank.gr.patch +1 -0
  101. ff9mapkit/data/provenance/blank.it.patch +1 -0
  102. ff9mapkit/data/provenance/blank.jp.patch +1 -0
  103. ff9mapkit/data/provenance/blank.uk.patch +1 -0
  104. ff9mapkit/data/provenance/blank.us.patch +1 -0
  105. ff9mapkit/data/provenance/manifest.json +65 -0
  106. ff9mapkit/data/provenance/region_template.patch +1 -0
  107. ff9mapkit/data/reference_arcs.toml +89 -0
  108. ff9mapkit/data/region_catalog.toml +593 -0
  109. ff9mapkit/deploystack.py +358 -0
  110. ff9mapkit/dialogue.py +803 -0
  111. ff9mapkit/eb/__init__.py +12 -0
  112. ff9mapkit/eb/_exprtable.py +59 -0
  113. ff9mapkit/eb/_membertable.py +38 -0
  114. ff9mapkit/eb/_optables.py +537 -0
  115. ff9mapkit/eb/_regen_optables.py +76 -0
  116. ff9mapkit/eb/cmdasm.py +323 -0
  117. ff9mapkit/eb/disasm.py +332 -0
  118. ff9mapkit/eb/edit.py +439 -0
  119. ff9mapkit/eb/exprasm.py +158 -0
  120. ff9mapkit/eb/model.py +178 -0
  121. ff9mapkit/eb/opcodes.py +463 -0
  122. ff9mapkit/eblint.py +177 -0
  123. ff9mapkit/editor/__init__.py +20 -0
  124. ff9mapkit/editor/app.py +950 -0
  125. ff9mapkit/editor/battle_forms.py +240 -0
  126. ff9mapkit/editor/breadcrumb.py +89 -0
  127. ff9mapkit/editor/dialogs.py +116 -0
  128. ff9mapkit/editor/feedback.py +208 -0
  129. ff9mapkit/editor/forms.py +632 -0
  130. ff9mapkit/editor/graphview.py +350 -0
  131. ff9mapkit/editor/jobs.py +342 -0
  132. ff9mapkit/editor/model.py +243 -0
  133. ff9mapkit/editor/picker.py +120 -0
  134. ff9mapkit/editor/theme.py +212 -0
  135. ff9mapkit/eventscan.py +1441 -0
  136. ff9mapkit/extract.py +2279 -0
  137. ff9mapkit/flags.py +693 -0
  138. ff9mapkit/forkreport.py +1383 -0
  139. ff9mapkit/hub.py +477 -0
  140. ff9mapkit/idgated.py +101 -0
  141. ff9mapkit/infohub.py +580 -0
  142. ff9mapkit/items.py +63 -0
  143. ff9mapkit/itemstats.py +346 -0
  144. ff9mapkit/journey.py +1902 -0
  145. ff9mapkit/keyitems.py +93 -0
  146. ff9mapkit/logic_add.py +632 -0
  147. ff9mapkit/logic_edit.py +728 -0
  148. ff9mapkit/logic_map.py +526 -0
  149. ff9mapkit/pack.py +175 -0
  150. ff9mapkit/playerswap.py +231 -0
  151. ff9mapkit/prop_archetypes.py +228 -0
  152. ff9mapkit/provision.py +282 -0
  153. ff9mapkit/refarc.py +825 -0
  154. ff9mapkit/save.py +337 -0
  155. ff9mapkit/save_items.py +1673 -0
  156. ff9mapkit/scene/__init__.py +11 -0
  157. ff9mapkit/scene/arena.py +63 -0
  158. ff9mapkit/scene/bgart.py +140 -0
  159. ff9mapkit/scene/bgi.py +732 -0
  160. ff9mapkit/scene/bgs.py +174 -0
  161. ff9mapkit/scene/bgx.py +185 -0
  162. ff9mapkit/scene/cam.py +345 -0
  163. ff9mapkit/scene/guide.py +311 -0
  164. ff9mapkit/scene/paint.py +506 -0
  165. ff9mapkit/scene/placeholder.py +107 -0
  166. ff9mapkit/sjbinary.py +285 -0
  167. ff9mapkit/sps/__init__.py +17 -0
  168. ff9mapkit/sps/author.py +294 -0
  169. ff9mapkit/sps/catalog.py +88 -0
  170. ff9mapkit/sps/codec.py +264 -0
  171. ff9mapkit/sps/edit.py +184 -0
  172. ff9mapkit/sps/lint.py +58 -0
  173. ff9mapkit/sps/render.py +116 -0
  174. ff9mapkit/sps/templates.py +47 -0
  175. ff9mapkit/sps/texture.py +131 -0
  176. ff9mapkit/walkmesh_hotfixes.py +163 -0
  177. ff9mapkit/workspace/__init__.py +18 -0
  178. ff9mapkit/workspace/battledoc.py +985 -0
  179. ff9mapkit/workspace/builddoc.py +607 -0
  180. ff9mapkit/workspace/forms_qt.py +586 -0
  181. ff9mapkit/workspace/importdoc.py +665 -0
  182. ff9mapkit/workspace/mapview.py +131 -0
  183. ff9mapkit/workspace/palette.py +85 -0
  184. ff9mapkit/workspace/savedoc.py +664 -0
  185. ff9mapkit/workspace/shell.py +6907 -0
  186. ff9mapkit/workspace/style.py +105 -0
  187. ff9mapkit/workspace/tuningdialog.py +223 -0
  188. ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
  189. ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
  190. ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
  191. ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
  192. ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
  193. ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,664 @@
1
+ """Qt save-editor documents for the Workspace (Phase 5b) -- the cross-cutting STATE layer.
2
+
3
+ A save isn't tied to a campaign/field; it's the player's story + inventory state. This module hosts the
4
+ Qt documents that read/EDIT it, reusing the kit's tk-free save backends verbatim (the same code the
5
+ tkinter ``ff9_storystate`` / ``ff9_items`` apps call):
6
+
7
+ * :class:`StoryStateDoc` -- ScenarioCounter + story flags (``save.inspect`` / ``flags.render_report`` /
8
+ ``flags.diff_reports`` / ``save.apply_story_edit``). Inspect / Diff / Edit, BACKUP-guarded +
9
+ reserved-region-refused (the edit path shares the CLI's guards). (5b-i)
10
+
11
+ Editing the encrypted ``SavedData_ww.dat`` needs pycryptodome; inspect/diff also read a Memoria
12
+ plaintext extra-save or an exported save JSON. Provenance-clean: only the user's own save, only on Apply.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+
19
+ from PySide6.QtCore import Qt
20
+ from PySide6.QtWidgets import (
21
+ QComboBox, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QMessageBox, QPlainTextEdit,
22
+ QPushButton, QSplitter, QTabWidget, QVBoxLayout, QWidget,
23
+ )
24
+
25
+ from .. import flags as _flags
26
+ from .. import save as _save
27
+ from .. import save_items as _si
28
+
29
+
30
+ class StoryStateDoc(QWidget):
31
+ """Inspect / Diff / EDIT a save's gEventGlobal story state (ScenarioCounter + story bits)."""
32
+
33
+ def __init__(self, palette, output=None):
34
+ super().__init__()
35
+ self.pal = palette
36
+ self._output = output # an output sink callable(text); None = an in-pane console (standalone)
37
+ self.reports = [] # [(label, SaveReport)] for the loaded save (A)
38
+ self.blocks = [] # editable block per report (None unless an encrypted .dat)
39
+ self.path = ""
40
+ self.flag_names = {} # {absolute bit: authored [[flag]] name} from the OPEN project (annotation only)
41
+ self.reports_b = [] # the compare-against save (B)
42
+
43
+ v = QVBoxLayout(self)
44
+ bar = QHBoxLayout()
45
+ self.open_btn = QPushButton("Open Save…")
46
+ self.open_btn.clicked.connect(self.browse)
47
+ self.path_lbl = QLabel("No save loaded.")
48
+ self.path_lbl.setStyleSheet(f"color:{palette['muted']};")
49
+ bar.addWidget(self.open_btn)
50
+ bar.addWidget(self.path_lbl, 1)
51
+ v.addLayout(bar)
52
+
53
+ split = QSplitter(Qt.Horizontal)
54
+ v.addWidget(split, 1)
55
+ self.slots = QListWidget()
56
+ self.slots.currentRowChanged.connect(lambda _r: self._on_slot())
57
+ split.addWidget(self.slots)
58
+
59
+ self.tabs = QTabWidget()
60
+ self.inspect = QPlainTextEdit()
61
+ self.inspect.setReadOnly(True)
62
+ self.tabs.addTab(self.inspect, "Inspect")
63
+ self.tabs.addTab(self._build_diff(), "Diff")
64
+ self.tabs.addTab(self._build_edit(), "Edit")
65
+ split.addWidget(self.tabs)
66
+ split.setSizes([240, 620])
67
+
68
+ self.status = QLabel("Open a SavedData_ww.dat (or a Memoria extra-save / save JSON) to inspect or edit.")
69
+ self.status.setStyleSheet(f"color:{palette['muted']};")
70
+ v.addWidget(self.status)
71
+
72
+ def _show_output(self, text):
73
+ """Preview/Apply console output -> the workspace's bottom panel when docked, else the in-pane box."""
74
+ if self._output is not None:
75
+ self._output(text)
76
+ elif getattr(self, "edit_txt", None) is not None:
77
+ self.edit_txt.setPlainText(text)
78
+
79
+ # ---- view scaffolding ----
80
+ def _build_diff(self):
81
+ page = QWidget()
82
+ lay = QVBoxLayout(page)
83
+ row = QHBoxLayout()
84
+ row.addWidget(QLabel("Compare A against B:"))
85
+ self.b_btn = QPushButton("Open B…")
86
+ self.b_btn.clicked.connect(self.browse_b)
87
+ row.addWidget(self.b_btn)
88
+ row.addWidget(QLabel("B slot:"))
89
+ self.b_slot = QComboBox()
90
+ row.addWidget(self.b_slot)
91
+ cmp_btn = QPushButton("Compare A → B")
92
+ cmp_btn.clicked.connect(self._compare)
93
+ row.addWidget(cmp_btn)
94
+ row.addStretch(1)
95
+ lay.addLayout(row)
96
+ self.diff_txt = QPlainTextEdit()
97
+ self.diff_txt.setReadOnly(True)
98
+ lay.addWidget(self.diff_txt, 1)
99
+ return page
100
+
101
+ def _build_edit(self):
102
+ page = QWidget()
103
+ lay = QVBoxLayout(page)
104
+ self.edit_target = QLabel("(no save selected)")
105
+ self.edit_target.setStyleSheet(f"color:{self.pal['muted']};")
106
+ self.edit_target.setWordWrap(True)
107
+ lay.addWidget(self.edit_target)
108
+ for label, attr, hint in (
109
+ ("Scenario:", "sc_var", 'a value or area name (e.g. "Ice Cavern")'),
110
+ ("Set flags:", "set_var", "comma-separated bit indices (custom band ≥ 8512)"),
111
+ ("Clear flags:", "clear_var", "comma-separated bit indices")):
112
+ row = QHBoxLayout()
113
+ row.addWidget(QLabel(label))
114
+ le = QLineEdit()
115
+ setattr(self, attr, le)
116
+ row.addWidget(le, 1)
117
+ h = QLabel(hint)
118
+ h.setStyleSheet(f"color:{self.pal['muted']};font-size:11px;")
119
+ row.addWidget(h)
120
+ lay.addLayout(row)
121
+ btns = QHBoxLayout()
122
+ self.preview_btn = QPushButton("Preview")
123
+ self.preview_btn.clicked.connect(self._preview)
124
+ self.apply_btn = QPushButton("Apply (backup + write)")
125
+ self.apply_btn.setObjectName("accent")
126
+ self.apply_btn.clicked.connect(self._apply)
127
+ btns.addWidget(self.preview_btn)
128
+ btns.addWidget(self.apply_btn)
129
+ btns.addStretch(1)
130
+ lay.addLayout(btns)
131
+ if self._output is None: # standalone: an in-pane console; docked -> the bottom panel
132
+ self.edit_txt = QPlainTextEdit()
133
+ self.edit_txt.setReadOnly(True)
134
+ lay.addWidget(self.edit_txt, 1)
135
+ return page
136
+
137
+ # ---- loading (A) ----
138
+ def browse(self):
139
+ from PySide6.QtWidgets import QFileDialog
140
+ f, _ = QFileDialog.getOpenFileName(self, "Pick a save (SavedData_ww.dat / extra-save / JSON)",
141
+ _save.default_save_dir() or "",
142
+ "FF9 save (*.dat);;Save JSON / Base64 (*.json *.txt);;All files (*)")
143
+ if f:
144
+ self.load(f)
145
+
146
+ def crumb_label(self):
147
+ """A short 'you are editing X' label for the breadcrumb when the Story State tab is active."""
148
+ return os.path.basename(self.path) if self.path else "no save loaded"
149
+
150
+ def set_flag_names(self, names):
151
+ """Push the OPEN project's ``{absolute bit: authored [[flag]] name}`` map (the shell builds it) so the
152
+ Inspect/Diff views label custom-band bits with the modder's names. Re-renders the current slot so an
153
+ already-open save picks up a newly-opened project. Empty == no annotation (the doc stands alone)."""
154
+ self.flag_names = dict(names or {})
155
+ if self.reports:
156
+ self._on_slot()
157
+
158
+ def load(self, path, select=0) -> bool:
159
+ try:
160
+ self.reports = _save.inspect(path)
161
+ except Exception as e: # noqa: BLE001
162
+ self.reports, self.blocks, self.path = [], [], ""
163
+ self.slots.clear()
164
+ self.inspect.setPlainText(f"Could not read story state from:\n{path}\n\n{e}\n\n"
165
+ "(An encrypted SavedData_ww.dat needs pycryptodome.)")
166
+ self.status.setText("no story state decoded")
167
+ return False
168
+ self.path = path
169
+ self.blocks = self._editable_blocks(path, len(self.reports))
170
+ self.path_lbl.setText(str(path))
171
+ self.slots.clear()
172
+ for label, rep in self.reports:
173
+ beat = rep.milestone[1] if rep.milestone else "(pre-story)"
174
+ self.slots.addItem(f"{label} — SC {rep.scenario_counter} · {beat}")
175
+ ro = "" if any(b is not None for b in self.blocks) else \
176
+ " (read-only: editing needs the encrypted SavedData_ww.dat + pycryptodome)"
177
+ self.status.setText(f"{len(self.reports)} populated save(s){ro}")
178
+ self._refresh_b_slots()
179
+ if self.reports:
180
+ self.slots.setCurrentRow(select if 0 <= select < len(self.reports) else 0)
181
+ return True
182
+
183
+ @staticmethod
184
+ def _editable_blocks(path, n):
185
+ try:
186
+ pops = _save.FF9Save.load(path).populated()
187
+ except Exception: # noqa: BLE001 -- not an encrypted .dat / no crypto
188
+ return [None] * n
189
+ return [p.block for p in pops] if len(pops) == n else [None] * n
190
+
191
+ def _refresh_b_slots(self):
192
+ reps = self.reports_b or self.reports
193
+ self.b_slot.clear()
194
+ for i, (label, rep) in enumerate(reps):
195
+ self.b_slot.addItem(f"{i}: {label} (SC {rep.scenario_counter})", i)
196
+
197
+ def _on_slot(self):
198
+ i = self.slots.currentRow()
199
+ if not (0 <= i < len(self.reports)):
200
+ return
201
+ label, rep = self.reports[i]
202
+ self.inspect.setPlainText(f"{label}\n\n" + _flags.render_report(rep, names=self.flag_names))
203
+ blk = self.blocks[i] if i < len(self.blocks) else None
204
+ if blk is None:
205
+ self.edit_target.setText("Editing disabled — load the encrypted SavedData_ww.dat (read-only).")
206
+ self.preview_btn.setEnabled(False)
207
+ self.apply_btn.setEnabled(False)
208
+ else:
209
+ self.edit_target.setText(f"Editing: {label} (block {blk}). Reserved-region flags are refused; "
210
+ "a .bak is written before any change.")
211
+ self.preview_btn.setEnabled(True)
212
+ self.apply_btn.setEnabled(True)
213
+
214
+ # ---- diff (B) ----
215
+ def browse_b(self):
216
+ from PySide6.QtWidgets import QFileDialog
217
+ f, _ = QFileDialog.getOpenFileName(self, "Pick the second save (B)", _save.default_save_dir() or "",
218
+ "FF9 save (*.dat);;Save JSON / Base64 (*.json *.txt);;All files (*)")
219
+ if not f:
220
+ return
221
+ try:
222
+ self.reports_b = _save.inspect(f)
223
+ except Exception as e: # noqa: BLE001
224
+ self.reports_b = []
225
+ self.diff_txt.setPlainText(f"Could not read save B:\n{f}\n\n{e}")
226
+ return
227
+ self._refresh_b_slots()
228
+ self.status.setText(f"B: {len(self.reports_b)} populated save(s) — pick a B slot, then Compare")
229
+
230
+ def _compare(self):
231
+ i = self.slots.currentRow()
232
+ if not (0 <= i < len(self.reports)):
233
+ self.diff_txt.setPlainText("Select a save on the left (A) first.")
234
+ return
235
+ reps_b = self.reports_b or self.reports # no B file -> compare two slots of A
236
+ j = self.b_slot.currentData()
237
+ j = j if isinstance(j, int) else 0
238
+ if not 0 <= j < len(reps_b):
239
+ self.diff_txt.setPlainText(f"B slot {j} out of range (B has {len(reps_b)}).")
240
+ return
241
+ (la, ra), (lb, rb) = self.reports[i], reps_b[j]
242
+ self.diff_txt.setPlainText(f"A: {la}\nB: {lb}\n\n"
243
+ + _flags.render_diff(_flags.diff_reports(ra, rb), names=self.flag_names))
244
+
245
+ # ---- edit (write) ----
246
+ def _confirm(self, detail) -> bool:
247
+ """The Apply confirm gate (a method so the smoke can stub it). True == the user said Yes."""
248
+ return QMessageBox.question(
249
+ self, "Apply save edit?",
250
+ "This edits your REAL save (a timestamped .bak is written first):\n\n"
251
+ + detail + "\n\nProceed?") == QMessageBox.StandardButton.Yes
252
+
253
+ def _parse_bits(self, s):
254
+ return [_flags.resolve(t.strip(), {}) for t in (s or "").replace(";", ",").split(",") if t.strip()]
255
+
256
+ def _edit_args(self):
257
+ sc = self.sc_var.text().strip()
258
+ return (_flags.resolve_scenario(sc) if sc else None,
259
+ self._parse_bits(self.set_var.text()), self._parse_bits(self.clear_var.text()))
260
+
261
+ def _target_block(self):
262
+ i = self.slots.currentRow()
263
+ return self.blocks[i] if (0 <= i < len(self.blocks)) else None
264
+
265
+ def _preview(self):
266
+ blk = self._target_block()
267
+ if blk is None:
268
+ return
269
+ try:
270
+ scenario, setb, clrb = self._edit_args()
271
+ res = _save.apply_story_edit(self.path, block=blk, scenario=scenario,
272
+ set_flags=setb, clear_flags=clrb, dry_run=True)
273
+ except (ValueError, IndexError) as e:
274
+ self._show_output(f"Cannot apply:\n {e}")
275
+ return
276
+ if not res["notes"]:
277
+ self._show_output("Nothing to change — set a Scenario / Set flags / Clear flags.")
278
+ return
279
+ body = "PREVIEW (nothing written yet):\n" + "\n".join(f" - {n}" for n in res["notes"])
280
+ if res["extra"]:
281
+ body += "\n\n (a Memoria extra-save is present and will be patched too)"
282
+ self._show_output(body)
283
+
284
+ def _apply(self):
285
+ blk = self._target_block()
286
+ if blk is None:
287
+ return
288
+ try:
289
+ scenario, setb, clrb = self._edit_args()
290
+ preview = _save.apply_story_edit(self.path, block=blk, scenario=scenario,
291
+ set_flags=setb, clear_flags=clrb, dry_run=True)
292
+ except (ValueError, IndexError) as e:
293
+ self._show_output(f"Cannot apply:\n {e}")
294
+ return
295
+ if not preview["notes"]:
296
+ self._show_output("Nothing to change.")
297
+ return
298
+ if not self._confirm("\n".join(preview["notes"])):
299
+ self._show_output("Cancelled — nothing written.")
300
+ return
301
+ try:
302
+ res = _save.apply_story_edit(self.path, block=blk, scenario=scenario,
303
+ set_flags=setb, clear_flags=clrb)
304
+ except Exception as e: # noqa: BLE001
305
+ self._show_output(f"Write failed:\n {e}")
306
+ return
307
+ msg = ["APPLIED — your save was edited:"] + [f" - {n}" for n in res["notes"]]
308
+ msg += [f" backed up -> {os.path.basename(b)}" for b in res["backups"]]
309
+ if res["extra"]:
310
+ msg.append(" [OK] Memoria extra-save patched + verified — this IS the gEventGlobal the game loads."
311
+ if res.get("extra_patched") else
312
+ " [WARN] a Memoria extra-save is present but could NOT be verified-patched.")
313
+ else:
314
+ msg.append(" (no Memoria extra-save for this slot — the main save block governs)")
315
+ msg.append("\nReload the save in-game to see it.")
316
+ self._show_output("\n".join(msg))
317
+ self.status.setText("save edited (backup written) — reload it in-game")
318
+ self.load(self.path, select=self.slots.currentRow()) # refresh, KEEPING the edited slot selected
319
+
320
+
321
+ class ItemEquipDoc(QWidget):
322
+ """Inspect / EDIT a save's gil, inventory, equipment, stats, abilities and key items (``save_items``).
323
+
324
+ A SEPARATE surface from Story State (it touches only ``save_items``, per the branch-lane rule). Each
325
+ slot resolves to a target ``{label, report, extra, container, block}``: a Memoria slot dual-writes the
326
+ main block + the extra mirror, a vanilla (no-extra) slot edits the encrypted main block directly.
327
+ Every write is PREVIEWable (dry-run) and Apply is backup-guarded (a timestamped .bak first)."""
328
+
329
+ _STATS = ["Speed", "Strength", "Magic", "Spirit"]
330
+
331
+ def __init__(self, palette, output=None):
332
+ super().__init__()
333
+ self.pal = palette
334
+ self._output = output # an output sink callable(text); None = an in-pane console (standalone)
335
+ self.targets = [] # [{label, report, extra, container, block}] per populated slot
336
+ self.path = ""
337
+
338
+ v = QVBoxLayout(self)
339
+ bar = QHBoxLayout()
340
+ self.open_btn = QPushButton("Open Save…")
341
+ self.open_btn.clicked.connect(self.browse)
342
+ self.path_lbl = QLabel("No save loaded.")
343
+ self.path_lbl.setStyleSheet(f"color:{palette['muted']};")
344
+ bar.addWidget(self.open_btn)
345
+ bar.addWidget(self.path_lbl, 1)
346
+ v.addLayout(bar)
347
+
348
+ split = QSplitter(Qt.Horizontal)
349
+ v.addWidget(split, 1)
350
+ self.slots = QListWidget()
351
+ self.slots.currentRowChanged.connect(lambda _r: self._on_slot())
352
+ split.addWidget(self.slots)
353
+ self.tabs = QTabWidget()
354
+ self.inspect = QPlainTextEdit()
355
+ self.inspect.setReadOnly(True)
356
+ self.tabs.addTab(self.inspect, "Inspect")
357
+ self.tabs.addTab(self._build_edit(), "Edit")
358
+ split.addWidget(self.tabs)
359
+ split.setSizes([240, 620])
360
+ self.status = QLabel("Open a save to read/edit gil, inventory, equipment, stats, abilities, key items.")
361
+ self.status.setStyleSheet(f"color:{palette['muted']};")
362
+ v.addWidget(self.status)
363
+
364
+ def _show_output(self, text):
365
+ """Preview/Apply console output -> the workspace's bottom panel when docked, else the in-pane box."""
366
+ if self._output is not None:
367
+ self._output(text)
368
+ elif getattr(self, "edit_txt", None) is not None:
369
+ self.edit_txt.setPlainText(text)
370
+
371
+ # ---- edit UI ----
372
+ def _section(self, parent_lay, title, widgets, buttons):
373
+ box = QGroupBox(title)
374
+ row = QHBoxLayout(box)
375
+ for w in widgets:
376
+ row.addWidget(QLabel(w[0])) if isinstance(w, tuple) else None
377
+ row.addWidget(w[1] if isinstance(w, tuple) else w)
378
+ row.addStretch(1)
379
+ for label, cb in buttons:
380
+ b = QPushButton(label)
381
+ if label == "Apply":
382
+ b.setObjectName("accent")
383
+ b.clicked.connect(lambda _=False, c=cb: c())
384
+ row.addWidget(b)
385
+ parent_lay.addWidget(box)
386
+
387
+ def _build_edit(self):
388
+ from PySide6.QtWidgets import QScrollArea
389
+ outer = QWidget()
390
+ ov = QVBoxLayout(outer)
391
+ self.edit_target = QLabel("(no save selected)")
392
+ self.edit_target.setWordWrap(True)
393
+ self.edit_target.setStyleSheet(f"color:{self.pal['muted']};")
394
+ ov.addWidget(self.edit_target)
395
+ # Only the edit SECTIONS scroll; the console (edit_txt) is pinned BELOW so Preview/Apply feedback
396
+ # is always visible even on a short window (the bug: a single scroll hid the console off-screen).
397
+ page = QWidget()
398
+ lay = QVBoxLayout(page)
399
+ lay.setContentsMargins(0, 0, 0, 0)
400
+
401
+ self.gil_var = QLineEdit()
402
+ self.gil_var.setFixedWidth(120)
403
+ self._section(lay, "Gil", [self.gil_var, QLabel(f"(0–{_si.GIL_CAP:,})")],
404
+ [("Preview", lambda: self._edit("gil", False)), ("Apply", lambda: self._edit("gil", True))])
405
+
406
+ self.item_var = QLineEdit()
407
+ self.count_var = QLineEdit("1")
408
+ self.count_var.setFixedWidth(48)
409
+ self._section(lay, "Item (count 0 removes; clamps to 99)",
410
+ [("name/id:", self.item_var), ("count:", self.count_var)],
411
+ [("Preview", lambda: self._edit("item", False)), ("Apply", lambda: self._edit("item", True))])
412
+
413
+ self.char_combo = QComboBox()
414
+ self.slot_combo = QComboBox()
415
+ self.slot_combo.addItems(list(_si.EQUIP_SLOTS))
416
+ self.eqitem_var = QLineEdit()
417
+ self._section(lay, "Equipment (item 'empty' unequips)",
418
+ [("who:", self.char_combo), ("slot:", self.slot_combo), ("item:", self.eqitem_var)],
419
+ [("Preview", lambda: self._edit("equip", False)), ("Apply", lambda: self._edit("equip", True))])
420
+
421
+ self.stat_char_combo = QComboBox()
422
+ self.stat_kind_combo = QComboBox()
423
+ self.stat_kind_combo.addItems(self._STATS)
424
+ self.stat_kind_combo.setCurrentText("Strength") # match the tkinter editor's default
425
+ self.stat_val_var = QLineEdit("50")
426
+ self.stat_val_var.setFixedWidth(48)
427
+ self._section(lay, "Stats (permanent: writes basis + the equipment bonus)",
428
+ [("who:", self.stat_char_combo), ("stat:", self.stat_kind_combo), ("value:", self.stat_val_var)],
429
+ [("Preview", lambda: self._edit_stat(False)), ("Apply", lambda: self._edit_stat(True))])
430
+
431
+ self.ap_char_combo = QComboBox()
432
+ self.ap_abil_var = QLineEdit("all")
433
+ self.ap_val_var = QLineEdit("master")
434
+ self.ap_val_var.setFixedWidth(90)
435
+ self._section(lay, "Abilities (AP / mastery — name / AA:X / SA:X / id / all)",
436
+ [("who:", self.ap_char_combo), ("ability:", self.ap_abil_var), ("AP:", self.ap_val_var)],
437
+ [("Preview", lambda: self._edit_ap(False)), ("Apply", lambda: self._edit_ap(True))])
438
+
439
+ self.ki_var = QLineEdit()
440
+ self._section(lay, "Key items (give / remove an important item by name)", [("name/id:", self.ki_var)],
441
+ [("Preview", lambda: self._edit_keyitem(False, True)),
442
+ ("Give", lambda: self._edit_keyitem(True, True)),
443
+ ("Remove", lambda: self._edit_keyitem(True, False))])
444
+
445
+ lay.addStretch(1)
446
+ scroll = QScrollArea()
447
+ scroll.setWidgetResizable(True)
448
+ scroll.setWidget(page)
449
+ ov.addWidget(scroll, 1) # the middle (sections) takes the stretch + scrolls
450
+ if self._output is None: # docked -> output goes to the workspace bottom panel,
451
+ self.edit_txt = QPlainTextEdit() # so the sections reclaim the whole height; standalone
452
+ self.edit_txt.setReadOnly(True) # keeps an in-pane console pinned below.
453
+ self.edit_txt.setMinimumHeight(120)
454
+ ov.addWidget(self.edit_txt)
455
+ return outer
456
+
457
+ # ---- loading ----
458
+ def browse(self):
459
+ from PySide6.QtWidgets import QFileDialog
460
+ f, _ = QFileDialog.getOpenFileName(self, "Pick a save (SavedData_ww.dat or a Memoria extra-save)",
461
+ _save.default_save_dir() or "", "FF9 save (*.dat);;All files (*)")
462
+ if f:
463
+ self.load(f)
464
+
465
+ def crumb_label(self):
466
+ """A short 'you are editing X' label for the breadcrumb when the Item & Equip tab is active."""
467
+ return os.path.basename(self.path) if self.path else "no save loaded"
468
+
469
+ def load(self, path, select=0) -> bool:
470
+ try:
471
+ self.targets = self._resolve_targets(path)
472
+ except Exception as e: # noqa: BLE001
473
+ self.targets, self.path = [], ""
474
+ self.slots.clear()
475
+ self.inspect.setPlainText(f"Could not read items/equipment from:\n{path}\n\n{e}\n\n"
476
+ "(A SavedData_ww.dat container needs pycryptodome; a Memoria extra-save "
477
+ "opens without it.)")
478
+ self.status.setText("no items/equipment decoded")
479
+ return False
480
+ self.path = path
481
+ self.path_lbl.setText(str(path))
482
+ self.slots.clear()
483
+ for t in self.targets:
484
+ self.slots.addItem(t["label"])
485
+ editable = sum(1 for t in self.targets if t["report"] is not None)
486
+ self.status.setText(f"{len(self.targets)} populated save(s); {editable} editable")
487
+ if self.targets:
488
+ self.slots.setCurrentRow(select if 0 <= select < len(self.targets) else 0)
489
+ return True
490
+
491
+ @staticmethod
492
+ def _resolve_targets(path):
493
+ common = _si.load_extra_common(path)[0]
494
+ if common is not None: # a Memoria extra-save, opened directly
495
+ return [{"label": "Memoria extra-save", "report": _si.report_from_common(common),
496
+ "extra": path, "container": None, "block": None}]
497
+ sv = _save.FF9Save.load(path) # the encrypted container (needs pycryptodome)
498
+ out = []
499
+ for s in sv.populated():
500
+ extra = _save.extra_file_path(path, s.block)
501
+ has_extra = bool(extra and os.path.isfile(extra))
502
+ if has_extra:
503
+ rep = _si.report_from_common(_si.load_extra_common(extra)[0])
504
+ lbl = _save._slot_label(s) + " · extra"
505
+ else:
506
+ rep = _si.decode_main_block(path, s.block)
507
+ lbl = _save._slot_label(s) + (" · main (vanilla)" if rep is not None else " · (unreadable)")
508
+ out.append({"label": lbl, "report": rep, "extra": extra if has_extra else None,
509
+ "container": path, "block": s.block})
510
+ if not out:
511
+ raise ValueError("no populated save slots found in this file")
512
+ return out
513
+
514
+ def _target(self):
515
+ i = self.slots.currentRow()
516
+ return self.targets[i] if (0 <= i < len(self.targets)) else None
517
+
518
+ def _on_slot(self):
519
+ t = self._target()
520
+ if t is None:
521
+ return
522
+ rep, extra, container = t["report"], t["extra"], t["container"]
523
+ self.inspect.setPlainText(f"{t['label']}\n\n" + _si.render_report(rep))
524
+ names = [pc["name"] or f"slot {pc['slot_no']}" for pc in (rep.equipment if rep else [])]
525
+ for combo in (self.char_combo, self.stat_char_combo, self.ap_char_combo):
526
+ keep = combo.currentText()
527
+ combo.clear()
528
+ combo.addItems(names)
529
+ if keep in names:
530
+ combo.setCurrentText(keep)
531
+ editable = rep is not None and (container is not None or extra is not None)
532
+ if not editable:
533
+ self.edit_target.setText("Editing disabled — this slot could not be decoded.")
534
+ self.gil_var.setText("")
535
+ elif extra is None:
536
+ self.edit_target.setText(f"Editing: {t['label']} (vanilla — main block). Gil, items, equipment "
537
+ "(by old-slot; slots 5-7 shared), stats, abilities, key items. Backed up first.")
538
+ self.gil_var.setText(str(rep.gil) if rep.gil is not None else "")
539
+ else:
540
+ where = "the extra file" if container is None else "the main block + the extra mirror"
541
+ self.edit_target.setText(f"Editing: {t['label']}. Writes {where}; a timestamped .bak is made first. "
542
+ "Reload the save in-game (no relaunch).")
543
+ self.gil_var.setText(str(rep.gil) if rep.gil is not None else "")
544
+
545
+ # ---- edit (write) ----
546
+ def _confirm(self, detail) -> bool:
547
+ """The Apply confirm gate (a method so the smoke can stub it). True == the user said Yes."""
548
+ return QMessageBox.question(
549
+ self, "Apply save edit?",
550
+ "This edits your REAL save (a timestamped .bak is written first):\n\n"
551
+ + detail + "\n\nProceed?") == QMessageBox.StandardButton.Yes
552
+
553
+ def _apply_plan(self, render, preview, do, apply):
554
+ if not apply:
555
+ self._show_output("PREVIEW (nothing written yet):\n" + render(preview))
556
+ return
557
+ if not self._confirm(render(preview)):
558
+ self._show_output("Cancelled — nothing written.")
559
+ return
560
+ try:
561
+ res = do()
562
+ except Exception as e: # noqa: BLE001
563
+ self._show_output(f"Write failed:\n {e}")
564
+ return
565
+ self._show_output(render(res) + "\n\nReload the save in-game to see it (no relaunch needed).")
566
+ self.status.setText("save edited (backup written) — reload it in-game")
567
+ self.load(self.path, select=self.slots.currentRow())
568
+
569
+ def _edit(self, kind, apply):
570
+ t = self._target()
571
+ if t is None or t["report"] is None:
572
+ self._show_output("Select a decodable slot on the left first.")
573
+ return
574
+ extra, container, block = t["extra"], t["container"], t["block"]
575
+ try:
576
+ if kind == "gil":
577
+ val = int(self.gil_var.text())
578
+ trio = ((_si.render_gil_dual, _si.set_gil_in_save(container, block, val, dry_run=True),
579
+ lambda: _si.set_gil_in_save(container, block, val, dry_run=False)) if container is not None
580
+ else (_si.render_gil_write, _si.set_gil(extra, val, dry_run=True),
581
+ lambda: _si.set_gil(extra, val, dry_run=False)))
582
+ elif kind == "item":
583
+ item, cnt = self.item_var.text().strip(), int(self.count_var.text())
584
+ trio = ((_si.render_item_dual, _si.set_item_in_save(container, block, item, cnt, dry_run=True),
585
+ lambda: _si.set_item_in_save(container, block, item, cnt, dry_run=False)) if container is not None
586
+ else (_si.render_item_write, _si.set_item(extra, item, cnt, dry_run=True),
587
+ lambda: _si.set_item(extra, item, cnt, dry_run=False)))
588
+ else:
589
+ char, slot, item = self.char_combo.currentText(), self.slot_combo.currentText(), self.eqitem_var.text().strip()
590
+ trio = ((_si.render_equip_dual, _si.set_equip_in_save(container, block, char, slot, item, dry_run=True),
591
+ lambda: _si.set_equip_in_save(container, block, char, slot, item, dry_run=False)) if container is not None
592
+ else (_si.render_equip_write, _si.set_equip(extra, char, slot, item, dry_run=True),
593
+ lambda: _si.set_equip(extra, char, slot, item, dry_run=False)))
594
+ except ValueError as e:
595
+ self._show_output(f"Cannot apply:\n {e}")
596
+ return
597
+ self._apply_plan(*trio, apply)
598
+
599
+ def _edit_stat(self, apply):
600
+ t = self._target()
601
+ if t is None or t["report"] is None:
602
+ self._show_output("Select a decodable slot on the left first.")
603
+ return
604
+ extra, container, block = t["extra"], t["container"], t["block"]
605
+ char, stat = self.stat_char_combo.currentText(), self.stat_kind_combo.currentText()
606
+ try:
607
+ val = int(self.stat_val_var.text())
608
+ if container is not None:
609
+ trio = (_si.render_stat_dual, _si.set_stat_in_save(container, block, char, stat, val, dry_run=True),
610
+ lambda: _si.set_stat_in_save(container, block, char, stat, val, dry_run=False))
611
+ elif extra is not None:
612
+ trio = (_si.render_stat_write, _si.set_stat_extra(extra, char, stat, val, dry_run=True),
613
+ lambda: _si.set_stat_extra(extra, char, stat, val, dry_run=False))
614
+ else:
615
+ self._show_output("Select an editable slot first.")
616
+ return
617
+ except ValueError as e:
618
+ self._show_output(f"Cannot apply:\n {e}")
619
+ return
620
+ self._apply_plan(*trio, apply)
621
+
622
+ def _edit_ap(self, apply):
623
+ t = self._target()
624
+ if t is None or t["report"] is None:
625
+ self._show_output("Select a decodable slot on the left first.")
626
+ return
627
+ extra, container, block = t["extra"], t["container"], t["block"]
628
+ char, ability, value = self.ap_char_combo.currentText(), self.ap_abil_var.text().strip(), self.ap_val_var.text().strip()
629
+ try:
630
+ if container is not None:
631
+ trio = (_si.render_ability_dual, _si.set_ap_in_save(container, block, char, ability, value, dry_run=True),
632
+ lambda: _si.set_ap_in_save(container, block, char, ability, value, dry_run=False))
633
+ elif extra is not None:
634
+ trio = (_si.render_ability_write, _si.set_ap_extra(extra, char, ability, value, dry_run=True),
635
+ lambda: _si.set_ap_extra(extra, char, ability, value, dry_run=False))
636
+ else:
637
+ self._show_output("Select an editable slot first.")
638
+ return
639
+ except (ValueError, TypeError) as e:
640
+ self._show_output(f"Cannot apply:\n {e}")
641
+ return
642
+ self._apply_plan(*trio, apply)
643
+
644
+ def _edit_keyitem(self, apply, obtained):
645
+ t = self._target()
646
+ if t is None or t["report"] is None:
647
+ self._show_output("Select a decodable slot on the left first.")
648
+ return
649
+ extra, container, block = t["extra"], t["container"], t["block"]
650
+ name = self.ki_var.text().strip()
651
+ try:
652
+ if container is not None:
653
+ trio = (_si.render_keyitem_dual, _si.set_keyitem_in_save(container, block, name, obtained=obtained, dry_run=True),
654
+ lambda: _si.set_keyitem_in_save(container, block, name, obtained=obtained, dry_run=False))
655
+ elif extra is not None:
656
+ trio = (_si.render_keyitem_write, _si.set_keyitem_extra(extra, name, obtained=obtained, dry_run=True),
657
+ lambda: _si.set_keyitem_extra(extra, name, obtained=obtained, dry_run=False))
658
+ else:
659
+ self._show_output("Select an editable slot first.")
660
+ return
661
+ except ValueError as e:
662
+ self._show_output(f"Cannot apply:\n {e}")
663
+ return
664
+ self._apply_plan(*trio, apply)