ff9mapkit 1.0.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. ff9mapkit/__init__.py +18 -0
  2. ff9mapkit/__main__.py +36 -0
  3. ff9mapkit/_animdb.py +2994 -0
  4. ff9mapkit/_animdb_all.py +14125 -0
  5. ff9mapkit/_fieldtable.py +1516 -0
  6. ff9mapkit/_fieldtext.py +845 -0
  7. ff9mapkit/_held_poses.py +44 -0
  8. ff9mapkit/_itemdb.py +65 -0
  9. ff9mapkit/_modeldb.py +725 -0
  10. ff9mapkit/_narrowmap_data.py +10 -0
  11. ff9mapkit/_npcparams.py +634 -0
  12. ff9mapkit/_regen_animdb.py +72 -0
  13. ff9mapkit/_regen_animdb_all.py +66 -0
  14. ff9mapkit/_regen_fieldtable.py +95 -0
  15. ff9mapkit/_regen_fieldtext.py +66 -0
  16. ff9mapkit/_regen_modeldb.py +67 -0
  17. ff9mapkit/_regen_npcparams.py +123 -0
  18. ff9mapkit/_regen_scenedb.py +57 -0
  19. ff9mapkit/_scenedb.py +869 -0
  20. ff9mapkit/abilities.py +225 -0
  21. ff9mapkit/animations.py +120 -0
  22. ff9mapkit/archetypes.py +218 -0
  23. ff9mapkit/areatitle.py +76 -0
  24. ff9mapkit/battle/__init__.py +21 -0
  25. ff9mapkit/battle/abilityfeatures.py +294 -0
  26. ff9mapkit/battle/actiondelta.py +441 -0
  27. ff9mapkit/battle/aiauthor.py +305 -0
  28. ff9mapkit/battle/ailint.py +140 -0
  29. ff9mapkit/battle/aipatch.py +175 -0
  30. ff9mapkit/battle/battleai.py +148 -0
  31. ff9mapkit/battle/battlecsv.py +390 -0
  32. ff9mapkit/battle/battlepatch.py +395 -0
  33. ff9mapkit/battle/build.py +558 -0
  34. ff9mapkit/battle/camera_codec.py +332 -0
  35. ff9mapkit/battle/camera_data.py +128 -0
  36. ff9mapkit/battle/characterdelta.py +789 -0
  37. ff9mapkit/battle/event_data.py +72 -0
  38. ff9mapkit/battle/extract.py +540 -0
  39. ff9mapkit/battle/fbx.py +223 -0
  40. ff9mapkit/battle/reskin.py +149 -0
  41. ff9mapkit/battle/scene_codec.py +314 -0
  42. ff9mapkit/battle/scene_data.py +369 -0
  43. ff9mapkit/battle/scenelint.py +125 -0
  44. ff9mapkit/battle/seqasm.py +131 -0
  45. ff9mapkit/battle/seqauthor.py +220 -0
  46. ff9mapkit/battle/seqcodec.py +300 -0
  47. ff9mapkit/battle/seqdis.py +106 -0
  48. ff9mapkit/battle/seqpatch.py +137 -0
  49. ff9mapkit/battle_bgm.py +133 -0
  50. ff9mapkit/binutils.py +60 -0
  51. ff9mapkit/build.py +5445 -0
  52. ff9mapkit/campaign.py +1276 -0
  53. ff9mapkit/catalog.py +316 -0
  54. ff9mapkit/chain.py +358 -0
  55. ff9mapkit/cli.py +3114 -0
  56. ff9mapkit/config.py +360 -0
  57. ff9mapkit/content/__init__.py +13 -0
  58. ff9mapkit/content/areatitle.py +36 -0
  59. ff9mapkit/content/ate.py +118 -0
  60. ff9mapkit/content/camera.py +123 -0
  61. ff9mapkit/content/chest.py +186 -0
  62. ff9mapkit/content/choice.py +163 -0
  63. ff9mapkit/content/conductor.py +217 -0
  64. ff9mapkit/content/cutscene.py +290 -0
  65. ff9mapkit/content/encounter.py +41 -0
  66. ff9mapkit/content/entry_settle.py +50 -0
  67. ff9mapkit/content/equipment.py +93 -0
  68. ff9mapkit/content/event.py +191 -0
  69. ff9mapkit/content/gateway.py +101 -0
  70. ff9mapkit/content/inventory.py +59 -0
  71. ff9mapkit/content/itemdata.py +644 -0
  72. ff9mapkit/content/itemtext.py +168 -0
  73. ff9mapkit/content/jump.py +114 -0
  74. ff9mapkit/content/ladder.py +633 -0
  75. ff9mapkit/content/movement.py +53 -0
  76. ff9mapkit/content/music.py +97 -0
  77. ff9mapkit/content/npc.py +348 -0
  78. ff9mapkit/content/object.py +340 -0
  79. ff9mapkit/content/onentry.py +135 -0
  80. ff9mapkit/content/party.py +111 -0
  81. ff9mapkit/content/pathfind.py +138 -0
  82. ff9mapkit/content/platform.py +314 -0
  83. ff9mapkit/content/player.py +168 -0
  84. ff9mapkit/content/prop.py +75 -0
  85. ff9mapkit/content/region.py +340 -0
  86. ff9mapkit/content/reinit.py +59 -0
  87. ff9mapkit/content/savepoint.py +90 -0
  88. ff9mapkit/content/shop.py +178 -0
  89. ff9mapkit/content/sps_trigger.py +66 -0
  90. ff9mapkit/content/startup.py +71 -0
  91. ff9mapkit/content/synthesis.py +106 -0
  92. ff9mapkit/content/text.py +183 -0
  93. ff9mapkit/content/textcarry.py +290 -0
  94. ff9mapkit/content/verbatim.py +86 -0
  95. ff9mapkit/content/walkmesh_hotfix.py +38 -0
  96. ff9mapkit/data/__init__.py +48 -0
  97. ff9mapkit/data/_regen_provenance.py +142 -0
  98. ff9mapkit/data/provenance/blank.es.patch +1 -0
  99. ff9mapkit/data/provenance/blank.fr.patch +1 -0
  100. ff9mapkit/data/provenance/blank.gr.patch +1 -0
  101. ff9mapkit/data/provenance/blank.it.patch +1 -0
  102. ff9mapkit/data/provenance/blank.jp.patch +1 -0
  103. ff9mapkit/data/provenance/blank.uk.patch +1 -0
  104. ff9mapkit/data/provenance/blank.us.patch +1 -0
  105. ff9mapkit/data/provenance/manifest.json +65 -0
  106. ff9mapkit/data/provenance/region_template.patch +1 -0
  107. ff9mapkit/data/reference_arcs.toml +89 -0
  108. ff9mapkit/data/region_catalog.toml +593 -0
  109. ff9mapkit/deploystack.py +358 -0
  110. ff9mapkit/dialogue.py +803 -0
  111. ff9mapkit/eb/__init__.py +12 -0
  112. ff9mapkit/eb/_exprtable.py +59 -0
  113. ff9mapkit/eb/_membertable.py +38 -0
  114. ff9mapkit/eb/_optables.py +537 -0
  115. ff9mapkit/eb/_regen_optables.py +76 -0
  116. ff9mapkit/eb/cmdasm.py +323 -0
  117. ff9mapkit/eb/disasm.py +332 -0
  118. ff9mapkit/eb/edit.py +439 -0
  119. ff9mapkit/eb/exprasm.py +158 -0
  120. ff9mapkit/eb/model.py +178 -0
  121. ff9mapkit/eb/opcodes.py +463 -0
  122. ff9mapkit/eblint.py +177 -0
  123. ff9mapkit/editor/__init__.py +20 -0
  124. ff9mapkit/editor/app.py +950 -0
  125. ff9mapkit/editor/battle_forms.py +240 -0
  126. ff9mapkit/editor/breadcrumb.py +89 -0
  127. ff9mapkit/editor/dialogs.py +116 -0
  128. ff9mapkit/editor/feedback.py +208 -0
  129. ff9mapkit/editor/forms.py +632 -0
  130. ff9mapkit/editor/graphview.py +350 -0
  131. ff9mapkit/editor/jobs.py +342 -0
  132. ff9mapkit/editor/model.py +243 -0
  133. ff9mapkit/editor/picker.py +120 -0
  134. ff9mapkit/editor/theme.py +212 -0
  135. ff9mapkit/eventscan.py +1441 -0
  136. ff9mapkit/extract.py +2279 -0
  137. ff9mapkit/flags.py +693 -0
  138. ff9mapkit/forkreport.py +1383 -0
  139. ff9mapkit/hub.py +477 -0
  140. ff9mapkit/idgated.py +101 -0
  141. ff9mapkit/infohub.py +580 -0
  142. ff9mapkit/items.py +63 -0
  143. ff9mapkit/itemstats.py +346 -0
  144. ff9mapkit/journey.py +1902 -0
  145. ff9mapkit/keyitems.py +93 -0
  146. ff9mapkit/logic_add.py +632 -0
  147. ff9mapkit/logic_edit.py +728 -0
  148. ff9mapkit/logic_map.py +526 -0
  149. ff9mapkit/pack.py +175 -0
  150. ff9mapkit/playerswap.py +231 -0
  151. ff9mapkit/prop_archetypes.py +228 -0
  152. ff9mapkit/provision.py +282 -0
  153. ff9mapkit/refarc.py +825 -0
  154. ff9mapkit/save.py +337 -0
  155. ff9mapkit/save_items.py +1673 -0
  156. ff9mapkit/scene/__init__.py +11 -0
  157. ff9mapkit/scene/arena.py +63 -0
  158. ff9mapkit/scene/bgart.py +140 -0
  159. ff9mapkit/scene/bgi.py +732 -0
  160. ff9mapkit/scene/bgs.py +174 -0
  161. ff9mapkit/scene/bgx.py +185 -0
  162. ff9mapkit/scene/cam.py +345 -0
  163. ff9mapkit/scene/guide.py +311 -0
  164. ff9mapkit/scene/paint.py +506 -0
  165. ff9mapkit/scene/placeholder.py +107 -0
  166. ff9mapkit/sjbinary.py +285 -0
  167. ff9mapkit/sps/__init__.py +17 -0
  168. ff9mapkit/sps/author.py +294 -0
  169. ff9mapkit/sps/catalog.py +88 -0
  170. ff9mapkit/sps/codec.py +264 -0
  171. ff9mapkit/sps/edit.py +184 -0
  172. ff9mapkit/sps/lint.py +58 -0
  173. ff9mapkit/sps/render.py +116 -0
  174. ff9mapkit/sps/templates.py +47 -0
  175. ff9mapkit/sps/texture.py +131 -0
  176. ff9mapkit/walkmesh_hotfixes.py +163 -0
  177. ff9mapkit/workspace/__init__.py +18 -0
  178. ff9mapkit/workspace/battledoc.py +985 -0
  179. ff9mapkit/workspace/builddoc.py +607 -0
  180. ff9mapkit/workspace/forms_qt.py +586 -0
  181. ff9mapkit/workspace/importdoc.py +665 -0
  182. ff9mapkit/workspace/mapview.py +131 -0
  183. ff9mapkit/workspace/palette.py +85 -0
  184. ff9mapkit/workspace/savedoc.py +664 -0
  185. ff9mapkit/workspace/shell.py +6907 -0
  186. ff9mapkit/workspace/style.py +105 -0
  187. ff9mapkit/workspace/tuningdialog.py +223 -0
  188. ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
  189. ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
  190. ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
  191. ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
  192. ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
  193. ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
