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