ff9mapkit/save.py ADDED
@@ -0,0 +1,337 @@
1
+ """Read/edit an FF9 (Memoria/Steam) save file's story state -- the RECREATE verb.
2
+
3
+ The on-disc save lives under **AppData\\LocalLow** (NOT Roaming/Local):
4
+ ``%USERPROFILE%\\AppData\\LocalLow\\SquareEnix\\FINAL FANTASY IX\\Steam\\EncryptedSavedData\\SavedData_ww.dat``
5
+ (:func:`default_save_dir` returns it). It is a container of fixed-size **save blocks**, each independently
6
+ AES-256-CBC encrypted. Layout (all from the engine, ``SharedDataBytesStorage.MetaData``):
7
+
8
+ [0, 320) metadata header
9
+ [320, 320+150*1024) 150 preview blocks (1024 B each)
10
+ BASE=153920 onward 1 autosave + 150 slot/save data blocks, 18432 B each
11
+
12
+ A data block decrypts (raw CBC, 18432 is a multiple of 16 -> no padding) to ``"SAVE"`` + a flat,
13
+ schema-ordered value stream. ``gEventGlobal`` (the 2048-byte story heap) is stored as a String4K field:
14
+ its 2048 bytes Base64'd to a 2732-char string. We locate that string, decode -> edit -> re-encode (always
15
+ 2732 chars, so byte-length-stable) -> re-encrypt the block. Because AES-CBC is a bijection,
16
+ ``encrypt(decrypt(block)) == block`` exactly, so an unedited block round-trips byte-identical and an edit
17
+ moves ONLY the bytes it must -- no checksum, no offset shift. (Crypto: ``AESCryptography.cs`` -- AES-256-CBC,
18
+ PBKDF2-HMAC-SHA1 1000 iters, salt ``[3,3,1,4,7,0,9,7]``; the password is the literal string
19
+ ``"System.Security.SecureString"`` -- the decompiled ``SecureString.ToString()`` returns the type name,
20
+ and that *is* the key. Verified against a real save.)
21
+
22
+ Requires ``pycryptodome`` (``py -m pip install pycryptodome``) -- imported lazily so the rest of the kit
23
+ doesn't depend on it.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import base64
28
+ import hashlib
29
+ import os
30
+ import re
31
+ import struct
32
+ from dataclasses import dataclass
33
+
34
+ from . import flags as _flags
35
+
36
+ # --- container layout (SharedDataBytesStorage.MetaData) ---
37
+ BASE_SAVE_BLOCK_OFFSET = 153920 # MetaDataReservedSize(320) + TotalSaveCount(150)*PreviewReservedSize(1024)
38
+ SAVE_BLOCK_SIZE = 18432
39
+ SLOT_COUNT = 10
40
+ SAVE_COUNT = 15 # saves per slot
41
+ GEG_B64_LEN = 2732 # base64 length of 2048 bytes (always)
42
+
43
+ # --- crypto (AESCryptography.cs) ---
44
+ _SALT = bytes([3, 3, 1, 4, 7, 0, 9, 7])
45
+ _PASSWORD = b"System.Security.SecureString" # the SecureString.ToString() quirk IS the key (verified)
46
+ _ITERS = 1000
47
+
48
+
49
+ def _key_iv():
50
+ dk = hashlib.pbkdf2_hmac("sha1", _PASSWORD, _SALT, _ITERS, 48)
51
+ return dk[:32], dk[32:48]
52
+
53
+
54
+ def _aes():
55
+ try:
56
+ from Crypto.Cipher import AES
57
+ except ImportError as e: # pragma: no cover - environment-dependent
58
+ raise RuntimeError(
59
+ "save editing needs pycryptodome -- install it with: py -m pip install pycryptodome") from e
60
+ return AES
61
+
62
+
63
+ def block_index(slot: int, save: int) -> int:
64
+ """The data-block index for (slot, save). Block 0 is the autosave; manual saves are
65
+ ``1 + slot*SAVE_COUNT + save`` (slot 0..9, save 0..14), matching the in-game load menu."""
66
+ return 1 + slot * SAVE_COUNT + save
67
+
68
+
69
+ def extra_file_path(main_path, block: int):
70
+ """The Memoria per-slot EXTRA-save path for a data block, or None if ``main_path`` isn't a ``.dat``.
71
+ Memoria stores the AUTHORITATIVE gEventGlobal (+ gAbilityUsage/gScriptVector/...) in this plaintext
72
+ file and RESTORES it on load, overriding the vanilla main block -- so a story-state edit must patch it
73
+ too. Layout: ``SavedData_ww_Memoria_Autosave.dat`` (block 0) / ``..._Memoria_{slot}_{save}.dat``
74
+ (``MetaData.GetMemoriaExtraSaveFilePath``)."""
75
+ p = str(main_path)
76
+ if not p.endswith(".dat"):
77
+ return None
78
+ stem = p[:-4]
79
+ if block == 0:
80
+ return stem + "_Memoria_Autosave.dat"
81
+ slot, save = (block - 1) // SAVE_COUNT, (block - 1) % SAVE_COUNT
82
+ return f"{stem}_Memoria_{slot}_{save}.dat"
83
+
84
+
85
+ def _find_b64_geg(buf: bytes):
86
+ """(start, end) of the gEventGlobal Base64 (the run that decodes to 2048 bytes) in a plaintext
87
+ buffer, or None. Shared by the encrypted main block and the plaintext extra file."""
88
+ for m in re.finditer(rb"[A-Za-z0-9+/]{2700,}={0,2}", buf):
89
+ try:
90
+ if len(base64.b64decode(m.group())) == 2048:
91
+ return (m.start(), m.start() + len(m.group()))
92
+ except Exception: # noqa: BLE001
93
+ continue
94
+ return None
95
+
96
+
97
+ def read_extra_gEventGlobal(path):
98
+ """The 2048-byte gEventGlobal from a Memoria extra-save file (plaintext), or None if absent."""
99
+ try:
100
+ buf = open(path, "rb").read()
101
+ except OSError:
102
+ return None
103
+ span = _find_b64_geg(buf)
104
+ return base64.b64decode(buf[span[0]:span[1]]) if span else None
105
+
106
+
107
+ def patch_extra_gEventGlobal(path, blob: bytes) -> bool:
108
+ """Replace the gEventGlobal Base64 in a Memoria extra-save file with ``blob`` (2048 bytes), in place
109
+ (length-stable). Returns True if patched, False if the file has no gEventGlobal field."""
110
+ if len(blob) != 2048:
111
+ raise ValueError(f"gEventGlobal must be 2048 bytes (got {len(blob)})")
112
+ buf = bytearray(open(path, "rb").read())
113
+ span = _find_b64_geg(bytes(buf))
114
+ if span is None:
115
+ return False
116
+ buf[span[0]:span[1]] = base64.b64encode(blob)
117
+ with open(path, "wb") as fh:
118
+ fh.write(bytes(buf))
119
+ return True
120
+
121
+
122
+ @dataclass
123
+ class SaveSlot:
124
+ block: int
125
+ slot: int # -1 for the autosave
126
+ save: int # -1 for the autosave
127
+ scenario: int
128
+ beat: str
129
+ chests: int
130
+
131
+
132
+ class FF9Save:
133
+ """An FF9 ``SavedData_ww.dat``, decrypted block-by-block on demand. Edits stay in memory until
134
+ :meth:`write`. Never mutates the source file (load reads; write takes an explicit path)."""
135
+
136
+ def __init__(self, data: bytes):
137
+ self.data = bytearray(data)
138
+ self.key, self.iv = _key_iv()
139
+
140
+ @classmethod
141
+ def load(cls, path) -> "FF9Save":
142
+ with open(path, "rb") as fh:
143
+ return cls(fh.read())
144
+
145
+ # --- block crypto ---
146
+ def _block_span(self, n: int):
147
+ off = BASE_SAVE_BLOCK_OFFSET + SAVE_BLOCK_SIZE * n
148
+ if off + SAVE_BLOCK_SIZE > len(self.data):
149
+ raise IndexError(f"block {n} is past the end of the save file")
150
+ return off, off + SAVE_BLOCK_SIZE
151
+
152
+ def _decrypt_block(self, n: int) -> bytes:
153
+ AES = _aes()
154
+ lo, hi = self._block_span(n)
155
+ return AES.new(self.key, AES.MODE_CBC, self.iv).decrypt(bytes(self.data[lo:hi]))
156
+
157
+ def _encrypt_block(self, n: int, plaintext: bytes):
158
+ AES = _aes()
159
+ lo, hi = self._block_span(n)
160
+ ct = AES.new(self.key, AES.MODE_CBC, self.iv).encrypt(bytes(plaintext))
161
+ self.data[lo:hi] = ct
162
+
163
+ @staticmethod
164
+ def is_save_block(plaintext: bytes) -> bool:
165
+ return plaintext[:4] == b"SAVE"
166
+
167
+ # --- gEventGlobal find / get / set ---
168
+ _find_geg_span = staticmethod(_find_b64_geg) # the gEventGlobal Base64 span in a decrypted block
169
+
170
+ def gEventGlobal(self, n: int) -> bytes:
171
+ """The 2048-byte gEventGlobal of data block ``n`` (raises if the block isn't a valid save)."""
172
+ pt = self._decrypt_block(n)
173
+ if not self.is_save_block(pt):
174
+ raise ValueError(f"block {n} is not a populated save (no 'SAVE' magic)")
175
+ span = self._find_geg_span(pt)
176
+ if span is None:
177
+ raise ValueError(f"block {n}: could not locate the gEventGlobal field")
178
+ return base64.b64decode(pt[span[0]:span[1]])
179
+
180
+ def set_gEventGlobal(self, n: int, blob: bytes):
181
+ """Replace block ``n``'s gEventGlobal with ``blob`` (exactly 2048 bytes) and re-encrypt the block
182
+ in place. Only the Base64 chars that actually change move; everything else stays byte-identical."""
183
+ if len(blob) != 2048:
184
+ raise ValueError(f"gEventGlobal must be 2048 bytes (got {len(blob)})")
185
+ pt = bytearray(self._decrypt_block(n))
186
+ span = self._find_geg_span(pt)
187
+ if span is None:
188
+ raise ValueError(f"block {n}: could not locate the gEventGlobal field")
189
+ nb64 = base64.b64encode(blob)
190
+ assert len(nb64) == GEG_B64_LEN, len(nb64) # 2048 bytes -> always 2732 base64 chars
191
+ pt[span[0]:span[1]] = nb64
192
+ self._encrypt_block(n, bytes(pt))
193
+
194
+ # --- enumeration ---
195
+ def populated(self) -> "list[SaveSlot]":
196
+ """Every populated save block (autosave + manual), decoded enough to identify it."""
197
+ out = []
198
+ n = 0
199
+ # 1 autosave + SLOT_COUNT*SAVE_COUNT manual blocks
200
+ while True:
201
+ try:
202
+ lo, hi = self._block_span(n)
203
+ except IndexError:
204
+ break
205
+ pt = self._decrypt_block(n)
206
+ if self.is_save_block(pt):
207
+ span = self._find_geg_span(pt)
208
+ if span is not None:
209
+ geg = base64.b64decode(pt[span[0]:span[1]])
210
+ sc = geg[0] | geg[1] << 8
211
+ ms = _flags.nearest_milestone(sc)
212
+ chests = sum(bin(geg[b]).count("1") for b in range(1047, 1064))
213
+ slot, save = (-1, -1) if n == 0 else ((n - 1) // SAVE_COUNT, (n - 1) % SAVE_COUNT)
214
+ out.append(SaveSlot(n, slot, save, sc, ms[1] if ms else "(pre-story)", chests))
215
+ n += 1
216
+ return out
217
+
218
+ def write(self, path):
219
+ with open(path, "wb") as fh:
220
+ fh.write(bytes(self.data))
221
+
222
+
223
+ # --- the high-level read (VIEW; used by the GUI inspector + a future CLI flag) ---
224
+ def default_save_dir():
225
+ """The FF9 Steam save folder if it exists, else None. Steam FF9 saves live under **AppData/LocalLow**
226
+ (``%USERPROFILE%/AppData/LocalLow/SquareEnix/FINAL FANTASY IX/Steam/EncryptedSavedData``) -- a frontend
227
+ uses this as a file-dialog's start directory so the user doesn't have to hunt for SavedData_ww.dat."""
228
+ base = os.environ.get("USERPROFILE") or os.path.expanduser("~")
229
+ cand = os.path.join(base, "AppData", "LocalLow", "SquareEnix", "FINAL FANTASY IX", "Steam",
230
+ "EncryptedSavedData")
231
+ return cand if os.path.isdir(cand) else None
232
+
233
+
234
+ def _slot_label(s: "SaveSlot") -> str:
235
+ return "autosave" if s.slot < 0 else f"slot {s.slot + 1} · save {s.save + 1}"
236
+
237
+
238
+ def inspect(path) -> "list[tuple[str, _flags.SaveReport]]":
239
+ """Decode a save's story state for VIEWING -- returns ``[(label, SaveReport)]``. Accepts, in order: a
240
+ Memoria plaintext extra-save (a ``.dat`` with a loose gEventGlobal -- no crypto); an encrypted
241
+ ``SavedData_ww.dat`` container (one entry per populated block -- needs pycryptodome); or an open save
242
+ JSON / bare Base64 gEventGlobal (one entry). Raises with a clear message if nothing decodes."""
243
+ p = str(path)
244
+ if p.lower().endswith(".dat"):
245
+ blob = read_extra_gEventGlobal(p) # a Memoria plaintext extra-save? (no crypto needed)
246
+ if blob is not None:
247
+ return [("Memoria extra-save", _flags.decode_gEventGlobal(blob))]
248
+ sv = FF9Save.load(p) # the encrypted container (needs pycryptodome)
249
+ out = []
250
+ for s in sv.populated():
251
+ # Memoria writes a per-slot extra file holding the AUTHORITATIVE gEventGlobal and RESTORES from
252
+ # it on LOAD -- so when it exists, THAT is the state the game uses (the main block is stale).
253
+ # Read it (and tag the slot) so inspect shows what the game actually loads, not the main block.
254
+ extra = extra_file_path(p, s.block)
255
+ eblob = read_extra_gEventGlobal(extra) if (extra and os.path.exists(extra)) else None
256
+ blob = eblob if eblob is not None else sv.gEventGlobal(s.block)
257
+ out.append((_slot_label(s) + (" · Memoria extra" if eblob is not None else ""),
258
+ _flags.decode_gEventGlobal(blob)))
259
+ if not out:
260
+ raise ValueError("no populated save slots found in this file")
261
+ return out
262
+ blob = _flags.gEventGlobal_from_save(p) # an open save JSON / bare Base64 gEventGlobal
263
+ return [("gEventGlobal", _flags.decode_gEventGlobal(blob))]
264
+
265
+
266
+ # --- the high-level edit (used by the CLI) ---
267
+ def edit_story_state(geg: bytearray, *, scenario: int | None = None,
268
+ set_flags=(), clear_flags=()) -> "list[str]":
269
+ """Apply story-state edits to a 2048-byte gEventGlobal IN PLACE; returns a list of human change notes.
270
+ ``scenario`` sets ScenarioCounter (bytes 0-1). ``set_flags`` / ``clear_flags`` are GLOB bit indices.
271
+ Refuses to touch a reserved region (chest band / worldmap / handshake / scratch) -- those corrupt
272
+ real state. Flag indices outside [0, 16383] are rejected."""
273
+ notes = []
274
+ if scenario is not None:
275
+ if not 0 <= scenario <= 0xFFFF:
276
+ raise ValueError(f"scenario {scenario} out of range (0-65535)")
277
+ old = geg[0] | geg[1] << 8
278
+ geg[0], geg[1] = scenario & 0xFF, scenario >> 8 & 0xFF
279
+ ms = _flags.nearest_milestone(scenario)
280
+ notes.append(f"ScenarioCounter {old} -> {scenario}" + (f" ({ms[1]})" if ms else ""))
281
+ for bit, on in [(b, True) for b in set_flags] + [(b, False) for b in clear_flags]:
282
+ if not 0 <= bit < 2048 * 8:
283
+ raise ValueError(f"flag {bit} out of range (0-16383)")
284
+ if _flags.is_reserved(bit):
285
+ r = _flags.bit_region(bit)
286
+ raise ValueError(f"flag {bit} is in the reserved region '{r.name}' -- refusing to edit it "
287
+ f"(would corrupt real FF9 state). {r.meaning}")
288
+ byte, mask = bit >> 3, 1 << (bit & 7)
289
+ if on:
290
+ geg[byte] |= mask
291
+ else:
292
+ geg[byte] &= ~mask
293
+ notes.append(f"flag {bit} {'set' if on else 'cleared'}")
294
+ return notes
295
+
296
+
297
+ def apply_story_edit(path, *, block: int, scenario: int | None = None, set_flags=(), clear_flags=(),
298
+ do_backup: bool = True, dry_run: bool = False) -> dict:
299
+ """Edit a real ``SavedData_ww.dat``'s story state IN PLACE and write it back -- the convenience the GUI's
300
+ "Apply" uses (the CLI's ``save-edit --in-place`` path as one call). Reads ``block``'s gEventGlobal from
301
+ the Memoria per-slot extra file when present (it overrides the main block on load), applies
302
+ :func:`edit_story_state`, backs up the ``.dat`` (+ the extra) when ``do_backup``, writes the ``.dat``,
303
+ and patches the extra. ``dry_run`` validates + lists the changes but writes NOTHING (the GUI's "Preview").
304
+ Returns ``{"notes", "extra", "backups", "written"}``. Raises ValueError on a bad edit (e.g. a
305
+ reserved-region flag) BEFORE writing -- nothing is touched until the edit validates. The edit shares the
306
+ same core (:func:`edit_story_state`) as the CLI, so the reserved-region guard holds."""
307
+ import time
308
+ sv = FF9Save.load(path)
309
+ extra = extra_file_path(path, block)
310
+ extra_exists = bool(extra and os.path.exists(extra))
311
+ src = read_extra_gEventGlobal(extra) if extra_exists else None
312
+ if src is None:
313
+ src = sv.gEventGlobal(block)
314
+ geg = bytearray(src)
315
+ notes = edit_story_state(geg, scenario=scenario, set_flags=set_flags, clear_flags=clear_flags)
316
+ if not notes or dry_run:
317
+ return {"notes": notes, "extra": extra_exists, "extra_patched": None, "backups": [], "written": False}
318
+ sv.set_gEventGlobal(block, bytes(geg))
319
+
320
+ def _bk(p):
321
+ b = f"{p}.bak.{time.strftime('%Y%m%d-%H%M%S')}"
322
+ with open(p, "rb") as s, open(b, "wb") as d:
323
+ d.write(s.read())
324
+ return b
325
+
326
+ backups = [_bk(path)] if do_backup else []
327
+ sv.write(path)
328
+ extra_patched = None
329
+ if extra_exists:
330
+ if do_backup:
331
+ backups.append(_bk(extra))
332
+ ok = patch_extra_gEventGlobal(extra, bytes(geg))
333
+ chk = read_extra_gEventGlobal(extra) # re-read to CONFIRM: the extra is what the game LOADS,
334
+ extra_patched = bool(ok) and chk is not None and bytes(chk) == bytes(geg) # so this must take or the
335
+ # edit won't show in-game even though the .dat changed
336
+ return {"notes": notes, "extra": extra_exists, "extra_patched": extra_patched,
337
+ "backups": backups, "written": True}