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_items.py
ADDED
|
@@ -0,0 +1,1673 @@
|
|
|
1
|
+
"""Read AND edit a save's ITEMS / EQUIPMENT / GIL -- the #5 editor surface.
|
|
2
|
+
|
|
3
|
+
Reads + writes the Memoria EXTRA file (``SavedData_ww_Memoria_{slot}_{save}.dat``) via the :mod:`sjbinary`
|
|
4
|
+
codec, decoding/mutating ``40000_Common/{gil, items, players[].equip}`` by kit item name
|
|
5
|
+
(:mod:`ff9mapkit.items`). The extra file is the **load-authoritative** store -- it overrides the encrypted main
|
|
6
|
+
block on load (memory project-ff9-save-item-layout) -- so editing it changes what the game loads (proven
|
|
7
|
+
in-game). The READ surface: :func:`inspect` / :func:`report_from_common` (extra) + :func:`decode_main_block`
|
|
8
|
+
(the encrypted main block, for a no-extra save). The WRITE surface (all backup-guarded, ``dry_run`` by default,
|
|
9
|
+
with a validation gate + a scoped-change check + atomic write + post-write confirm): :func:`set_gil`/
|
|
10
|
+
:func:`set_item`/:func:`set_equip` on the extra; :func:`set_main_gil`/:func:`set_main_item` on the ENCRYPTED MAIN
|
|
11
|
+
block (so a **vanilla no-extra save is editable** -- gil + items); and :func:`set_gil_in_save`/
|
|
12
|
+
:func:`set_item_in_save`/:func:`set_equip_in_save`, which DUAL-WRITE the main block + the extra mirror (the extra
|
|
13
|
+
stays load-authoritative). So a vanilla no-extra save is now editable for gil, items AND equipment; only
|
|
14
|
+
key/important items remain deferred.
|
|
15
|
+
|
|
16
|
+
SEPARATE surface per [[project-ff9-branch-lanes]] rule 3: reuses :class:`save.FF9Save` + :mod:`sjbinary`; it
|
|
17
|
+
does NOT touch :func:`save.apply_story_edit` / ``edit_story_state`` (story_flags' gEventGlobal core).
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
from . import abilities as _abilities
|
|
26
|
+
from . import items as _items
|
|
27
|
+
from . import keyitems as _keyitems
|
|
28
|
+
from . import save as _save
|
|
29
|
+
from . import sjbinary as _sj
|
|
30
|
+
|
|
31
|
+
NO_ITEM = 255 # the empty-slot / list-terminator sentinel
|
|
32
|
+
EQUIP_SLOTS = ("weapon", "head", "wrist", "armor", "accessory") # equip[] order (CharacterEquipment.cs)
|
|
33
|
+
_SLOT_ALIASES = {"body": "armor", "acc": "accessory"} # friendly aliases for the slot names
|
|
34
|
+
COMMON = "40000_Common"
|
|
35
|
+
GIL_CAP = 9_999_999 # the in-game gil display cap (project-ff9-save-item-layout)
|
|
36
|
+
ITEM_COUNT_CAP = 99 # the in-game per-stack count cap
|
|
37
|
+
|
|
38
|
+
# The 4 growth stats (ff9level.cs): displayed = base + level*growth + (bonus>>5), capped per stat. `bonus` is the
|
|
39
|
+
# hidden EQUIPMENT accumulator; `basis` is the displayed value (recomputed from bonus only at level-up). Editing a
|
|
40
|
+
# save's `basis` (shows immediately) + `bonus` (holds it through level-ups) = a "set permanent stat" editor.
|
|
41
|
+
STAT_CAPS = {"dex": 50, "str": 99, "mgc": 99, "wpr": 50} # Speed / Strength / Magic / Spirit
|
|
42
|
+
STAT_LABELS = {"dex": "Speed", "str": "Strength", "mgc": "Magic", "wpr": "Spirit"}
|
|
43
|
+
_STAT_ALIASES = {"speed": "dex", "spd": "dex", "dex": "dex", "strength": "str", "str": "str",
|
|
44
|
+
"magic": "mgc", "mag": "mgc", "mgc": "mgc",
|
|
45
|
+
"spirit": "wpr", "spr": "wpr", "wpr": "wpr", "will": "wpr"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _resolve_stat(stat) -> str:
|
|
49
|
+
s = _STAT_ALIASES.get(str(stat).strip().lower())
|
|
50
|
+
if s is None:
|
|
51
|
+
raise ValueError(f"unknown stat {stat!r} (Speed / Strength / Magic / Spirit)")
|
|
52
|
+
return s
|
|
53
|
+
|
|
54
|
+
# AP / ability mastery. The save stores each character's per-ability AP in `players[].pa_extended` as
|
|
55
|
+
# `[{id, cur}]` (id = the global abil_id, cur = AP earned); an ability is MASTERED when cur >= its AP-to-master
|
|
56
|
+
# requirement (ff9abil.FF9Abil_IsMaster). Mastering an active ability makes it permanently usable; mastering a
|
|
57
|
+
# support ability makes it equippable. AP-to-master is always <= 255 (the old-format `pa` cell is a Byte), so
|
|
58
|
+
# AP_CAP=255 is a safe "force-master" value when the exact requirement is unknown (a modded ability).
|
|
59
|
+
AP_CAP = 255
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_ap_value(value, ap_req) -> int:
|
|
63
|
+
"""A CLI/API AP value -> the int to write. ``"master"``/``"learn"`` = the known AP requirement (or
|
|
64
|
+
:data:`AP_CAP` when unknown -- a safe overshoot that masters any ability); ``"max"``/``"full"`` = AP_CAP;
|
|
65
|
+
``"forget"``/``"clear"`` = 0; an int is clamped to ``[0, AP_CAP]``."""
|
|
66
|
+
if isinstance(value, str):
|
|
67
|
+
v = value.strip().lower()
|
|
68
|
+
if v in ("master", "learn"):
|
|
69
|
+
return ap_req if ap_req is not None else AP_CAP
|
|
70
|
+
if v in ("max", "full"):
|
|
71
|
+
return AP_CAP
|
|
72
|
+
if v in ("forget", "clear", "reset"):
|
|
73
|
+
return 0
|
|
74
|
+
if v.isdigit():
|
|
75
|
+
value = int(v)
|
|
76
|
+
else:
|
|
77
|
+
raise ValueError(f"unknown AP value {value!r} (a number 0-{AP_CAP}, or master / max / forget)")
|
|
78
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
79
|
+
raise TypeError(f"AP value must be an int or master/max/forget (got {type(value).__name__})")
|
|
80
|
+
if value < 0:
|
|
81
|
+
raise ValueError(f"AP cannot be negative (got {value}); use 'forget' or 0 to un-learn")
|
|
82
|
+
return min(value, AP_CAP)
|
|
83
|
+
|
|
84
|
+
# --- encrypted MAIN-block (old-format) layout (step 4b) -------------------------------------------
|
|
85
|
+
# The main AES block is a flat alpha-sorted typed stream. gil + the 256-pair item array sit at FIXED
|
|
86
|
+
# offsets in the OLD save format -- empirically confirmed byte-stable across this install's saves at
|
|
87
|
+
# scenario 0..7200 (both Memoria and VANILLA saves). NOT blindly trusted: every write first validates
|
|
88
|
+
# the item block parses cleanly at MAIN_ITEMS_OFF (so a differently-laid-out save is REFUSED, not corrupted).
|
|
89
|
+
MAIN_GIL_OFF = 5235 # UInt32 LE (40000_Common/gil)
|
|
90
|
+
MAIN_ITEMS_OFF = 5239 # 256 fixed {count:Byte, id:Byte} pairs (count BEFORE id)
|
|
91
|
+
MAIN_ITEMS_N = 256
|
|
92
|
+
# Old-format players: 9 fixed 244-byte structs; each holds a 5-BYTE equip array [wpn,head,wrist,armor,accy].
|
|
93
|
+
# Empirically byte-stable across Memoria + vanilla saves. (slots 5-7 SHARE with story temp Cinna/Marcus/Blank.)
|
|
94
|
+
MAIN_EQUIP_OFF = 5784 # old-slot 0's equip; old-slot k = +MAIN_PLAYER_STRIDE*k
|
|
95
|
+
MAIN_PLAYER_STRIDE = 244
|
|
96
|
+
MAIN_PLAYERS_N = 9
|
|
97
|
+
OLD_SLOT_NAMES = {0: "Zidane", 1: "Vivi", 2: "Garnet", 3: "Steiner", 4: "Freya",
|
|
98
|
+
5: "Quina", 6: "Eiko", 7: "Amarant", 8: "Beatrix"} # old-slot -> primary character
|
|
99
|
+
_CHAR_TO_OLD_SLOT = {0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 11: 8, # CharacterId -> old-slot
|
|
100
|
+
8: 5, 9: 6, 10: 7} # Cinna/Marcus/Blank share 5/6/7
|
|
101
|
+
# Key items: a 64-byte bitfield, 2 bits per item (obtained at even bit, used at odd) -> 256 items. Item j is at
|
|
102
|
+
# byte MAIN_RAREITEMS_OFF + j//4, bits (j%4)*2 (obtained) / +1 (used). Empirically byte-stable (vanilla saves
|
|
103
|
+
# decode to sensible key-item sets). (FF9StateGlobal.Get/ParseRareItemByteFormat.)
|
|
104
|
+
MAIN_RAREITEMS_OFF = 7947
|
|
105
|
+
MAIN_RAREITEMS_LEN = 64
|
|
106
|
+
# Per-player growth stats in the old-format player struct (basis Bytes, bonus UInt16 LE; +244*old_slot). Verified:
|
|
107
|
+
# all 9 players' basis+bonus match the extra. Byte offsets within the struct (basis is alpha dex,[hp,mp],mgc,str,wpr):
|
|
108
|
+
MAIN_BASIS_OFF = 5751 # old-slot 0's basis.dex
|
|
109
|
+
MAIN_BONUS_OFF = 5759 # old-slot 0's bonus.dex
|
|
110
|
+
_BASIS_STAT_BYTE = {"dex": 0, "mgc": 5, "str": 6, "wpr": 7} # Byte offset within basis
|
|
111
|
+
_BONUS_STAT_OFF = {"dex": 0, "mgc": 2, "str": 4, "wpr": 6} # UInt16 byte offset within bonus (alpha dex,mgc,str,wpr)
|
|
112
|
+
# Ability AP in the old-format player struct. The struct (alpha-sorted SharedDataBytesStorage keys, base = basis
|
|
113
|
+
# @5751) lays out basis(8) bonus(8) category(1) cur(8) defence(4) elem(4) equip(5,@5784) exp(4) info(6,@5793)
|
|
114
|
+
# level(1) max(8) name(128) pa(48,@5936) sa(8,@5984) status trance web_bone -> the 244 stride, old-slot 8 ending
|
|
115
|
+
# exactly at rareItems@7947. Empirically confirmed: a vanilla save's pa@5936 decodes to each char's base-pool AP
|
|
116
|
+
# (Flee@40, Soul Blade@35, ...). pa[i] = AP earned for pool entry i (mastered when pa[i] >= the pool's AP req).
|
|
117
|
+
MAIN_PA_OFF = 5936 # old-slot 0's pa[0]; old-slot k = +MAIN_PLAYER_STRIDE*k
|
|
118
|
+
MAIN_PA_LEN = 48
|
|
119
|
+
MAIN_SA_OFF = 5984 # old-slot 0's sa (2x UInt32 equipped-SA bitfield) -- read-only ref
|
|
120
|
+
MAIN_MENU_TYPE_OFF = 5793 # old-slot 0's info.menu_type (the live CharacterPresetId per slot)
|
|
121
|
+
|
|
122
|
+
# CharacterId (Memoria.Data.Characters.CharacterId) -- the key of `players[].info.slot_no` in the save. The
|
|
123
|
+
# equip array is keyed by THIS (not array index / EquipmentSetId, which diverges above 7). Names/ids only.
|
|
124
|
+
CHARACTER_NAMES = {0: "Zidane", 1: "Vivi", 2: "Garnet", 3: "Steiner", 4: "Freya", 5: "Quina",
|
|
125
|
+
6: "Eiko", 7: "Amarant", 8: "Cinna", 9: "Marcus", 10: "Blank", 11: "Beatrix"}
|
|
126
|
+
_CHAR_BY_NAME = {v.lower(): k for k, v in CHARACTER_NAMES.items()}
|
|
127
|
+
_CHAR_BY_NAME.update({"dagger": 2, "salamander": 7}) # Garnet's alias, Amarant's nickname
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class ItemReport:
|
|
132
|
+
"""What a save slot's items/equipment/gil decode to (from the Memoria extra file)."""
|
|
133
|
+
gil: int | None = None
|
|
134
|
+
inventory: list = field(default_factory=list) # [(id, name, count), ...]
|
|
135
|
+
equipment: list = field(default_factory=list) # [{"slot_no", "name", "equip": {slot: (id, name)|None}}]
|
|
136
|
+
keyitems: list = field(default_factory=list) # [(id, name, obtained, used), ...] (key/important items)
|
|
137
|
+
stats: list = field(default_factory=list) # [{"slot_no", "name", "stats": {Speed, Strength, ...}}]
|
|
138
|
+
abilities: list = field(default_factory=list) # [{"slot_no", "name", "total", "mastered", "in_progress"}]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class AbilityWriteReport:
|
|
143
|
+
"""The outcome of a :func:`set_ap_extra` call (single ability or a bulk ``all`` edit; dry-run or applied)."""
|
|
144
|
+
path: str
|
|
145
|
+
slot_no: int
|
|
146
|
+
character: "str | None"
|
|
147
|
+
abil_id: int # the global ability id (-1 for a bulk "all" edit)
|
|
148
|
+
token: str # "AA:X" / "SA:X" (or "all")
|
|
149
|
+
ability_name: "str | None"
|
|
150
|
+
old_ap: int
|
|
151
|
+
new_ap: int
|
|
152
|
+
ap_req: "int | None" # AP needed to master (None if a modded id, unknown)
|
|
153
|
+
mastered: bool # new_ap masters it
|
|
154
|
+
action: str # "changed" | "unchanged" | "mastered" | "forgot" | "set"
|
|
155
|
+
count: int # # abilities CHANGED (1 single; N for "all")
|
|
156
|
+
wrote: bool
|
|
157
|
+
pool_total: int = 0 # non-void abilities in the pool (bulk "all" denominator)
|
|
158
|
+
backup_path: "str | None" = None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class StatWriteReport:
|
|
163
|
+
"""The outcome of a :func:`set_stat_extra` / :func:`set_main_stat` call (dry-run or applied)."""
|
|
164
|
+
path: str
|
|
165
|
+
slot_no: int
|
|
166
|
+
character: "str | None"
|
|
167
|
+
stat: str # in-game label (Speed/Strength/Magic/Spirit)
|
|
168
|
+
old_value: int # old displayed (basis) value
|
|
169
|
+
new_value: int # new displayed (basis) value
|
|
170
|
+
old_bonus: int
|
|
171
|
+
new_bonus: int
|
|
172
|
+
wrote: bool
|
|
173
|
+
backup_path: "str | None" = None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass
|
|
177
|
+
class KeyItemWriteReport:
|
|
178
|
+
"""The outcome of a :func:`set_keyitem_extra` / :func:`set_main_keyitem` call (dry-run or applied)."""
|
|
179
|
+
path: str
|
|
180
|
+
item_id: int
|
|
181
|
+
item_name: "str | None"
|
|
182
|
+
obtained: bool
|
|
183
|
+
used: bool
|
|
184
|
+
action: str # "added" | "changed" | "removed" | "unchanged"
|
|
185
|
+
wrote: bool
|
|
186
|
+
backup_path: "str | None" = None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass
|
|
190
|
+
class GilWriteReport:
|
|
191
|
+
"""The outcome of a :func:`set_gil` call (dry-run or applied)."""
|
|
192
|
+
path: str
|
|
193
|
+
old_gil: int
|
|
194
|
+
new_gil: int
|
|
195
|
+
bytes_changed: int # how many on-disk bytes the gil edit moves (<=4)
|
|
196
|
+
wrote: bool # False = dry-run (nothing written)
|
|
197
|
+
backup_path: "str | None" = None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass
|
|
201
|
+
class ItemWriteReport:
|
|
202
|
+
"""The outcome of a :func:`set_item` call (dry-run or applied)."""
|
|
203
|
+
path: str
|
|
204
|
+
item_id: int
|
|
205
|
+
item_name: "str | None"
|
|
206
|
+
old_count: int
|
|
207
|
+
new_count: int
|
|
208
|
+
action: str # "added" | "changed" | "removed" | "unchanged"
|
|
209
|
+
wrote: bool
|
|
210
|
+
backup_path: "str | None" = None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass
|
|
214
|
+
class EquipWriteReport:
|
|
215
|
+
"""The outcome of a :func:`set_equip` / :func:`set_main_equip` call (dry-run or applied)."""
|
|
216
|
+
path: str
|
|
217
|
+
slot_no: int # CharacterId (extra writer) OR old-format slot 0-8 (main writer)
|
|
218
|
+
character: "str | None" # its in-save name
|
|
219
|
+
slot: str # one of EQUIP_SLOTS
|
|
220
|
+
old_id: int
|
|
221
|
+
old_name: "str | None"
|
|
222
|
+
new_id: int
|
|
223
|
+
new_name: "str | None"
|
|
224
|
+
wrote: bool
|
|
225
|
+
backup_path: "str | None" = None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# --- low-level reads off a parsed 40000_Common SJClass --------------------------------------------
|
|
229
|
+
|
|
230
|
+
def read_gil(common) -> int | None:
|
|
231
|
+
n = _sj.get_path(common, "gil")
|
|
232
|
+
return int(n.value) if n is not None else None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def read_inventory(common) -> list:
|
|
236
|
+
"""``40000_Common/items`` -> ``[(id, name, count), ...]`` (extra-file compacted list; names via the kit
|
|
237
|
+
item table). NoItem (255) entries are skipped."""
|
|
238
|
+
arr = _sj.get_path(common, "items")
|
|
239
|
+
out = []
|
|
240
|
+
if arr is None:
|
|
241
|
+
return out
|
|
242
|
+
for entry in arr:
|
|
243
|
+
iid, cnt = _sj.get_path(entry, "id"), _sj.get_path(entry, "count")
|
|
244
|
+
if iid is None or cnt is None:
|
|
245
|
+
continue
|
|
246
|
+
i = int(iid.value)
|
|
247
|
+
if i == NO_ITEM:
|
|
248
|
+
continue
|
|
249
|
+
out.append((i, _items.name_of(i), int(cnt.value)))
|
|
250
|
+
return out
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def read_equipment(common) -> list:
|
|
254
|
+
"""``40000_Common/players[]`` -> ``[{slot_no, name, equip}, ...]``; ``equip`` maps each of the 5 slots
|
|
255
|
+
(weapon/head/wrist/armor/accessory) to ``(id, name)`` or ``None`` (empty). The owner is the player's own
|
|
256
|
+
``name`` + ``info/slot_no`` (CharacterId), NOT the array index."""
|
|
257
|
+
players = _sj.get_path(common, "players")
|
|
258
|
+
out = []
|
|
259
|
+
if players is None:
|
|
260
|
+
return out
|
|
261
|
+
for p in players:
|
|
262
|
+
eq = _sj.get_path(p, "equip")
|
|
263
|
+
if eq is None:
|
|
264
|
+
continue
|
|
265
|
+
sn, nm = _sj.get_path(p, "info", "slot_no"), _sj.get_path(p, "name")
|
|
266
|
+
gear = {}
|
|
267
|
+
for j, slot in enumerate(EQUIP_SLOTS):
|
|
268
|
+
iid = int(eq.items[j].value) if j < len(eq.items) else NO_ITEM
|
|
269
|
+
gear[slot] = None if iid == NO_ITEM else (iid, _items.name_of(iid))
|
|
270
|
+
out.append({"slot_no": int(sn.value) if sn is not None else None,
|
|
271
|
+
"name": nm.value if nm is not None else None, "equip": gear})
|
|
272
|
+
return out
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _sjbool(node) -> bool:
|
|
276
|
+
"""A ``rareItemsEx`` ``obtained``/``used`` leaf -> bool. Stored as a VALUE string ``"True"``/``"False"`` (NOT
|
|
277
|
+
a Bool leaf), so ``bool(value)`` would be wrong (``bool("False")`` is True); compare the text."""
|
|
278
|
+
if node is None:
|
|
279
|
+
return False
|
|
280
|
+
v = node.value
|
|
281
|
+
return v if isinstance(v, bool) else str(v).strip().lower() == "true"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def read_keyitems(common) -> list:
|
|
285
|
+
"""``40000_Common/rareItemsEx`` -> ``[(id, name, obtained, used), ...]`` -- the key/important items the save
|
|
286
|
+
knows about (names via the live :mod:`ff9mapkit.keyitems` table, ``None`` if the install isn't reachable)."""
|
|
287
|
+
arr = _sj.get_path(common, "rareItemsEx")
|
|
288
|
+
out = []
|
|
289
|
+
if arr is None:
|
|
290
|
+
return out
|
|
291
|
+
for e in arr:
|
|
292
|
+
eid = _sj.get_path(e, "id")
|
|
293
|
+
if eid is None:
|
|
294
|
+
continue
|
|
295
|
+
i = int(eid.value)
|
|
296
|
+
out.append((i, _keyitems.name_of(i), _sjbool(_sj.get_path(e, "obtained")),
|
|
297
|
+
_sjbool(_sj.get_path(e, "used"))))
|
|
298
|
+
return out
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def read_abilities(common) -> list:
|
|
302
|
+
"""``40000_Common/players[]`` -> per-character ability state from ``pa_extended``:
|
|
303
|
+
``[{slot_no, name, menu_type, total, mastered: [(abil_id, token, name)],
|
|
304
|
+
in_progress: [(abil_id, token, name, cur, ap_req)]}, ...]``. ``total`` counts non-void pool entries;
|
|
305
|
+
``mastered`` = abilities whose AP meets the (best-effort) requirement; ``in_progress`` = partially learned.
|
|
306
|
+
Names + AP requirements are best-effort (live from the base pool CSVs via :mod:`ff9mapkit.abilities`); a
|
|
307
|
+
modded id with no base entry shows its ``AA:X``/``SA:X`` token and is judged mastered only at AP_CAP."""
|
|
308
|
+
players = _sj.get_path(common, "players")
|
|
309
|
+
out = []
|
|
310
|
+
if players is None:
|
|
311
|
+
return out
|
|
312
|
+
for p in players:
|
|
313
|
+
paext = _sj.get_path(p, "pa_extended")
|
|
314
|
+
if not isinstance(paext, _sj.SJArray):
|
|
315
|
+
continue
|
|
316
|
+
mt = _sj.get_path(p, "info", "menu_type")
|
|
317
|
+
menu_type = int(mt.value) if mt is not None else None
|
|
318
|
+
sn, nm = _sj.get_path(p, "info", "slot_no"), _sj.get_path(p, "name")
|
|
319
|
+
mastered, in_progress, total = [], [], 0
|
|
320
|
+
for e in paext:
|
|
321
|
+
eid, cnode = _sj.get_path(e, "id"), _sj.get_path(e, "cur")
|
|
322
|
+
if eid is None or cnode is None:
|
|
323
|
+
continue
|
|
324
|
+
abil_id, cur = int(eid.value), int(cnode.value)
|
|
325
|
+
if abil_id == 0: # a void pool slot
|
|
326
|
+
continue
|
|
327
|
+
total += 1
|
|
328
|
+
token = _abilities.decode_token(abil_id)
|
|
329
|
+
name = _abilities.name_of(abil_id, menu_type)
|
|
330
|
+
ap_req = _abilities.ap_required(menu_type, abil_id)
|
|
331
|
+
is_master = (ap_req is not None and cur >= ap_req) or (ap_req is None and cur >= AP_CAP)
|
|
332
|
+
if is_master:
|
|
333
|
+
mastered.append((abil_id, token, name))
|
|
334
|
+
elif cur > 0:
|
|
335
|
+
in_progress.append((abil_id, token, name, cur, ap_req))
|
|
336
|
+
out.append({"slot_no": int(sn.value) if sn is not None else None,
|
|
337
|
+
"name": nm.value if nm is not None else None, "menu_type": menu_type,
|
|
338
|
+
"total": total, "mastered": mastered, "in_progress": in_progress})
|
|
339
|
+
return out
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def report_from_common(common) -> ItemReport:
|
|
343
|
+
return ItemReport(gil=read_gil(common), inventory=read_inventory(common),
|
|
344
|
+
equipment=read_equipment(common), keyitems=read_keyitems(common),
|
|
345
|
+
stats=read_stats(common), abilities=read_abilities(common))
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# --- file-level helpers ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
def load_extra_common(extra_path):
|
|
351
|
+
"""Parse a Memoria extra file and return its ``40000_Common`` SJClass (+ the root + trailing for a future
|
|
352
|
+
write), or ``(None, None, b"")`` if it's missing/unparseable/not an extra file."""
|
|
353
|
+
try:
|
|
354
|
+
raw = open(extra_path, "rb").read()
|
|
355
|
+
except OSError:
|
|
356
|
+
return None, None, b""
|
|
357
|
+
try:
|
|
358
|
+
root, trailing = _sj.loads(raw)
|
|
359
|
+
except (ValueError, IndexError):
|
|
360
|
+
return None, None, b""
|
|
361
|
+
common = _sj.get_path(root, COMMON)
|
|
362
|
+
return common, root, trailing
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def inspect(path) -> list:
|
|
366
|
+
"""Decode a save's items/equipment/gil for VIEWING -- returns ``[(label, ItemReport), ...]``, one per
|
|
367
|
+
populated slot. Accepts a Memoria extra file directly (plaintext, no crypto), OR the encrypted
|
|
368
|
+
``SavedData_ww.dat`` container (enumerates populated slots via :meth:`save.FF9Save.populated` -- needs
|
|
369
|
+
pycryptodome). A Memoria slot reads its EXTRA (what the game loads); a VANILLA slot (no extra) reads the
|
|
370
|
+
encrypted MAIN block (:func:`decode_main_block` -- gil + inventory + the 9 players' equipment). A slot that
|
|
371
|
+
decodes to neither is reported as ``None``. Raises with a clear message if nothing decodes."""
|
|
372
|
+
p = str(path)
|
|
373
|
+
# case 1: path IS a Memoria extra file (a plaintext SimpleJSON tree with 40000_Common)
|
|
374
|
+
common, _, _ = load_extra_common(p)
|
|
375
|
+
if common is not None:
|
|
376
|
+
return [("Memoria extra-save", report_from_common(common))]
|
|
377
|
+
# case 2: the encrypted container -> per populated slot, the extra (Memoria) or the main block (vanilla)
|
|
378
|
+
sv = _save.FF9Save.load(p)
|
|
379
|
+
out = []
|
|
380
|
+
for s in sv.populated():
|
|
381
|
+
extra = _save.extra_file_path(p, s.block)
|
|
382
|
+
common = load_extra_common(extra)[0] if (extra and os.path.isfile(extra)) else None
|
|
383
|
+
if common is not None:
|
|
384
|
+
out.append((_save._slot_label(s) + " · Memoria extra", report_from_common(common)))
|
|
385
|
+
else:
|
|
386
|
+
rep = decode_main_block(p, s.block) # a vanilla slot -> read the main block
|
|
387
|
+
out.append((_save._slot_label(s) + (" · main (vanilla)" if rep is not None else " · (undecodable)"),
|
|
388
|
+
rep))
|
|
389
|
+
if not out:
|
|
390
|
+
raise ValueError("no populated save slots found in this file")
|
|
391
|
+
return out
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# --- write surface: shared machinery for the EXTRA writers (the load-authoritative store) ----------
|
|
395
|
+
|
|
396
|
+
def _atomic_write(extra_path, raw: bytes, new_bytes: bytes, *, backup: bool) -> "str | None":
|
|
397
|
+
"""Backup-guarded ATOMIC overwrite of a Memoria extra save file. Writes a timestamped ``<path>.bak.<ts>``
|
|
398
|
+
from the PRISTINE ``raw`` first (never clobbers a prior backup -- matches :func:`save.apply_story_edit`),
|
|
399
|
+
then writes ``new_bytes`` to a sibling ``.tmp`` and ``os.replace``\\ s it in (so the real save is never
|
|
400
|
+
observed half-written). Returns the backup path (or None when ``backup`` is False)."""
|
|
401
|
+
backup_path = None
|
|
402
|
+
if backup:
|
|
403
|
+
backup_path = f"{extra_path}.bak.{time.strftime('%Y%m%d-%H%M%S')}"
|
|
404
|
+
with open(backup_path, "wb") as fh:
|
|
405
|
+
fh.write(raw)
|
|
406
|
+
tmp = f"{extra_path}.tmp"
|
|
407
|
+
with open(tmp, "wb") as fh:
|
|
408
|
+
fh.write(new_bytes)
|
|
409
|
+
os.replace(tmp, extra_path)
|
|
410
|
+
return backup_path
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _load_for_edit(extra_path):
|
|
414
|
+
"""Read + parse a Memoria extra file for editing: returns ``(raw, root, trailing, common)``. Runs GATE 1
|
|
415
|
+
(the codec must reproduce the on-disk bytes exactly -- else refuse, never risk a corrupt write) and the
|
|
416
|
+
``40000_Common`` SJClass guards. Raises ValueError with a clear message on any problem."""
|
|
417
|
+
try:
|
|
418
|
+
raw = open(extra_path, "rb").read()
|
|
419
|
+
except OSError as e:
|
|
420
|
+
raise ValueError(f"cannot read extra save file {extra_path!r}: {e}") from e
|
|
421
|
+
try:
|
|
422
|
+
root, trailing = _sj.loads(raw)
|
|
423
|
+
except (ValueError, IndexError) as e:
|
|
424
|
+
raise ValueError(f"{extra_path!r} is not a parseable Memoria extra save file: {e}") from e
|
|
425
|
+
if _sj.dumps(root, trailing) != raw: # GATE 1
|
|
426
|
+
raise ValueError("refusing to edit: the SimpleJSON codec does not reproduce this file byte-for-byte "
|
|
427
|
+
"(editing could corrupt it). Please report this save.")
|
|
428
|
+
common = _sj.get_path(root, COMMON)
|
|
429
|
+
if common is None:
|
|
430
|
+
raise ValueError(f"no {COMMON} module in {extra_path!r}")
|
|
431
|
+
if not isinstance(common, _sj.SJClass):
|
|
432
|
+
raise ValueError(f"{COMMON} is not a class node in {extra_path!r}; refusing to edit")
|
|
433
|
+
return raw, root, trailing, common
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _assert_scoped(raw: bytes, root, trailing: bytes, allowed_prefixes):
|
|
437
|
+
"""Re-serialize the (mutated) ``root`` and assert the change is SCOPED: every path where the new tree
|
|
438
|
+
differs from the pristine on-disk tree must lie under one of ``allowed_prefixes`` (tuples). Returns
|
|
439
|
+
``(new_bytes, changed_paths)``. Aborts (AssertionError) if anything outside the allowed scope moved -- the
|
|
440
|
+
general analog of :func:`set_gil`'s byte-surgical gate, for variable-length (items) edits."""
|
|
441
|
+
new_bytes = _sj.dumps(root, trailing)
|
|
442
|
+
orig, _ = _sj.loads(raw) # pristine tree (GATE 1 proved this == on-disk)
|
|
443
|
+
changed = list(_sj.diff_paths(orig, root))
|
|
444
|
+
for p in changed:
|
|
445
|
+
if not any(p[:len(pre)] == tuple(pre) for pre in allowed_prefixes):
|
|
446
|
+
raise AssertionError(f"edit touched an unexpected path {p}; aborting (allowed: {allowed_prefixes})")
|
|
447
|
+
return new_bytes, changed
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _resolve_slot(slot) -> int:
|
|
451
|
+
"""An equip slot NAME (or alias) -> its index in :data:`EQUIP_SLOTS`. Raises ValueError on an unknown slot."""
|
|
452
|
+
s = str(slot).strip().lower()
|
|
453
|
+
s = _SLOT_ALIASES.get(s, s)
|
|
454
|
+
if s not in EQUIP_SLOTS:
|
|
455
|
+
raise ValueError(f"unknown equip slot {slot!r} (expected one of {', '.join(EQUIP_SLOTS)})")
|
|
456
|
+
return EQUIP_SLOTS.index(s)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _find_player(players, character):
|
|
460
|
+
"""Find the ``players[]`` entry for ``character`` -- an int CharacterId (0-11, matched on ``info/slot_no``)
|
|
461
|
+
or a name (matched first against each entry's in-save ``name``, then against the canonical CharacterId
|
|
462
|
+
names/aliases). Returns ``(index, node, slot_no, name)``. Raises ValueError if absent."""
|
|
463
|
+
if not isinstance(players, _sj.SJArray):
|
|
464
|
+
raise ValueError(f"no {COMMON}/players array to equip")
|
|
465
|
+
entries = []
|
|
466
|
+
for i, p in enumerate(players):
|
|
467
|
+
sn = _sj.get_path(p, "info", "slot_no")
|
|
468
|
+
nm = _sj.get_path(p, "name")
|
|
469
|
+
entries.append((i, p, int(sn.value) if sn is not None else None, nm.value if nm is not None else None))
|
|
470
|
+
want_slot = None
|
|
471
|
+
if isinstance(character, bool):
|
|
472
|
+
raise ValueError("character cannot be a boolean")
|
|
473
|
+
if isinstance(character, int):
|
|
474
|
+
want_slot = character
|
|
475
|
+
else:
|
|
476
|
+
key = str(character).strip().lower()
|
|
477
|
+
if key.isdigit(): # a numeric CharacterId, e.g. the CLI's "6"
|
|
478
|
+
want_slot = int(key)
|
|
479
|
+
else:
|
|
480
|
+
for i, p, sn, nm in entries: # try the in-save name first (handles renamed PCs)
|
|
481
|
+
if nm is not None and nm.strip().lower() == key:
|
|
482
|
+
return i, p, sn, nm
|
|
483
|
+
if key in _CHAR_BY_NAME:
|
|
484
|
+
want_slot = _CHAR_BY_NAME[key]
|
|
485
|
+
if want_slot is not None:
|
|
486
|
+
for i, p, sn, nm in entries:
|
|
487
|
+
if sn == want_slot:
|
|
488
|
+
return i, p, sn, nm
|
|
489
|
+
have = ", ".join(f"{nm or '?'}({sn})" for _, _, sn, nm in entries)
|
|
490
|
+
raise ValueError(f"no character {character!r} in this save (have: {have})")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# --- write surface: gil (step 3 -- the first real-save WRITE, extra-only) -------------------------
|
|
494
|
+
|
|
495
|
+
def resolve_extra(save_path, *, slot=None, save=None, autosave=False):
|
|
496
|
+
"""Resolve the Memoria EXTRA-file path a write should target. If ``save_path`` is itself an extra file
|
|
497
|
+
(a SimpleJSON tree with ``40000_Common``), return it. If it's a ``SavedData_ww.dat`` container, compute the
|
|
498
|
+
extra path for ``--autosave`` or a 0-indexed ``(slot, save)`` -- 0-indexed to match the on-disk file name
|
|
499
|
+
``SavedData_ww_Memoria_{slot}_{save}.dat`` (the in-game menu shows these 1-indexed). Raises with a clear
|
|
500
|
+
message if the target can't be identified or its extra file is absent."""
|
|
501
|
+
p = str(save_path)
|
|
502
|
+
if load_extra_common(p)[0] is not None: # already a Memoria extra file
|
|
503
|
+
return p
|
|
504
|
+
block = _resolve_block(slot=slot, save=save, autosave=autosave)
|
|
505
|
+
extra = _save.extra_file_path(p, block)
|
|
506
|
+
if extra is None:
|
|
507
|
+
raise ValueError(f"{p!r} is not a .dat save container or a Memoria extra file")
|
|
508
|
+
if not os.path.isfile(extra):
|
|
509
|
+
raise ValueError(f"no Memoria extra file for that slot: {extra}")
|
|
510
|
+
return extra
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _resolve_block(*, slot=None, save=None, autosave=False) -> int:
|
|
514
|
+
"""A container data-block index from ``--autosave`` (block 0) or a 0-indexed ``(slot, save)``
|
|
515
|
+
(``block_index``). Raises ValueError on an ambiguous / missing selection."""
|
|
516
|
+
if autosave and (slot is not None or save is not None):
|
|
517
|
+
raise ValueError("pass --autosave OR --slot/--save-no, not both")
|
|
518
|
+
if autosave:
|
|
519
|
+
return 0
|
|
520
|
+
if slot is not None and save is not None:
|
|
521
|
+
return _save.block_index(int(slot), int(save))
|
|
522
|
+
raise ValueError("to edit a SavedData_ww.dat container, pass --slot and --save-no (0-indexed) or "
|
|
523
|
+
"--autosave; or pass a SavedData_ww_Memoria_*.dat extra file directly")
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def set_gil(extra_path, gil: int, *, dry_run: bool = True, backup: bool = True) -> GilWriteReport:
|
|
527
|
+
"""Write ``40000_Common/gil`` in a Memoria EXTRA save file (the load-authoritative store -- memory
|
|
528
|
+
project-ff9-save-item-layout), preserving every other byte. gil is a length-stable Int32 leaf (IntValue,
|
|
529
|
+
tag 4), so this is the smallest possible real-save mutation: the #5 editor's FIRST write and the falsifiable
|
|
530
|
+
proof of "the extra overrides the encrypted main block on load" -- write ONLY the extra, and if the in-game
|
|
531
|
+
gil changes to match, the extra wins (the main block still holds the old value). This writes only the extra;
|
|
532
|
+
:func:`set_gil_in_save` dual-writes the main block too. Never touches ``00001_time``.
|
|
533
|
+
|
|
534
|
+
Safety (this writes a REAL save): re-serializes the WHOLE extra tree (siblings round-trip verbatim) but
|
|
535
|
+
(gate 1) FIRST asserts the codec reproduces the on-disk bytes EXACTLY -- aborting rather than writing a file
|
|
536
|
+
it can't reproduce (guards an unhandled tag / float culture-format) -- and (gate 2) asserts the new bytes
|
|
537
|
+
differ from the old ONLY within the gil leaf's 4-byte value (length-stable, <=4 contiguous bytes). The write
|
|
538
|
+
is ATOMIC (temp file + ``os.replace``, so the save is never half-written) and re-reads to CONFIRM the new
|
|
539
|
+
gil; a timestamped ``<path>.bak.<ts>`` backup is taken first (``backup=True``, never clobbers a prior one,
|
|
540
|
+
matching :func:`save.apply_story_edit`). ``dry_run`` by default (computes + verifies, writes nothing); a
|
|
541
|
+
no-op (gil already == requested) writes nothing even on apply. Returns a :class:`GilWriteReport`."""
|
|
542
|
+
if isinstance(gil, bool) or not isinstance(gil, int):
|
|
543
|
+
raise TypeError(f"gil must be an int (got {type(gil).__name__})")
|
|
544
|
+
if gil < 0 or gil > GIL_CAP:
|
|
545
|
+
raise ValueError(f"gil must be in [0, {GIL_CAP:,}] (the in-game cap); got {gil:,}")
|
|
546
|
+
try:
|
|
547
|
+
raw = open(extra_path, "rb").read()
|
|
548
|
+
except OSError as e:
|
|
549
|
+
raise ValueError(f"cannot read extra save file {extra_path!r}: {e}") from e
|
|
550
|
+
try:
|
|
551
|
+
root, trailing = _sj.loads(raw)
|
|
552
|
+
except (ValueError, IndexError) as e:
|
|
553
|
+
raise ValueError(f"{extra_path!r} is not a parseable Memoria extra save file: {e}") from e
|
|
554
|
+
# GATE 1: never edit a file we can't reproduce byte-for-byte (an unhandled leaf would corrupt it).
|
|
555
|
+
if _sj.dumps(root, trailing) != raw:
|
|
556
|
+
raise ValueError("refusing to edit: the SimpleJSON codec does not reproduce this file byte-for-byte "
|
|
557
|
+
"(editing could corrupt it). Please report this save.")
|
|
558
|
+
common = _sj.get_path(root, COMMON)
|
|
559
|
+
if common is None:
|
|
560
|
+
raise ValueError(f"no {COMMON} module in {extra_path!r}")
|
|
561
|
+
if not isinstance(common, _sj.SJClass): # a parseable-but-non-Class 40000_Common -> refuse cleanly
|
|
562
|
+
raise ValueError(f"{COMMON} is not a class node in {extra_path!r}; refusing to edit")
|
|
563
|
+
gnode = common.get("gil")
|
|
564
|
+
if not isinstance(gnode, _sj.SJData):
|
|
565
|
+
raise ValueError(f"no {COMMON}/gil leaf in {extra_path!r}")
|
|
566
|
+
if gnode.tag != _sj.INT:
|
|
567
|
+
raise ValueError(f"{COMMON}/gil is not an Int32 leaf (tag {gnode.tag}); refusing to edit")
|
|
568
|
+
old_gil = int(gnode.value)
|
|
569
|
+
common.set("gil", _sj.SJData(_sj.INT, gil)) # preserve the on-disk tag (INT) -> length-stable
|
|
570
|
+
new_bytes = _sj.dumps(root, trailing)
|
|
571
|
+
# GATE 2: the edit must be surgical -- same length, only the gil value's bytes move (<=4, contiguous).
|
|
572
|
+
if len(new_bytes) != len(raw):
|
|
573
|
+
raise AssertionError(f"gil write changed the file length ({len(raw)} -> {len(new_bytes)}); aborting")
|
|
574
|
+
diff = [i for i in range(len(raw)) if raw[i] != new_bytes[i]]
|
|
575
|
+
if old_gil != gil and (len(diff) > 4 or (diff and diff[-1] - diff[0] >= 4)):
|
|
576
|
+
raise AssertionError(f"gil write touched {len(diff)} non-contiguous bytes; aborting (expected <=4)")
|
|
577
|
+
backup_path = None
|
|
578
|
+
did_write = False
|
|
579
|
+
if not dry_run and old_gil != gil: # a no-op (gil already == old) writes NOTHING
|
|
580
|
+
backup_path = _atomic_write(extra_path, raw, new_bytes, backup=backup)
|
|
581
|
+
check = load_extra_common(extra_path)[0] # CONFIRM the write took (mirrors apply_story_edit's re-read)
|
|
582
|
+
cg = _sj.get_path(check, "gil") if check is not None else None
|
|
583
|
+
if cg is None or int(cg.value) != gil:
|
|
584
|
+
raise AssertionError(f"post-write check failed: gil did not read back as {gil:,}")
|
|
585
|
+
did_write = True
|
|
586
|
+
return GilWriteReport(path=str(extra_path), old_gil=old_gil, new_gil=gil,
|
|
587
|
+
bytes_changed=len(diff), wrote=did_write, backup_path=backup_path)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
# --- write surface: inventory + equipment (step 4a, extra-only) ------------------------------------
|
|
591
|
+
|
|
592
|
+
def set_item(extra_path, item, count: int, *, dry_run: bool = True, backup: bool = True) -> ItemWriteReport:
|
|
593
|
+
"""Set the inventory COUNT of ``item`` (a kit name or 0-254 id) in a Memoria EXTRA save file. ``count`` 0
|
|
594
|
+
REMOVES the stack; otherwise it's added (in ascending-id position, matching how the engine writes the bag)
|
|
595
|
+
or its count is updated. ``count`` is clamped to the in-game cap (99). The extra's ``40000_Common/items`` is
|
|
596
|
+
a variable ``[{id,count}]`` list of live stacks; the engine DROPS ``NoItem``/unknown ids on load, so only a
|
|
597
|
+
real id is written. Same safety as :func:`set_gil` but with a SCOPED-change check (only the ``items`` array
|
|
598
|
+
may move) instead of the byte-surgical one: GATE 1 + scoped diff + atomic write + a post-write re-read that
|
|
599
|
+
confirms the new count. ``dry_run`` by default; a no-op writes nothing. Returns an :class:`ItemWriteReport`."""
|
|
600
|
+
iid = _items.resolve(item) # name/id -> 0-255, validated (raises on unknown)
|
|
601
|
+
if iid == NO_ITEM:
|
|
602
|
+
raise ValueError("cannot add NoItem (255); pass count=0 to REMOVE an item instead")
|
|
603
|
+
if isinstance(count, bool) or not isinstance(count, int):
|
|
604
|
+
raise TypeError(f"count must be an int (got {type(count).__name__})")
|
|
605
|
+
if count < 0:
|
|
606
|
+
raise ValueError(f"count cannot be negative (got {count}); use 0 to remove")
|
|
607
|
+
count = min(count, ITEM_COUNT_CAP) # clamp to the in-game per-stack cap
|
|
608
|
+
raw, root, trailing, common = _load_for_edit(extra_path)
|
|
609
|
+
arr = common.get("items")
|
|
610
|
+
if not isinstance(arr, _sj.SJArray):
|
|
611
|
+
raise ValueError(f"no {COMMON}/items array in {extra_path!r}")
|
|
612
|
+
idx, old_count = None, 0
|
|
613
|
+
for i, e in enumerate(arr.items): # find the existing stack for this id (if any)
|
|
614
|
+
eid = _sj.get_path(e, "id")
|
|
615
|
+
if isinstance(e, _sj.SJClass) and eid is not None and int(eid.value) == iid:
|
|
616
|
+
cnode = _sj.get_path(e, "count") # guard like read_inventory does (clean ValueError, not AttributeError)
|
|
617
|
+
if cnode is None:
|
|
618
|
+
raise ValueError(f"malformed {COMMON}/items entry for id {iid} (no count) in {extra_path!r}; "
|
|
619
|
+
"refusing to edit")
|
|
620
|
+
idx, old_count = i, int(cnode.value)
|
|
621
|
+
break
|
|
622
|
+
if count == 0:
|
|
623
|
+
action = "removed" if idx is not None else "unchanged"
|
|
624
|
+
if idx is not None:
|
|
625
|
+
del arr.items[idx]
|
|
626
|
+
elif idx is not None:
|
|
627
|
+
action = "unchanged" if count == old_count else "changed"
|
|
628
|
+
arr.items[idx].set("count", _sj.SJData(_sj.INT, count)) # preserve key order (id, count); INT tag
|
|
629
|
+
else:
|
|
630
|
+
action = "added"
|
|
631
|
+
entry = _sj.SJClass() # a new {id, count} stack, id-first (matches the engine)
|
|
632
|
+
entry.add("id", _sj.SJData(_sj.INT, iid))
|
|
633
|
+
entry.add("count", _sj.SJData(_sj.INT, count))
|
|
634
|
+
pos = next((i for i, e in enumerate(arr.items)
|
|
635
|
+
if _sj.get_path(e, "id") is not None and int(_sj.get_path(e, "id").value) > iid), len(arr.items))
|
|
636
|
+
arr.items.insert(pos, entry)
|
|
637
|
+
new_bytes, changed = _assert_scoped(raw, root, trailing, [(COMMON, "items")])
|
|
638
|
+
backup_path, did_write = None, False
|
|
639
|
+
if not dry_run and changed: # `changed` empty => a true no-op => write nothing
|
|
640
|
+
backup_path = _atomic_write(extra_path, raw, new_bytes, backup=backup)
|
|
641
|
+
chk = read_inventory(load_extra_common(extra_path)[0]) # CONFIRM the write took
|
|
642
|
+
got = next((c for i, _, c in chk if i == iid), 0)
|
|
643
|
+
if got != count:
|
|
644
|
+
raise AssertionError(f"post-write check failed: {iid} count read back {got}, expected {count}")
|
|
645
|
+
did_write = True
|
|
646
|
+
return ItemWriteReport(path=str(extra_path), item_id=iid, item_name=_items.name_of(iid),
|
|
647
|
+
old_count=old_count, new_count=count, action=action,
|
|
648
|
+
wrote=did_write, backup_path=backup_path)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def set_equip(extra_path, character, slot, item, *, dry_run: bool = True, backup: bool = True) -> EquipWriteReport:
|
|
652
|
+
"""Set one equip ``slot`` (weapon/head/wrist/armor/accessory, + aliases body/acc) of one ``character`` (a
|
|
653
|
+
CharacterId 0-11, or a name -- the in-save name or a canonical one incl. dagger/salamander) in a Memoria
|
|
654
|
+
EXTRA save file. ``item`` is a kit name/id, or ``None``/255/"empty" to UNEQUIP. The save's
|
|
655
|
+
``players[].equip`` is a 5-int array keyed by ``info/slot_no`` (CharacterId); the engine resets an unknown
|
|
656
|
+
id to NoItem on load, so only a real id (or 255) is written -- and it RECOMPUTES derived defence/affinity
|
|
657
|
+
from the equip, so we only touch the id. Length-stable INT edit, scoped to that one player's ``equip``.
|
|
658
|
+
GATE 1 + scoped diff + atomic write + a post-write re-read confirm; ``dry_run`` by default. Returns an
|
|
659
|
+
:class:`EquipWriteReport`."""
|
|
660
|
+
slot_idx = _resolve_slot(slot)
|
|
661
|
+
if item is None or (isinstance(item, str) and item.strip().lower() in ("none", "empty", "unequip", "")):
|
|
662
|
+
iid = NO_ITEM
|
|
663
|
+
else:
|
|
664
|
+
iid = _items.resolve(item) # 0-255 (255 also allowed = unequip)
|
|
665
|
+
raw, root, trailing, common = _load_for_edit(extra_path)
|
|
666
|
+
players = common.get("players")
|
|
667
|
+
pidx, pnode, slot_no, cname = _find_player(players, character)
|
|
668
|
+
eq = pnode.get("equip")
|
|
669
|
+
if not isinstance(eq, _sj.SJArray) or len(eq.items) < len(EQUIP_SLOTS):
|
|
670
|
+
raise ValueError(f"{cname or character}'s equip is not a 5-slot array; refusing to edit")
|
|
671
|
+
old_node = eq.items[slot_idx]
|
|
672
|
+
old_id = int(old_node.value) if isinstance(old_node, _sj.SJData) else NO_ITEM
|
|
673
|
+
eq.items[slot_idx] = _sj.SJData(_sj.INT, iid) # length-stable; preserves the array shape
|
|
674
|
+
new_bytes, changed = _assert_scoped(raw, root, trailing, [(COMMON, "players", pidx, "equip")])
|
|
675
|
+
backup_path, did_write = None, False
|
|
676
|
+
if not dry_run and changed:
|
|
677
|
+
backup_path = _atomic_write(extra_path, raw, new_bytes, backup=backup)
|
|
678
|
+
chk = _sj.get_path(load_extra_common(extra_path)[0], "players", pidx, "equip") # CONFIRM
|
|
679
|
+
if chk is None or int(chk.items[slot_idx].value) != iid:
|
|
680
|
+
raise AssertionError(f"post-write check failed: {cname} {EQUIP_SLOTS[slot_idx]} did not read back {iid}")
|
|
681
|
+
did_write = True
|
|
682
|
+
return EquipWriteReport(path=str(extra_path), slot_no=slot_no, character=cname, slot=EQUIP_SLOTS[slot_idx],
|
|
683
|
+
old_id=old_id, old_name=(None if old_id == NO_ITEM else _items.name_of(old_id)),
|
|
684
|
+
new_id=iid, new_name=(None if iid == NO_ITEM else _items.name_of(iid)),
|
|
685
|
+
wrote=did_write, backup_path=backup_path)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _new_bonus_for(target: int, old_basis: int, old_bonus: int) -> int:
|
|
689
|
+
"""The `bonus` accumulator that makes the level-up recompute land on ``target`` at the current level:
|
|
690
|
+
``basis = (base+level*growth) + (bonus>>5)`` and ``base+level*growth = old_basis - (old_bonus>>5)``, so
|
|
691
|
+
``new_bonus = (target - old_basis + (old_bonus>>5)) << 5`` (the base/growth terms cancel -- no game data
|
|
692
|
+
needed). Clamped to a UInt16 [0, 65535] (the engine's bonus type)."""
|
|
693
|
+
return max(0, min(0xFFFF, (target - old_basis + (old_bonus >> 5)) << 5))
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def read_stats(common) -> list:
|
|
697
|
+
"""``[{slot_no, name, stats: {Speed, Strength, Magic, Spirit}}, ...]`` -- each player's displayed (basis)
|
|
698
|
+
growth stats, from the extra. (basis is what the menu shows; bonus is the hidden accumulator.)"""
|
|
699
|
+
players = _sj.get_path(common, "players")
|
|
700
|
+
out = []
|
|
701
|
+
if players is None:
|
|
702
|
+
return out
|
|
703
|
+
for p in players:
|
|
704
|
+
basis = _sj.get_path(p, "basis")
|
|
705
|
+
if basis is None:
|
|
706
|
+
continue
|
|
707
|
+
stats = {STAT_LABELS[f]: (int(_sj.get_path(basis, f).value) if _sj.get_path(basis, f) is not None else None)
|
|
708
|
+
for f in STAT_CAPS}
|
|
709
|
+
sn, nm = _sj.get_path(p, "info", "slot_no"), _sj.get_path(p, "name")
|
|
710
|
+
out.append({"slot_no": int(sn.value) if sn is not None else None,
|
|
711
|
+
"name": nm.value if nm is not None else None, "stats": stats})
|
|
712
|
+
return out
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def set_stat_extra(extra_path, character, stat, target: int, *, dry_run: bool = True,
|
|
716
|
+
backup: bool = True) -> StatWriteReport:
|
|
717
|
+
"""Set a character's permanent growth STAT (Speed/Strength/Magic/Spirit) in a Memoria EXTRA save. Writes BOTH
|
|
718
|
+
``players[].basis.<field>`` (the displayed value -- shows immediately) AND ``players[].bonus.<field>`` (the
|
|
719
|
+
hidden equipment accumulator -- so the level-up recompute holds the value; see :func:`_new_bonus_for`).
|
|
720
|
+
``target`` clamps to the stat cap (Speed/Spirit 50, Strength/Magic 99). Scoped to that one player's
|
|
721
|
+
basis+bonus; GATE 1 + atomic write + backup + post-write confirm; dry-run default."""
|
|
722
|
+
field = _resolve_stat(stat)
|
|
723
|
+
if isinstance(target, bool) or not isinstance(target, int):
|
|
724
|
+
raise TypeError(f"target must be an int (got {type(target).__name__})")
|
|
725
|
+
if target < 0:
|
|
726
|
+
raise ValueError(f"target stat cannot be negative (got {target})")
|
|
727
|
+
target = min(target, STAT_CAPS[field])
|
|
728
|
+
raw, root, trailing, common = _load_for_edit(extra_path)
|
|
729
|
+
players = common.get("players")
|
|
730
|
+
pidx, pnode, slot_no, cname = _find_player(players, character)
|
|
731
|
+
basis, bonus = pnode.get("basis"), pnode.get("bonus")
|
|
732
|
+
if not isinstance(basis, _sj.SJClass) or not isinstance(bonus, _sj.SJClass):
|
|
733
|
+
raise ValueError(f"{cname or character} has no basis/bonus stats; refusing to edit")
|
|
734
|
+
bn, bo = basis.get(field), bonus.get(field)
|
|
735
|
+
if not isinstance(bn, _sj.SJData) or not isinstance(bo, _sj.SJData):
|
|
736
|
+
raise ValueError(f"{cname or character}'s {field} stat leaf is missing; refusing to edit")
|
|
737
|
+
old_basis, old_bonus = int(bn.value), int(bo.value)
|
|
738
|
+
new_bonus = _new_bonus_for(target, old_basis, old_bonus)
|
|
739
|
+
basis.set(field, _sj.SJData(_sj.INT, target))
|
|
740
|
+
bonus.set(field, _sj.SJData(_sj.INT, new_bonus))
|
|
741
|
+
new_bytes, changed = _assert_scoped(raw, root, trailing,
|
|
742
|
+
[(COMMON, "players", pidx, "basis"), (COMMON, "players", pidx, "bonus")])
|
|
743
|
+
backup_path, did_write = None, False
|
|
744
|
+
if not dry_run and changed:
|
|
745
|
+
backup_path = _atomic_write(extra_path, raw, new_bytes, backup=backup)
|
|
746
|
+
cb = _sj.get_path(load_extra_common(extra_path)[0], "players", pidx, "basis", field) # CONFIRM
|
|
747
|
+
if cb is None or int(cb.value) != target:
|
|
748
|
+
raise AssertionError(f"post-write check failed: {cname} {field} basis read back {cb}, expected {target}")
|
|
749
|
+
did_write = True
|
|
750
|
+
return StatWriteReport(path=str(extra_path), slot_no=slot_no, character=cname, stat=STAT_LABELS[field],
|
|
751
|
+
old_value=old_basis, new_value=target, old_bonus=old_bonus, new_bonus=new_bonus,
|
|
752
|
+
wrote=did_write, backup_path=backup_path)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def render_stat_write(rep: StatWriteReport) -> str:
|
|
756
|
+
"""A human-readable summary of a :func:`set_stat_extra` / :func:`set_main_stat` outcome."""
|
|
757
|
+
who = rep.character or f"slot {rep.slot_no}"
|
|
758
|
+
if rep.old_value == rep.new_value and rep.wrote is False and rep.old_bonus == rep.new_bonus:
|
|
759
|
+
return f" {who} {rep.stat} already {rep.new_value} in {rep.path} -- nothing to change."
|
|
760
|
+
head = "WROTE" if rep.wrote else "DRY RUN -- would set"
|
|
761
|
+
lines = [f" {head} {who} {rep.stat}: {rep.old_value} -> {rep.new_value} in {rep.path} "
|
|
762
|
+
f"(bonus {rep.old_bonus} -> {rep.new_bonus})"]
|
|
763
|
+
lines.append(f" Backup: {rep.backup_path}" if rep.backup_path else
|
|
764
|
+
(" (--no-backup: no backup written)" if rep.wrote else
|
|
765
|
+
" Re-run with --apply to write (a .bak backup is made first unless --no-backup)."))
|
|
766
|
+
return "\n".join(lines)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
# --- write surface: AP / ability mastery (extra-only) ---------------------------------------------
|
|
770
|
+
|
|
771
|
+
def _ap_entry_indices(paext, abil_id: int) -> list:
|
|
772
|
+
"""Every ``pa_extended`` index whose ``id`` == ``abil_id``. Normally one, but a malformed save can hold a
|
|
773
|
+
duplicate id; the engine's load (JsonParser) resolves all of them to one ``pa`` slot, so the LAST entry wins.
|
|
774
|
+
The editor therefore sets EVERY match (and reports/confirms the last) so the result is engine-faithful."""
|
|
775
|
+
return [i for i, e in enumerate(paext.items)
|
|
776
|
+
if isinstance(e, _sj.SJClass) and _sj.get_path(e, "id") is not None
|
|
777
|
+
and int(_sj.get_path(e, "id").value) == abil_id]
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def set_ap_extra(extra_path, character, ability, value="master", *, dry_run: bool = True,
|
|
781
|
+
backup: bool = True) -> AbilityWriteReport:
|
|
782
|
+
"""Set the AP of one of ``character``'s learnable abilities -- or, when ``ability == "all"``, every ability
|
|
783
|
+
in their pool -- in a Memoria EXTRA save's ``players[].pa_extended``. ``value`` is ``"master"`` (set AP to
|
|
784
|
+
the requirement so it's learned/usable), ``"max"`` (AP_CAP, force-master even a modded ability), ``"forget"``
|
|
785
|
+
(0), or a number. ``ability`` is a NAME, an ``AA:X``/``SA:X`` token, or a numeric abil_id.
|
|
786
|
+
|
|
787
|
+
The editor only changes abilities ALREADY in the character's pool (the save's ``pa_extended`` is the source of
|
|
788
|
+
truth -- the game keys AP by pool entry, so an id the character doesn't have is meaningless). Same safety as
|
|
789
|
+
the other extra writers: GATE 1 + a scoped-change check (only that player's ``pa_extended`` may move) + atomic
|
|
790
|
+
write + backup + a post-write re-read + dry-run default."""
|
|
791
|
+
raw, root, trailing, common = _load_for_edit(extra_path)
|
|
792
|
+
players = common.get("players")
|
|
793
|
+
pidx, pnode, slot_no, cname = _find_player(players, character)
|
|
794
|
+
mt = _sj.get_path(pnode, "info", "menu_type")
|
|
795
|
+
menu_type = int(mt.value) if mt is not None else None
|
|
796
|
+
paext = pnode.get("pa_extended")
|
|
797
|
+
if not isinstance(paext, _sj.SJArray):
|
|
798
|
+
raise ValueError(f"{cname or character} has no pa_extended ability list; refusing to edit")
|
|
799
|
+
scope = [(COMMON, "players", pidx, "pa_extended")]
|
|
800
|
+
|
|
801
|
+
if isinstance(ability, str) and ability.strip().lower() == "all": # -------- bulk: every ability --------
|
|
802
|
+
n_changed, pool_total, n_master = 0, 0, 0
|
|
803
|
+
for e in paext.items:
|
|
804
|
+
eid, cnode = _sj.get_path(e, "id"), _sj.get_path(e, "cur")
|
|
805
|
+
if not isinstance(e, _sj.SJClass) or eid is None or cnode is None:
|
|
806
|
+
continue
|
|
807
|
+
aid = int(eid.value)
|
|
808
|
+
if aid == 0: # skip void pool slots
|
|
809
|
+
continue
|
|
810
|
+
pool_total += 1
|
|
811
|
+
ap_req = _abilities.ap_required(menu_type, aid)
|
|
812
|
+
new_ap = _resolve_ap_value(value, ap_req)
|
|
813
|
+
if int(cnode.value) != new_ap:
|
|
814
|
+
e.set("cur", _sj.SJData(_sj.INT, new_ap))
|
|
815
|
+
n_changed += 1
|
|
816
|
+
if (ap_req is not None and new_ap >= ap_req) or (ap_req is None and new_ap >= AP_CAP):
|
|
817
|
+
n_master += 1 # outcome-based: count abilities the new AP masters
|
|
818
|
+
new_bytes, changed = _assert_scoped(raw, root, trailing, scope)
|
|
819
|
+
backup_path, did_write = None, False
|
|
820
|
+
if not dry_run and changed:
|
|
821
|
+
backup_path = _atomic_write(extra_path, raw, new_bytes, backup=backup)
|
|
822
|
+
chk = _sj.get_path(load_extra_common(extra_path)[0], "players", pidx, "pa_extended") # CONFIRM
|
|
823
|
+
if chk is None or len(chk) != len(paext):
|
|
824
|
+
raise AssertionError("post-write check failed: pa_extended changed shape")
|
|
825
|
+
did_write = True
|
|
826
|
+
if _resolve_ap_value(value, None) == 0: # the value zeroes every ability -> forget
|
|
827
|
+
label, is_master = "forgot", False
|
|
828
|
+
elif pool_total and n_master == pool_total: # every non-void ability ends mastered
|
|
829
|
+
label, is_master = "mastered", True
|
|
830
|
+
else:
|
|
831
|
+
label, is_master = "set", False
|
|
832
|
+
return AbilityWriteReport(path=str(extra_path), slot_no=slot_no, character=cname, abil_id=-1, token="all",
|
|
833
|
+
ability_name=None, old_ap=0, new_ap=0, ap_req=None, mastered=is_master,
|
|
834
|
+
action=label if n_changed else "unchanged", count=n_changed,
|
|
835
|
+
pool_total=pool_total, wrote=did_write, backup_path=backup_path)
|
|
836
|
+
|
|
837
|
+
abil_id = _abilities.resolve(menu_type, ability) # -------- a single ability --------
|
|
838
|
+
idxs = _ap_entry_indices(paext, abil_id) # all matches (the engine loads the LAST -> set all)
|
|
839
|
+
if not idxs:
|
|
840
|
+
present = ", ".join(sorted({_abilities.decode_token(int(_sj.get_path(e, "id").value))
|
|
841
|
+
for e in paext.items if _sj.get_path(e, "id") is not None
|
|
842
|
+
and int(_sj.get_path(e, "id").value) != 0}))
|
|
843
|
+
raise ValueError(f"{cname or character} has no ability {ability!r} ({_abilities.decode_token(abil_id)}, "
|
|
844
|
+
f"abil_id {abil_id}) in their pool; the editor only changes abilities already present.\n"
|
|
845
|
+
f" present: {present}")
|
|
846
|
+
cnode = _sj.get_path(paext.items[idxs[-1]], "cur") # report/confirm from the LAST (= the engine-effective) one
|
|
847
|
+
if cnode is None:
|
|
848
|
+
raise ValueError(f"malformed pa_extended entry (no cur) for {ability!r}; refusing to edit")
|
|
849
|
+
old_ap = int(cnode.value)
|
|
850
|
+
ap_req = _abilities.ap_required(menu_type, abil_id)
|
|
851
|
+
new_ap = _resolve_ap_value(value, ap_req)
|
|
852
|
+
for i in idxs: # set EVERY duplicate so the load is deterministic
|
|
853
|
+
if _sj.get_path(paext.items[i], "cur") is not None:
|
|
854
|
+
paext.items[i].set("cur", _sj.SJData(_sj.INT, new_ap))
|
|
855
|
+
new_bytes, changed = _assert_scoped(raw, root, trailing, scope)
|
|
856
|
+
backup_path, did_write = None, False
|
|
857
|
+
if not dry_run and changed:
|
|
858
|
+
backup_path = _atomic_write(extra_path, raw, new_bytes, backup=backup)
|
|
859
|
+
cc = _sj.get_path(load_extra_common(extra_path)[0], "players", pidx, "pa_extended", idxs[-1], "cur") # CONFIRM
|
|
860
|
+
if cc is None or int(cc.value) != new_ap:
|
|
861
|
+
raise AssertionError(f"post-write check failed: {ability} AP read back {cc}, expected {new_ap}")
|
|
862
|
+
did_write = True
|
|
863
|
+
mastered = (ap_req is not None and new_ap >= ap_req) or (ap_req is None and new_ap >= AP_CAP)
|
|
864
|
+
action = "unchanged" if old_ap == new_ap else ("forgot" if new_ap == 0 else ("mastered" if mastered else "changed"))
|
|
865
|
+
return AbilityWriteReport(path=str(extra_path), slot_no=slot_no, character=cname, abil_id=abil_id,
|
|
866
|
+
token=_abilities.decode_token(abil_id), ability_name=_abilities.name_of(abil_id, menu_type),
|
|
867
|
+
old_ap=old_ap, new_ap=new_ap, ap_req=ap_req, mastered=mastered, action=action,
|
|
868
|
+
count=1, wrote=did_write, backup_path=backup_path)
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def render_ability_write(rep: AbilityWriteReport) -> str:
|
|
872
|
+
"""A human-readable summary of a :func:`set_ap_extra` outcome (single ability or a bulk ``all`` edit)."""
|
|
873
|
+
who = rep.character or f"slot {rep.slot_no}"
|
|
874
|
+
if rep.token == "all": # bulk
|
|
875
|
+
if rep.action == "unchanged":
|
|
876
|
+
return f" {who}: every ability already at the target AP in {rep.path} -- nothing to change."
|
|
877
|
+
head = "WROTE" if rep.wrote else "DRY RUN -- would"
|
|
878
|
+
verb = {"forgot": "forget", "mastered": "master", "set": "set the AP of"}.get(rep.action, "change")
|
|
879
|
+
lines = [f" {head} {verb} {rep.count}/{rep.pool_total} of {who}'s abilities in {rep.path}"]
|
|
880
|
+
else: # single ability
|
|
881
|
+
nm = rep.ability_name or rep.token
|
|
882
|
+
if rep.action == "unchanged":
|
|
883
|
+
return f" {who}'s {nm} already at {rep.new_ap} AP in {rep.path} -- nothing to change."
|
|
884
|
+
head = "WROTE" if rep.wrote else "DRY RUN -- would set"
|
|
885
|
+
req = f"/{rep.ap_req}" if rep.ap_req is not None else ""
|
|
886
|
+
star = " [MASTERED]" if rep.mastered else ""
|
|
887
|
+
lines = [f" {head} {who}'s {nm} ({rep.token}): {rep.old_ap} -> {rep.new_ap}{req} AP in {rep.path}{star}"]
|
|
888
|
+
lines.append(f" Backup: {rep.backup_path}" if rep.backup_path else
|
|
889
|
+
(" (--no-backup: no backup written)" if rep.wrote else
|
|
890
|
+
" Re-run with --apply to write (a .bak backup is made first unless --no-backup)."))
|
|
891
|
+
return "\n".join(lines)
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def set_keyitem_extra(extra_path, keyitem, *, obtained: bool = True, used: bool = False,
|
|
895
|
+
dry_run: bool = True, backup: bool = True) -> KeyItemWriteReport:
|
|
896
|
+
"""Set a KEY/important item's state in a Memoria EXTRA save's ``40000_Common/rareItemsEx`` list (each entry
|
|
897
|
+
``{id, obtained, used}`` -- the bools are VALUE strings ``"True"``/``"False"``). ``obtained``/``used`` both
|
|
898
|
+
False REMOVES the entry (the engine only stores known key items); otherwise it's added (ascending-id) or
|
|
899
|
+
updated. ``keyitem`` is a name (live :mod:`keyitems` table) or a 0-255 id. Same safety as :func:`set_item`:
|
|
900
|
+
GATE 1 + a scoped-change check (only ``rareItemsEx`` moves) + atomic write + backup + post-write confirm +
|
|
901
|
+
dry-run default."""
|
|
902
|
+
iid = _keyitems.resolve(keyitem)
|
|
903
|
+
raw, root, trailing, common = _load_for_edit(extra_path)
|
|
904
|
+
arr = common.get("rareItemsEx")
|
|
905
|
+
if not isinstance(arr, _sj.SJArray):
|
|
906
|
+
raise ValueError(f"no {COMMON}/rareItemsEx in {extra_path!r} (an early save with no key items yet?)")
|
|
907
|
+
idx, old_ob, old_us = None, False, False
|
|
908
|
+
for i, e in enumerate(arr.items):
|
|
909
|
+
eid = _sj.get_path(e, "id")
|
|
910
|
+
if isinstance(e, _sj.SJClass) and eid is not None and int(eid.value) == iid:
|
|
911
|
+
idx, old_ob, old_us = i, _sjbool(_sj.get_path(e, "obtained")), _sjbool(_sj.get_path(e, "used"))
|
|
912
|
+
break
|
|
913
|
+
sb = lambda b: _sj.SJData(_sj.VALUE, "True" if b else "False") # noqa: E731 (the engine's bool-as-string form)
|
|
914
|
+
if not obtained and not used:
|
|
915
|
+
action = "removed" if idx is not None else "unchanged"
|
|
916
|
+
if idx is not None:
|
|
917
|
+
del arr.items[idx]
|
|
918
|
+
elif idx is not None:
|
|
919
|
+
action = "unchanged" if (obtained, used) == (old_ob, old_us) else "changed"
|
|
920
|
+
arr.items[idx].set("obtained", sb(obtained))
|
|
921
|
+
arr.items[idx].set("used", sb(used))
|
|
922
|
+
else:
|
|
923
|
+
action = "added"
|
|
924
|
+
entry = _sj.SJClass() # id, obtained, used -- the engine's key order
|
|
925
|
+
entry.add("id", _sj.SJData(_sj.INT, iid))
|
|
926
|
+
entry.add("obtained", sb(obtained))
|
|
927
|
+
entry.add("used", sb(used))
|
|
928
|
+
pos = next((i for i, e in enumerate(arr.items)
|
|
929
|
+
if _sj.get_path(e, "id") is not None and int(_sj.get_path(e, "id").value) > iid), len(arr.items))
|
|
930
|
+
arr.items.insert(pos, entry)
|
|
931
|
+
new_bytes, changed = _assert_scoped(raw, root, trailing, [(COMMON, "rareItemsEx")])
|
|
932
|
+
backup_path, did_write = None, False
|
|
933
|
+
if not dry_run and changed:
|
|
934
|
+
backup_path = _atomic_write(extra_path, raw, new_bytes, backup=backup)
|
|
935
|
+
chk = {i: (ob, us) for i, _, ob, us in read_keyitems(load_extra_common(extra_path)[0])}
|
|
936
|
+
got = chk.get(iid, (False, False))
|
|
937
|
+
if got != (obtained, used) and not (obtained is False and used is False and iid not in chk):
|
|
938
|
+
raise AssertionError(f"post-write check failed: key item {iid} read back {got}")
|
|
939
|
+
did_write = True
|
|
940
|
+
return KeyItemWriteReport(path=str(extra_path), item_id=iid, item_name=_keyitems.name_of(iid),
|
|
941
|
+
obtained=obtained, used=used, action=action, wrote=did_write, backup_path=backup_path)
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
# --- write surface: the encrypted MAIN block (step 4b -- edit no-extra/vanilla saves) -------------
|
|
945
|
+
|
|
946
|
+
def validate_main_block(pt) -> None:
|
|
947
|
+
"""Raise ValueError unless ``pt`` (a decrypted save block) is a populated OLD-format block whose 256-pair
|
|
948
|
+
item array parses cleanly at :data:`MAIN_ITEMS_OFF`: every LIVE (count>=1) pair is a valid item
|
|
949
|
+
(``count 1-99, id 0-254``) and the array ENDS in padding (the last slot's count is 0). count==0 entries may
|
|
950
|
+
appear mid-list (FF9 doesn't always compact the inventory -- e.g. a ``{0, 196}`` gap), so padding is keyed on
|
|
951
|
+
count, not position. This is the SAFETY GATE: a save whose gil/item offsets differ from this install's old
|
|
952
|
+
format won't satisfy it (a wrong offset reads random bytes -> some count lands in 100-255), so we REFUSE
|
|
953
|
+
rather than corrupt it."""
|
|
954
|
+
if pt[:4] != b"SAVE":
|
|
955
|
+
raise ValueError("not a populated save block (no 'SAVE' magic); refusing to edit the main block")
|
|
956
|
+
if pt[MAIN_ITEMS_OFF + 2 * (MAIN_ITEMS_N - 1)] != 0:
|
|
957
|
+
raise ValueError("main item block has no padding tail (last slot is a live item) -- not this install's "
|
|
958
|
+
"expected old-format layout; refusing to edit")
|
|
959
|
+
for k in range(MAIN_ITEMS_N):
|
|
960
|
+
off = MAIN_ITEMS_OFF + 2 * k
|
|
961
|
+
c, i = pt[off], pt[off + 1]
|
|
962
|
+
if c != 0 and not (1 <= c <= ITEM_COUNT_CAP and 0 <= i <= 254):
|
|
963
|
+
raise ValueError(f"main item block: invalid live pair at slot {k} (count {c}, id {i}) -- not this "
|
|
964
|
+
"install's expected old-format layout; refusing to edit")
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def read_main_gil(pt) -> int:
|
|
968
|
+
return int.from_bytes(pt[MAIN_GIL_OFF:MAIN_GIL_OFF + 4], "little")
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def read_main_inventory(pt) -> list:
|
|
972
|
+
"""``[(id, name, count), ...]`` -- every LIVE (count>=1) stack in the main block's 256-pair item array
|
|
973
|
+
(count==0 entries, padding or a mid-list gap, are skipped)."""
|
|
974
|
+
out = []
|
|
975
|
+
for k in range(MAIN_ITEMS_N):
|
|
976
|
+
off = MAIN_ITEMS_OFF + 2 * k
|
|
977
|
+
c, i = pt[off], pt[off + 1]
|
|
978
|
+
if c >= 1:
|
|
979
|
+
out.append((i, _items.name_of(i), c))
|
|
980
|
+
return out
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def read_main_equipment(pt) -> list:
|
|
984
|
+
"""``[{slot_no, name, equip}, ...]`` for the 9 old-format player slots (same shape as :func:`read_equipment`).
|
|
985
|
+
``slot_no`` is the OLD-slot 0-8; ``name`` its primary character (slots 5-7 may instead hold the temp
|
|
986
|
+
Cinna/Marcus/Blank -- the current gear disambiguates). ``equip`` maps each of the 5 slots to ``(id, name)``
|
|
987
|
+
or ``None`` (255 = empty)."""
|
|
988
|
+
out = []
|
|
989
|
+
for k in range(MAIN_PLAYERS_N):
|
|
990
|
+
base = MAIN_EQUIP_OFF + MAIN_PLAYER_STRIDE * k
|
|
991
|
+
gear = {}
|
|
992
|
+
for j, slot in enumerate(EQUIP_SLOTS):
|
|
993
|
+
iid = pt[base + j]
|
|
994
|
+
gear[slot] = None if iid == NO_ITEM else (iid, _items.name_of(iid))
|
|
995
|
+
out.append({"slot_no": k, "name": OLD_SLOT_NAMES.get(k), "equip": gear})
|
|
996
|
+
return out
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def read_main_keyitems(pt) -> list:
|
|
1000
|
+
"""``[(id, name, obtained, used), ...]`` from the main block's 64-byte 2-bit ``rareItems`` bitfield (only the
|
|
1001
|
+
items with either bit set)."""
|
|
1002
|
+
out = []
|
|
1003
|
+
for b in range(MAIN_RAREITEMS_LEN):
|
|
1004
|
+
bv = pt[MAIN_RAREITEMS_OFF + b]
|
|
1005
|
+
for k in range(4):
|
|
1006
|
+
ob, us = bool(bv & (1 << (k * 2))), bool(bv & (1 << (k * 2 + 1)))
|
|
1007
|
+
if ob or us:
|
|
1008
|
+
j = b * 4 + k
|
|
1009
|
+
out.append((j, _keyitems.name_of(j), ob, us))
|
|
1010
|
+
return out
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def read_main_stats(pt) -> list:
|
|
1014
|
+
"""``[{slot_no, name, stats: {Speed, Strength, Magic, Spirit}}, ...]`` -- the 9 old-format players' displayed
|
|
1015
|
+
(basis) growth stats from the main block."""
|
|
1016
|
+
out = []
|
|
1017
|
+
for k in range(MAIN_PLAYERS_N):
|
|
1018
|
+
base = MAIN_BASIS_OFF + MAIN_PLAYER_STRIDE * k
|
|
1019
|
+
stats = {STAT_LABELS[f]: pt[base + _BASIS_STAT_BYTE[f]] for f in STAT_CAPS}
|
|
1020
|
+
out.append({"slot_no": k, "name": OLD_SLOT_NAMES.get(k), "stats": stats})
|
|
1021
|
+
return out
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def read_main_abilities(pt) -> list:
|
|
1025
|
+
"""The 9 old-format players' ability AP from the main block's ``pa`` array (per old-slot, +244·k). ``pa[i]``
|
|
1026
|
+
is the AP earned for the slot's CURRENT pool entry ``i`` (the live ``info.menu_type`` = CharacterPresetId);
|
|
1027
|
+
mastered when ``pa[i] >= the pool's AP requirement``. Best-effort names/req via :mod:`ff9mapkit.abilities`
|
|
1028
|
+
(the OLD format uses the live pool order, which on a vanilla save is the base pool -> names resolve). Same
|
|
1029
|
+
shape as :func:`read_abilities`."""
|
|
1030
|
+
out = []
|
|
1031
|
+
for k in range(MAIN_PLAYERS_N):
|
|
1032
|
+
base = MAIN_PA_OFF + MAIN_PLAYER_STRIDE * k
|
|
1033
|
+
menu_type = pt[MAIN_MENU_TYPE_OFF + MAIN_PLAYER_STRIDE * k]
|
|
1034
|
+
pool = _abilities.pool_for_preset(menu_type)
|
|
1035
|
+
mastered, in_progress, total = [], [], 0
|
|
1036
|
+
for i in range(MAIN_PA_LEN):
|
|
1037
|
+
ab = pool[i] if i < len(pool) else None
|
|
1038
|
+
if ab is None or ab.abil_id == 0:
|
|
1039
|
+
continue
|
|
1040
|
+
total += 1
|
|
1041
|
+
cur, req = pt[base + i], ab.ap_req
|
|
1042
|
+
if cur >= req if req else cur >= AP_CAP:
|
|
1043
|
+
if cur > 0:
|
|
1044
|
+
mastered.append((ab.abil_id, ab.token, ab.name))
|
|
1045
|
+
elif cur > 0:
|
|
1046
|
+
in_progress.append((ab.abil_id, ab.token, ab.name, cur, req))
|
|
1047
|
+
out.append({"slot_no": k, "name": OLD_SLOT_NAMES.get(k), "menu_type": menu_type,
|
|
1048
|
+
"total": total, "mastered": mastered, "in_progress": in_progress})
|
|
1049
|
+
return out
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def main_report(pt) -> ItemReport:
|
|
1053
|
+
"""An :class:`ItemReport` for a decrypted main block (gil + inventory + equipment + key items + stats + AP)."""
|
|
1054
|
+
return ItemReport(gil=read_main_gil(pt), inventory=read_main_inventory(pt),
|
|
1055
|
+
equipment=read_main_equipment(pt), keyitems=read_main_keyitems(pt),
|
|
1056
|
+
stats=read_main_stats(pt), abilities=read_main_abilities(pt))
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def decode_main_block(container, block):
|
|
1060
|
+
"""Decrypt + decode the gil/inventory of one block of a ``SavedData_ww.dat`` container, or ``None`` if it's
|
|
1061
|
+
not a populated/old-format block. Needs pycryptodome (via :class:`save.FF9Save`)."""
|
|
1062
|
+
sv = _save.FF9Save.load(container)
|
|
1063
|
+
try:
|
|
1064
|
+
pt = bytearray(_decrypt_main(sv, block))
|
|
1065
|
+
except ValueError:
|
|
1066
|
+
return None
|
|
1067
|
+
if pt[:4] != b"SAVE":
|
|
1068
|
+
return None
|
|
1069
|
+
try:
|
|
1070
|
+
validate_main_block(pt)
|
|
1071
|
+
except ValueError:
|
|
1072
|
+
return None
|
|
1073
|
+
return main_report(pt)
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def _decrypt_main(sv, block: int) -> bytes:
|
|
1077
|
+
"""Decrypt save block ``block`` of ``sv`` (a :class:`save.FF9Save`), translating a bad block index into a
|
|
1078
|
+
clean ValueError (the module's contract -- not a raw IndexError / a wrong block from a negative index)."""
|
|
1079
|
+
if not isinstance(block, int) or isinstance(block, bool) or block < 0:
|
|
1080
|
+
raise ValueError(f"block must be a non-negative int (got {block!r})")
|
|
1081
|
+
try:
|
|
1082
|
+
return sv._decrypt_block(block)
|
|
1083
|
+
except IndexError as e:
|
|
1084
|
+
raise ValueError(f"no save block {block} in this container ({e})") from e
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def set_main_gil(container, block: int, gil: int, *, dry_run: bool = True, backup: bool = True) -> GilWriteReport:
|
|
1088
|
+
"""Write ``40000_Common/gil`` into the ENCRYPTED MAIN block (``block``) of a ``SavedData_ww.dat`` container
|
|
1089
|
+
-- the path to editing a save that has **no Memoria extra file** (a vanilla save), and the basis of the gil
|
|
1090
|
+
main-mirror. gil sits at the fixed :data:`MAIN_GIL_OFF` (UInt32 LE) in the old format; the block is
|
|
1091
|
+
decrypt → edit → re-encrypt (AES-CBC round-trips the untouched bytes, so only the gil's ciphertext moves).
|
|
1092
|
+
|
|
1093
|
+
Same safety as the extra writers: validates the block is a clean old-format save (:func:`validate_main_block`)
|
|
1094
|
+
FIRST -- refusing rather than corrupting an unrecognised layout -- then an atomic, timestamped-backup,
|
|
1095
|
+
post-write-re-read-confirmed write of the whole container. ``dry_run`` by default; a no-op writes nothing."""
|
|
1096
|
+
if isinstance(gil, bool) or not isinstance(gil, int):
|
|
1097
|
+
raise TypeError(f"gil must be an int (got {type(gil).__name__})")
|
|
1098
|
+
if gil < 0 or gil > GIL_CAP:
|
|
1099
|
+
raise ValueError(f"gil must be in [0, {GIL_CAP:,}] (the in-game cap); got {gil:,}")
|
|
1100
|
+
try:
|
|
1101
|
+
raw = open(container, "rb").read()
|
|
1102
|
+
except OSError as e:
|
|
1103
|
+
raise ValueError(f"cannot read save container {container!r}: {e}") from e
|
|
1104
|
+
sv = _save.FF9Save.load(container)
|
|
1105
|
+
pt = bytearray(_decrypt_main(sv, block))
|
|
1106
|
+
validate_main_block(pt) # GATE: refuse an unrecognised layout
|
|
1107
|
+
old = read_main_gil(pt)
|
|
1108
|
+
pt[MAIN_GIL_OFF:MAIN_GIL_OFF + 4] = int(gil).to_bytes(4, "little")
|
|
1109
|
+
backup_path, did_write = None, False
|
|
1110
|
+
if not dry_run and old != gil:
|
|
1111
|
+
sv._encrypt_block(block, bytes(pt)) # re-encrypt the edited block into sv.data
|
|
1112
|
+
backup_path = _atomic_write(container, raw, bytes(sv.data), backup=backup)
|
|
1113
|
+
chk = _save.FF9Save.load(container) # CONFIRM the write took
|
|
1114
|
+
if read_main_gil(bytearray(chk._decrypt_block(block))) != gil:
|
|
1115
|
+
raise AssertionError(f"post-write check failed: main-block gil did not read back as {gil:,}")
|
|
1116
|
+
did_write = True
|
|
1117
|
+
return GilWriteReport(path=f"{container}#block{block}", old_gil=old, new_gil=gil,
|
|
1118
|
+
bytes_changed=4, wrote=did_write, backup_path=backup_path)
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def set_main_item(container, block: int, item, count: int, *, dry_run: bool = True,
|
|
1122
|
+
backup: bool = True) -> ItemWriteReport:
|
|
1123
|
+
"""Set an item's COUNT in the ENCRYPTED MAIN block's 256-pair item array (for editing a vanilla/no-extra
|
|
1124
|
+
save). ``count`` 0 removes the stack (-> padding ``{0, 255}``, which loads cleanly); otherwise the count is
|
|
1125
|
+
updated in place, or the item is added at the first free slot. ``count`` clamps to 99; ``NoItem`` rejected.
|
|
1126
|
+
Same safety as :func:`set_main_gil`: ``validate_main_block`` gate, a scoped check that ONLY the item-array
|
|
1127
|
+
bytes moved, atomic container write, timestamped backup, post-write re-read confirm, dry-run default."""
|
|
1128
|
+
iid = _items.resolve(item)
|
|
1129
|
+
if iid == NO_ITEM:
|
|
1130
|
+
raise ValueError("cannot add NoItem (255); pass count=0 to REMOVE an item instead")
|
|
1131
|
+
if isinstance(count, bool) or not isinstance(count, int):
|
|
1132
|
+
raise TypeError(f"count must be an int (got {type(count).__name__})")
|
|
1133
|
+
if count < 0:
|
|
1134
|
+
raise ValueError(f"count cannot be negative (got {count}); use 0 to remove")
|
|
1135
|
+
count = min(count, ITEM_COUNT_CAP)
|
|
1136
|
+
try:
|
|
1137
|
+
raw = open(container, "rb").read()
|
|
1138
|
+
except OSError as e:
|
|
1139
|
+
raise ValueError(f"cannot read save container {container!r}: {e}") from e
|
|
1140
|
+
sv = _save.FF9Save.load(container)
|
|
1141
|
+
orig_pt = bytes(_decrypt_main(sv, block))
|
|
1142
|
+
pt = bytearray(orig_pt)
|
|
1143
|
+
validate_main_block(pt) # GATE
|
|
1144
|
+
idx, old_count = None, 0
|
|
1145
|
+
for k in range(MAIN_ITEMS_N): # find the live stack for this id
|
|
1146
|
+
c, i = pt[MAIN_ITEMS_OFF + 2 * k], pt[MAIN_ITEMS_OFF + 2 * k + 1]
|
|
1147
|
+
if c >= 1 and i == iid:
|
|
1148
|
+
idx, old_count = k, c
|
|
1149
|
+
break
|
|
1150
|
+
edited = idx # the slot the edit touches (for a position-aware confirm)
|
|
1151
|
+
if count == 0:
|
|
1152
|
+
action = "removed" if idx is not None else "unchanged"
|
|
1153
|
+
if idx is not None:
|
|
1154
|
+
pt[MAIN_ITEMS_OFF + 2 * idx], pt[MAIN_ITEMS_OFF + 2 * idx + 1] = 0, NO_ITEM # -> clean padding
|
|
1155
|
+
elif idx is not None:
|
|
1156
|
+
action = "unchanged" if count == old_count else "changed"
|
|
1157
|
+
pt[MAIN_ITEMS_OFF + 2 * idx] = count # keep id, update count
|
|
1158
|
+
else:
|
|
1159
|
+
action = "added" # reserve the last slot as the padding terminator
|
|
1160
|
+
edited = next((k for k in range(MAIN_ITEMS_N - 1) if pt[MAIN_ITEMS_OFF + 2 * k] == 0), None)
|
|
1161
|
+
if edited is None:
|
|
1162
|
+
raise ValueError("the inventory is full (255 stacks); cannot add another item")
|
|
1163
|
+
pt[MAIN_ITEMS_OFF + 2 * edited], pt[MAIN_ITEMS_OFF + 2 * edited + 1] = count, iid
|
|
1164
|
+
validate_main_block(pt) # still well-formed after the edit
|
|
1165
|
+
diff = [k for k in range(len(pt)) if pt[k] != orig_pt[k]] # SCOPED: only item-array bytes may move
|
|
1166
|
+
if diff and (min(diff) < MAIN_ITEMS_OFF or max(diff) >= MAIN_ITEMS_OFF + 2 * MAIN_ITEMS_N):
|
|
1167
|
+
raise AssertionError("main item edit touched bytes outside the item array; aborting")
|
|
1168
|
+
backup_path, did_write = None, False
|
|
1169
|
+
if not dry_run and diff:
|
|
1170
|
+
sv._encrypt_block(block, bytes(pt))
|
|
1171
|
+
backup_path = _atomic_write(container, raw, bytes(sv.data), backup=backup)
|
|
1172
|
+
chk = bytearray(_decrypt_main(_save.FF9Save.load(container), block)) # CONFIRM the exact slot
|
|
1173
|
+
gc, gi = chk[MAIN_ITEMS_OFF + 2 * edited], chk[MAIN_ITEMS_OFF + 2 * edited + 1]
|
|
1174
|
+
ok = (gc == 0) if count == 0 else (gc == count and gi == iid)
|
|
1175
|
+
if not ok:
|
|
1176
|
+
raise AssertionError(f"post-write check failed: slot {edited} read back (count {gc}, id {gi})")
|
|
1177
|
+
did_write = True
|
|
1178
|
+
return ItemWriteReport(path=f"{container}#block{block}", item_id=iid, item_name=_items.name_of(iid),
|
|
1179
|
+
old_count=old_count, new_count=count, action=action,
|
|
1180
|
+
wrote=did_write, backup_path=backup_path)
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def _resolve_old_slot(character) -> int:
|
|
1184
|
+
"""A ``character`` (a CharacterId 0-11, a digit string, or a name/alias incl. Cinna/Marcus/Blank) -> its
|
|
1185
|
+
OLD-format slot 0-8. Quina/Cinna -> 5, Eiko/Marcus -> 6, Amarant/Blank -> 7, Beatrix -> 8 (either name targets
|
|
1186
|
+
that shared slot; the slot's current gear shows who actually holds it). Raises ValueError on an unknown
|
|
1187
|
+
name / out-of-range CharacterId."""
|
|
1188
|
+
if isinstance(character, bool):
|
|
1189
|
+
raise ValueError("character cannot be a boolean")
|
|
1190
|
+
if isinstance(character, str) and character.strip().isdigit():
|
|
1191
|
+
character = int(character.strip())
|
|
1192
|
+
if isinstance(character, int):
|
|
1193
|
+
if character in _CHAR_TO_OLD_SLOT: # a CharacterId 0-11
|
|
1194
|
+
return _CHAR_TO_OLD_SLOT[character]
|
|
1195
|
+
raise ValueError(f"CharacterId {character} out of range (0-11)")
|
|
1196
|
+
key = str(character).strip().lower()
|
|
1197
|
+
cid = _CHAR_BY_NAME.get(key)
|
|
1198
|
+
if cid is None or cid not in _CHAR_TO_OLD_SLOT:
|
|
1199
|
+
raise ValueError(f"unknown character {character!r} (Zidane..Beatrix, Cinna/Marcus/Blank, Dagger/Salamander)")
|
|
1200
|
+
return _CHAR_TO_OLD_SLOT[cid]
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def set_main_equip(container, block: int, character, slot, item, *, dry_run: bool = True,
|
|
1204
|
+
backup: bool = True) -> EquipWriteReport:
|
|
1205
|
+
"""Set one equip ``slot`` of one ``character`` in the ENCRYPTED MAIN block (for editing a vanilla/no-extra
|
|
1206
|
+
save's equipment). ``character`` is a CharacterId 0-11 / name / alias (Beatrix = CharacterId 11; old-slots
|
|
1207
|
+
5-7 hold Quina/Eiko/Amarant OR the story temp Cinna/Marcus/Blank, either name -> that shared slot -- check the
|
|
1208
|
+
slot's current gear). ``item`` is a name/id, or
|
|
1209
|
+
``None``/255/"empty" to unequip. Each player's equip is 5 BYTES at :data:`MAIN_EQUIP_OFF` ``+ stride*old_slot``;
|
|
1210
|
+
only that one byte moves. Same safety as :func:`set_main_item`: validate gate, a scoped byte-diff, atomic
|
|
1211
|
+
write, timestamped backup, position-aware post-write confirm, dry-run default. The engine resets an unknown
|
|
1212
|
+
id to NoItem + recomputes derived stats on load, so only the id is written."""
|
|
1213
|
+
slot_idx = _resolve_slot(slot)
|
|
1214
|
+
if item is None or (isinstance(item, str) and item.strip().lower() in ("none", "empty", "unequip", "")):
|
|
1215
|
+
iid = NO_ITEM
|
|
1216
|
+
else:
|
|
1217
|
+
iid = _items.resolve(item)
|
|
1218
|
+
old_slot = _resolve_old_slot(character)
|
|
1219
|
+
try:
|
|
1220
|
+
raw = open(container, "rb").read()
|
|
1221
|
+
except OSError as e:
|
|
1222
|
+
raise ValueError(f"cannot read save container {container!r}: {e}") from e
|
|
1223
|
+
sv = _save.FF9Save.load(container)
|
|
1224
|
+
orig_pt = bytes(_decrypt_main(sv, block))
|
|
1225
|
+
pt = bytearray(orig_pt)
|
|
1226
|
+
validate_main_block(pt) # GATE (gil/items confirm the old-format layout)
|
|
1227
|
+
pos = MAIN_EQUIP_OFF + MAIN_PLAYER_STRIDE * old_slot + slot_idx
|
|
1228
|
+
old_id = pt[pos]
|
|
1229
|
+
pt[pos] = iid
|
|
1230
|
+
diff = [k for k in range(len(pt)) if pt[k] != orig_pt[k]] # SCOPED: only that one equip byte may move
|
|
1231
|
+
if diff and diff != [pos]:
|
|
1232
|
+
raise AssertionError(f"main equip edit touched bytes other than slot {old_slot} {EQUIP_SLOTS[slot_idx]}; "
|
|
1233
|
+
"aborting")
|
|
1234
|
+
backup_path, did_write = None, False
|
|
1235
|
+
if not dry_run and diff:
|
|
1236
|
+
sv._encrypt_block(block, bytes(pt))
|
|
1237
|
+
backup_path = _atomic_write(container, raw, bytes(sv.data), backup=backup)
|
|
1238
|
+
chk = bytearray(_decrypt_main(_save.FF9Save.load(container), block)) # CONFIRM the exact byte
|
|
1239
|
+
if chk[pos] != iid:
|
|
1240
|
+
raise AssertionError(f"post-write check failed: equip byte read back {chk[pos]}, expected {iid}")
|
|
1241
|
+
did_write = True
|
|
1242
|
+
return EquipWriteReport(path=f"{container}#block{block}", slot_no=old_slot, character=OLD_SLOT_NAMES.get(old_slot),
|
|
1243
|
+
slot=EQUIP_SLOTS[slot_idx], old_id=old_id,
|
|
1244
|
+
old_name=(None if old_id == NO_ITEM else _items.name_of(old_id)),
|
|
1245
|
+
new_id=iid, new_name=(None if iid == NO_ITEM else _items.name_of(iid)),
|
|
1246
|
+
wrote=did_write, backup_path=backup_path)
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def set_main_keyitem(container, block: int, keyitem, *, obtained: bool = True, used: bool = False,
|
|
1250
|
+
dry_run: bool = True, backup: bool = True) -> KeyItemWriteReport:
|
|
1251
|
+
"""Set a KEY item's state in the ENCRYPTED MAIN block's 64-byte 2-bit ``rareItems`` bitfield (for a
|
|
1252
|
+
vanilla/no-extra save). Flips exactly the 2 bits for ``keyitem`` (a name / 0-255 id). Same safety as the
|
|
1253
|
+
other main writers: ``validate_main_block`` gate, a scoped byte-diff (only that one byte moves), atomic
|
|
1254
|
+
write, timestamped backup, post-write confirm, dry-run default."""
|
|
1255
|
+
iid = _keyitems.resolve(keyitem)
|
|
1256
|
+
try:
|
|
1257
|
+
raw = open(container, "rb").read()
|
|
1258
|
+
except OSError as e:
|
|
1259
|
+
raise ValueError(f"cannot read save container {container!r}: {e}") from e
|
|
1260
|
+
sv = _save.FF9Save.load(container)
|
|
1261
|
+
orig_pt = bytes(_decrypt_main(sv, block))
|
|
1262
|
+
pt = bytearray(orig_pt)
|
|
1263
|
+
validate_main_block(pt) # GATE (gil/items confirm the old-format layout)
|
|
1264
|
+
pos = MAIN_RAREITEMS_OFF + iid // 4
|
|
1265
|
+
shift = (iid % 4) * 2
|
|
1266
|
+
old = pt[pos]
|
|
1267
|
+
old_ob, old_us = bool(old & (1 << shift)), bool(old & (1 << (shift + 1)))
|
|
1268
|
+
nv = old & ~(0b11 << shift) # clear this item's 2 bits, then set per request
|
|
1269
|
+
if obtained:
|
|
1270
|
+
nv |= 1 << shift
|
|
1271
|
+
if used:
|
|
1272
|
+
nv |= 1 << (shift + 1)
|
|
1273
|
+
pt[pos] = nv
|
|
1274
|
+
if not obtained and not used:
|
|
1275
|
+
action = "removed" if (old_ob or old_us) else "unchanged"
|
|
1276
|
+
elif (obtained, used) == (old_ob, old_us):
|
|
1277
|
+
action = "unchanged"
|
|
1278
|
+
else:
|
|
1279
|
+
action = "changed" if (old_ob or old_us) else "added"
|
|
1280
|
+
diff = [k for k in range(len(pt)) if pt[k] != orig_pt[k]] # SCOPED: only that one bitfield byte may move
|
|
1281
|
+
if diff and diff != [pos]:
|
|
1282
|
+
raise AssertionError("main key-item edit touched bytes other than its rareItems byte; aborting")
|
|
1283
|
+
backup_path, did_write = None, False
|
|
1284
|
+
if not dry_run and diff:
|
|
1285
|
+
sv._encrypt_block(block, bytes(pt))
|
|
1286
|
+
backup_path = _atomic_write(container, raw, bytes(sv.data), backup=backup)
|
|
1287
|
+
chk = bytearray(_decrypt_main(_save.FF9Save.load(container), block)) # CONFIRM the 2 bits
|
|
1288
|
+
cv = chk[pos]
|
|
1289
|
+
if (bool(cv & (1 << shift)), bool(cv & (1 << (shift + 1)))) != (obtained, used):
|
|
1290
|
+
raise AssertionError(f"post-write check failed: key item {iid} bits read back wrong")
|
|
1291
|
+
did_write = True
|
|
1292
|
+
return KeyItemWriteReport(path=f"{container}#block{block}", item_id=iid, item_name=_keyitems.name_of(iid),
|
|
1293
|
+
obtained=obtained, used=used, action=action, wrote=did_write, backup_path=backup_path)
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def set_main_stat(container, block: int, character, stat, target: int, *, dry_run: bool = True,
|
|
1297
|
+
backup: bool = True) -> StatWriteReport:
|
|
1298
|
+
"""Set a character's permanent growth STAT in the ENCRYPTED MAIN block (for a vanilla/no-extra save). Writes
|
|
1299
|
+
the ``basis`` Byte (displayed) + the ``bonus`` UInt16 (the equipment accumulator) for that old-slot, same as
|
|
1300
|
+
:func:`set_stat_extra`. ``target`` clamps to the stat cap. Validate gate + scoped byte-diff (only those <=3
|
|
1301
|
+
bytes move) + atomic write + backup + post-write confirm + dry-run."""
|
|
1302
|
+
field = _resolve_stat(stat)
|
|
1303
|
+
if isinstance(target, bool) or not isinstance(target, int):
|
|
1304
|
+
raise TypeError(f"target must be an int (got {type(target).__name__})")
|
|
1305
|
+
if target < 0:
|
|
1306
|
+
raise ValueError(f"target stat cannot be negative (got {target})")
|
|
1307
|
+
target = min(target, STAT_CAPS[field])
|
|
1308
|
+
old_slot = _resolve_old_slot(character)
|
|
1309
|
+
try:
|
|
1310
|
+
raw = open(container, "rb").read()
|
|
1311
|
+
except OSError as e:
|
|
1312
|
+
raise ValueError(f"cannot read save container {container!r}: {e}") from e
|
|
1313
|
+
sv = _save.FF9Save.load(container)
|
|
1314
|
+
orig_pt = bytes(_decrypt_main(sv, block))
|
|
1315
|
+
pt = bytearray(orig_pt)
|
|
1316
|
+
validate_main_block(pt) # GATE
|
|
1317
|
+
bpos = MAIN_BASIS_OFF + MAIN_PLAYER_STRIDE * old_slot + _BASIS_STAT_BYTE[field]
|
|
1318
|
+
opos = MAIN_BONUS_OFF + MAIN_PLAYER_STRIDE * old_slot + _BONUS_STAT_OFF[field]
|
|
1319
|
+
old_basis = pt[bpos]
|
|
1320
|
+
old_bonus = int.from_bytes(pt[opos:opos + 2], "little")
|
|
1321
|
+
new_bonus = _new_bonus_for(target, old_basis, old_bonus)
|
|
1322
|
+
pt[bpos] = target
|
|
1323
|
+
pt[opos:opos + 2] = new_bonus.to_bytes(2, "little")
|
|
1324
|
+
diff = [k for k in range(len(pt)) if pt[k] != orig_pt[k]] # SCOPED: only the basis byte + the bonus UInt16
|
|
1325
|
+
if diff and any(k not in (bpos, opos, opos + 1) for k in diff):
|
|
1326
|
+
raise AssertionError(f"main stat edit touched bytes outside {field}'s basis/bonus; aborting")
|
|
1327
|
+
backup_path, did_write = None, False
|
|
1328
|
+
if not dry_run and diff:
|
|
1329
|
+
sv._encrypt_block(block, bytes(pt))
|
|
1330
|
+
backup_path = _atomic_write(container, raw, bytes(sv.data), backup=backup)
|
|
1331
|
+
chk = bytearray(_decrypt_main(_save.FF9Save.load(container), block)) # CONFIRM
|
|
1332
|
+
if chk[bpos] != target or int.from_bytes(chk[opos:opos + 2], "little") != new_bonus:
|
|
1333
|
+
raise AssertionError(f"post-write check failed: {field} basis/bonus read back wrong")
|
|
1334
|
+
did_write = True
|
|
1335
|
+
return StatWriteReport(path=f"{container}#block{block}", slot_no=old_slot, character=OLD_SLOT_NAMES.get(old_slot),
|
|
1336
|
+
stat=STAT_LABELS[field], old_value=old_basis, new_value=target,
|
|
1337
|
+
old_bonus=old_bonus, new_bonus=new_bonus, wrote=did_write, backup_path=backup_path)
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def set_main_ap(container, block: int, character, ability, value="master", *, dry_run: bool = True,
|
|
1341
|
+
backup: bool = True) -> AbilityWriteReport:
|
|
1342
|
+
"""Set ability AP / mastery in the ENCRYPTED MAIN block's ``pa`` array (for a vanilla/no-extra save), the
|
|
1343
|
+
main-block twin of :func:`set_ap_extra`. ``ability`` = a name / ``AA:X`` / ``SA:X`` / id / ``all``; ``value`` =
|
|
1344
|
+
``master`` / ``max`` / ``forget`` / a number. The OLD format keys AP by pool POSITION (not id), so a single
|
|
1345
|
+
ability resolves to its index in the slot's live pool (``info.menu_type``); on a vanilla save that's the base
|
|
1346
|
+
pool, so names resolve. ``all`` needs no pool order (sets every position) and is fully mod-safe. Validate gate
|
|
1347
|
+
+ scoped byte-diff (only this slot's 48 ``pa`` bytes move) + atomic + backup + post-write confirm + dry-run."""
|
|
1348
|
+
old_slot = _resolve_old_slot(character)
|
|
1349
|
+
try:
|
|
1350
|
+
raw = open(container, "rb").read()
|
|
1351
|
+
except OSError as e:
|
|
1352
|
+
raise ValueError(f"cannot read save container {container!r}: {e}") from e
|
|
1353
|
+
sv = _save.FF9Save.load(container)
|
|
1354
|
+
orig_pt = bytes(_decrypt_main(sv, block))
|
|
1355
|
+
pt = bytearray(orig_pt)
|
|
1356
|
+
validate_main_block(pt) # GATE
|
|
1357
|
+
base = MAIN_PA_OFF + MAIN_PLAYER_STRIDE * old_slot
|
|
1358
|
+
menu_type = pt[MAIN_MENU_TYPE_OFF + MAIN_PLAYER_STRIDE * old_slot]
|
|
1359
|
+
pool = _abilities.pool_for_preset(menu_type)
|
|
1360
|
+
sslot = OLD_SLOT_NAMES.get(old_slot)
|
|
1361
|
+
|
|
1362
|
+
is_all = isinstance(ability, str) and ability.strip().lower() == "all"
|
|
1363
|
+
if is_all: # -------- every pool position --------
|
|
1364
|
+
n = len(pool) if pool else MAIN_PA_LEN # no pool (install unreachable) -> set all 48 (mod-safe)
|
|
1365
|
+
n_changed, pool_total, n_master = 0, 0, 0
|
|
1366
|
+
for i in range(min(n, MAIN_PA_LEN)):
|
|
1367
|
+
ab = pool[i] if i < len(pool) else None
|
|
1368
|
+
if ab is not None and ab.abil_id == 0:
|
|
1369
|
+
continue
|
|
1370
|
+
req = ab.ap_req if ab is not None else None
|
|
1371
|
+
new_ap = _resolve_ap_value(value, req)
|
|
1372
|
+
pool_total += 1
|
|
1373
|
+
if pt[base + i] != new_ap:
|
|
1374
|
+
pt[base + i] = new_ap
|
|
1375
|
+
n_changed += 1
|
|
1376
|
+
if (req and new_ap >= req) or (req is None and new_ap >= AP_CAP):
|
|
1377
|
+
n_master += 1
|
|
1378
|
+
abil_id, token, ability_name, old_ap, new_ap_rep, ap_req = -1, "all", None, 0, 0, None
|
|
1379
|
+
else: # -------- a single ability (by pool index) --------
|
|
1380
|
+
abil_id = _abilities.resolve(menu_type, ability)
|
|
1381
|
+
idx = next((i for i, ab in enumerate(pool) if ab.abil_id == abil_id and i < MAIN_PA_LEN), None)
|
|
1382
|
+
if idx is None:
|
|
1383
|
+
have = ", ".join(ab.token for ab in pool[:MAIN_PA_LEN] if ab.abil_id != 0) or "(pool unavailable)"
|
|
1384
|
+
raise ValueError(f"{sslot or character} has no ability {ability!r} ({_abilities.decode_token(abil_id)}) "
|
|
1385
|
+
f"in their (main-block) pool; the editor only changes abilities present.\n present: {have}")
|
|
1386
|
+
ap_req = pool[idx].ap_req
|
|
1387
|
+
token, ability_name = pool[idx].token, pool[idx].name
|
|
1388
|
+
old_ap, new_ap_rep = pt[base + idx], _resolve_ap_value(value, ap_req)
|
|
1389
|
+
pt[base + idx] = new_ap_rep
|
|
1390
|
+
|
|
1391
|
+
diff = [k for k in range(len(pt)) if pt[k] != orig_pt[k]] # SCOPED: only this slot's pa bytes
|
|
1392
|
+
if diff and any(not (base <= k < base + MAIN_PA_LEN) for k in diff):
|
|
1393
|
+
raise AssertionError("main AP edit touched bytes outside this slot's pa array; aborting")
|
|
1394
|
+
backup_path, did_write = None, False
|
|
1395
|
+
if not dry_run and diff:
|
|
1396
|
+
sv._encrypt_block(block, bytes(pt))
|
|
1397
|
+
backup_path = _atomic_write(container, raw, bytes(sv.data), backup=backup)
|
|
1398
|
+
chk = bytearray(_decrypt_main(_save.FF9Save.load(container), block)) # CONFIRM
|
|
1399
|
+
if bytes(chk[base:base + MAIN_PA_LEN]) != bytes(pt[base:base + MAIN_PA_LEN]):
|
|
1400
|
+
raise AssertionError("post-write check failed: pa array read back wrong")
|
|
1401
|
+
did_write = True
|
|
1402
|
+
if is_all:
|
|
1403
|
+
vl = str(value).strip().lower()
|
|
1404
|
+
if _resolve_ap_value(value, None) == 0:
|
|
1405
|
+
label, is_master = "forgot", False
|
|
1406
|
+
elif pool_total and n_master == pool_total:
|
|
1407
|
+
label, is_master = "mastered", True
|
|
1408
|
+
else:
|
|
1409
|
+
label, is_master = "set", False
|
|
1410
|
+
return AbilityWriteReport(path=f"{container}#block{block}", slot_no=old_slot, character=sslot, abil_id=-1,
|
|
1411
|
+
token="all", ability_name=None, old_ap=0, new_ap=0, ap_req=None, mastered=is_master,
|
|
1412
|
+
action=label if n_changed else "unchanged", count=n_changed,
|
|
1413
|
+
pool_total=pool_total, wrote=did_write, backup_path=backup_path)
|
|
1414
|
+
mastered = (ap_req is not None and new_ap_rep >= ap_req) or (ap_req is None and new_ap_rep >= AP_CAP)
|
|
1415
|
+
action = "unchanged" if old_ap == new_ap_rep else ("forgot" if new_ap_rep == 0 else ("mastered" if mastered else "changed"))
|
|
1416
|
+
return AbilityWriteReport(path=f"{container}#block{block}", slot_no=old_slot, character=sslot, abil_id=abil_id,
|
|
1417
|
+
token=token, ability_name=ability_name, old_ap=old_ap, new_ap=new_ap_rep, ap_req=ap_req,
|
|
1418
|
+
mastered=mastered, action=action, count=1, wrote=did_write, backup_path=backup_path)
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def set_gil_in_save(container, block: int, gil: int, *, dry_run: bool = True, backup: bool = True,
|
|
1422
|
+
mirror: bool = True) -> dict:
|
|
1423
|
+
"""Write gil into a whole save SLOT: the ENCRYPTED MAIN block AND (when ``mirror`` and it exists) the Memoria
|
|
1424
|
+
EXTRA file. For a no-extra (vanilla) save only the main block is written; for a Memoria save both are written
|
|
1425
|
+
so the load-authoritative extra and the main block stay consistent. Returns ``{"main": GilWriteReport,
|
|
1426
|
+
"extra": GilWriteReport|None}``. Each leg is independently dry-run/backup-guarded by its own writer.
|
|
1427
|
+
|
|
1428
|
+
★ The EXTRA (load-authoritative) leg is written FIRST: the legs aren't transactional across files, so if the
|
|
1429
|
+
second (main) leg then fails, the extra already holds the new value -- the game shows the EDIT (correct), and
|
|
1430
|
+
only the main-block fallback is left stale (recoverable from its ``.bak``). The reverse order would silently
|
|
1431
|
+
show the OLD value in-game on a partial failure. An extra-leg failure raises before the main is touched."""
|
|
1432
|
+
extra_rep = None
|
|
1433
|
+
if mirror: # the EXTRA is load-authoritative -> write it FIRST
|
|
1434
|
+
extra = _save.extra_file_path(container, block)
|
|
1435
|
+
if extra and os.path.isfile(extra):
|
|
1436
|
+
extra_rep = set_gil(extra, gil, dry_run=dry_run, backup=backup)
|
|
1437
|
+
main_rep = set_main_gil(container, block, gil, dry_run=dry_run, backup=backup)
|
|
1438
|
+
return {"main": main_rep, "extra": extra_rep}
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def set_item_in_save(container, block: int, item, count: int, *, dry_run: bool = True, backup: bool = True,
|
|
1442
|
+
mirror: bool = True) -> dict:
|
|
1443
|
+
"""Set an item's count in a whole save SLOT: the ENCRYPTED MAIN block AND (when ``mirror`` + present) the
|
|
1444
|
+
Memoria EXTRA. Vanilla save -> main only. Returns ``{"main": ItemWriteReport, "extra": ItemWriteReport|None}``."""
|
|
1445
|
+
extra_rep = None
|
|
1446
|
+
if mirror: # the EXTRA is load-authoritative -> write it first
|
|
1447
|
+
extra = _save.extra_file_path(container, block)
|
|
1448
|
+
if extra and os.path.isfile(extra):
|
|
1449
|
+
extra_rep = set_item(extra, item, count, dry_run=dry_run, backup=backup)
|
|
1450
|
+
main_rep = set_main_item(container, block, item, count, dry_run=dry_run, backup=backup)
|
|
1451
|
+
return {"main": main_rep, "extra": extra_rep}
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def set_equip_in_save(container, block: int, character, slot, item, *, dry_run: bool = True, backup: bool = True,
|
|
1455
|
+
mirror: bool = True) -> dict:
|
|
1456
|
+
"""Set one equip slot in a whole save SLOT: the MAIN block AND (when ``mirror`` + present) the Memoria EXTRA.
|
|
1457
|
+
Vanilla -> main only. ★ The extra keys equip by CharacterId (12 players) and the main by OLD-slot (9); both
|
|
1458
|
+
resolve ``character`` independently, so the same name targets the matching player in each. Returns
|
|
1459
|
+
``{"main": EquipWriteReport, "extra": EquipWriteReport|None}`` (extra written FIRST -- it's load-authoritative)."""
|
|
1460
|
+
extra_rep = None
|
|
1461
|
+
if mirror:
|
|
1462
|
+
extra = _save.extra_file_path(container, block)
|
|
1463
|
+
if extra and os.path.isfile(extra):
|
|
1464
|
+
extra_rep = set_equip(extra, character, slot, item, dry_run=dry_run, backup=backup)
|
|
1465
|
+
main_rep = set_main_equip(container, block, character, slot, item, dry_run=dry_run, backup=backup)
|
|
1466
|
+
return {"main": main_rep, "extra": extra_rep}
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
def set_keyitem_in_save(container, block: int, keyitem, *, obtained: bool = True, used: bool = False,
|
|
1470
|
+
dry_run: bool = True, backup: bool = True, mirror: bool = True) -> dict:
|
|
1471
|
+
"""Set a key item in a whole save SLOT: the MAIN block's ``rareItems`` bitfield AND (when ``mirror`` +
|
|
1472
|
+
present) the Memoria EXTRA's ``rareItemsEx``. Vanilla -> main only. Returns ``{"main": KeyItemWriteReport,
|
|
1473
|
+
"extra": KeyItemWriteReport|None}`` (extra written FIRST -- it's load-authoritative)."""
|
|
1474
|
+
extra_rep = None
|
|
1475
|
+
if mirror:
|
|
1476
|
+
extra = _save.extra_file_path(container, block)
|
|
1477
|
+
if extra and os.path.isfile(extra):
|
|
1478
|
+
extra_rep = set_keyitem_extra(extra, keyitem, obtained=obtained, used=used,
|
|
1479
|
+
dry_run=dry_run, backup=backup)
|
|
1480
|
+
main_rep = set_main_keyitem(container, block, keyitem, obtained=obtained, used=used,
|
|
1481
|
+
dry_run=dry_run, backup=backup)
|
|
1482
|
+
return {"main": main_rep, "extra": extra_rep}
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
def set_stat_in_save(container, block: int, character, stat, target: int, *, dry_run: bool = True,
|
|
1486
|
+
backup: bool = True, mirror: bool = True) -> dict:
|
|
1487
|
+
"""Set a growth stat in a whole save SLOT: the MAIN block (basis+bonus) AND (when ``mirror`` + present) the
|
|
1488
|
+
Memoria EXTRA. Vanilla -> main only. Returns ``{"main": StatWriteReport, "extra": StatWriteReport|None}``
|
|
1489
|
+
(extra written FIRST -- load-authoritative)."""
|
|
1490
|
+
extra_rep = None
|
|
1491
|
+
if mirror:
|
|
1492
|
+
extra = _save.extra_file_path(container, block)
|
|
1493
|
+
if extra and os.path.isfile(extra):
|
|
1494
|
+
extra_rep = set_stat_extra(extra, character, stat, target, dry_run=dry_run, backup=backup)
|
|
1495
|
+
main_rep = set_main_stat(container, block, character, stat, target, dry_run=dry_run, backup=backup)
|
|
1496
|
+
return {"main": main_rep, "extra": extra_rep}
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
def set_ap_in_save(container, block: int, character, ability, value="master", *, dry_run: bool = True,
|
|
1500
|
+
backup: bool = True, mirror: bool = True) -> dict:
|
|
1501
|
+
"""Set ability AP / mastery in a whole save SLOT: the MAIN block's ``pa`` array AND (when ``mirror`` + present)
|
|
1502
|
+
the Memoria EXTRA's ``pa_extended``. Vanilla -> main only. Returns ``{"main": AbilityWriteReport, "extra":
|
|
1503
|
+
AbilityWriteReport|None}`` (extra written FIRST -- it's load-authoritative). ★ The extra keys AP by id (the
|
|
1504
|
+
save's own pool = the source of truth) and the main by pool POSITION; on a modded (Moguri) Memoria save a
|
|
1505
|
+
single-ability main leg may index a different slot, but the extra wins on load. ``all`` is exact on both."""
|
|
1506
|
+
extra_rep = None
|
|
1507
|
+
if mirror: # the EXTRA is load-authoritative -> write it FIRST
|
|
1508
|
+
extra = _save.extra_file_path(container, block)
|
|
1509
|
+
if extra and os.path.isfile(extra):
|
|
1510
|
+
extra_rep = set_ap_extra(extra, character, ability, value, dry_run=dry_run, backup=backup)
|
|
1511
|
+
main_rep = set_main_ap(container, block, character, ability, value, dry_run=dry_run, backup=backup)
|
|
1512
|
+
return {"main": main_rep, "extra": extra_rep}
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
# --- rendering ------------------------------------------------------------------------------------
|
|
1516
|
+
|
|
1517
|
+
def render_report(rep: "ItemReport | None") -> str:
|
|
1518
|
+
"""A human-readable items/equipment/gil report (the read surface's display; mirrors flags.render_report)."""
|
|
1519
|
+
if rep is None:
|
|
1520
|
+
return " (no Memoria extra file for this slot)"
|
|
1521
|
+
lines = [f" Gil: {rep.gil:,}" if rep.gil is not None else " Gil: (none)"]
|
|
1522
|
+
lines.append(f" Inventory ({len(rep.inventory)} stacks):")
|
|
1523
|
+
for iid, name, count in rep.inventory:
|
|
1524
|
+
lines.append(f" {count:>3} x {name or '?'} (id {iid})")
|
|
1525
|
+
lines.append(" Equipment:")
|
|
1526
|
+
for pc in rep.equipment:
|
|
1527
|
+
worn = ", ".join(f"{slot}={pc['equip'][slot][1] or '?'}" for slot in EQUIP_SLOTS if pc["equip"].get(slot))
|
|
1528
|
+
lines.append(f" {pc['name'] or '?':<10} {worn or '(nothing equipped)'}")
|
|
1529
|
+
if rep.keyitems:
|
|
1530
|
+
held = [(i, n) for i, n, ob, us in rep.keyitems if ob]
|
|
1531
|
+
lines.append(f" Key items ({len(held)} held):")
|
|
1532
|
+
lines.append(" " + ", ".join(n or f"id {i}" for i, n in held) if held else " (none)")
|
|
1533
|
+
if rep.abilities:
|
|
1534
|
+
lines.append(" Abilities (mastered / in pool):")
|
|
1535
|
+
for pc in rep.abilities:
|
|
1536
|
+
ms = ", ".join(n or t for _, t, n in pc["mastered"][:8])
|
|
1537
|
+
more = f", +{len(pc['mastered']) - 8} more" if len(pc["mastered"]) > 8 else ""
|
|
1538
|
+
lines.append(f" {pc['name'] or '?':<10} {len(pc['mastered'])}/{pc['total']}"
|
|
1539
|
+
+ (f" ({ms}{more})" if ms else ""))
|
|
1540
|
+
return "\n".join(lines)
|
|
1541
|
+
|
|
1542
|
+
|
|
1543
|
+
def render_keyitem_write(rep: KeyItemWriteReport) -> str:
|
|
1544
|
+
"""A human-readable summary of a :func:`set_keyitem_extra` / :func:`set_main_keyitem` outcome."""
|
|
1545
|
+
name = rep.item_name or f"key item id {rep.item_id}"
|
|
1546
|
+
if rep.action == "unchanged":
|
|
1547
|
+
return f" {name} already obtained={rep.obtained} used={rep.used} in {rep.path} -- nothing to change."
|
|
1548
|
+
verb = {"added": "give", "changed": "set", "removed": "remove"}[rep.action]
|
|
1549
|
+
head = "WROTE" if rep.wrote else "DRY RUN -- would"
|
|
1550
|
+
flags = "removed" if rep.action == "removed" else f"obtained={rep.obtained}, used={rep.used}"
|
|
1551
|
+
lines = [f" {head} {verb} key item {name} ({flags}) in {rep.path}"]
|
|
1552
|
+
lines.append(f" Backup: {rep.backup_path}" if rep.backup_path else
|
|
1553
|
+
(" (--no-backup: no backup written)" if rep.wrote else
|
|
1554
|
+
" Re-run with --apply to write (a .bak backup is made first unless --no-backup)."))
|
|
1555
|
+
return "\n".join(lines)
|
|
1556
|
+
|
|
1557
|
+
|
|
1558
|
+
def render_gil_write(rep: GilWriteReport) -> str:
|
|
1559
|
+
"""A human-readable summary of a :func:`set_gil` outcome (dry-run preview or applied write)."""
|
|
1560
|
+
if rep.old_gil == rep.new_gil:
|
|
1561
|
+
return f" Gil already {rep.new_gil:,} in {rep.path} -- nothing to change."
|
|
1562
|
+
head = "WROTE" if rep.wrote else "DRY RUN -- would change"
|
|
1563
|
+
lines = [f" {head} gil {rep.old_gil:,} -> {rep.new_gil:,} in {rep.path} ({rep.bytes_changed} bytes)"]
|
|
1564
|
+
if rep.wrote:
|
|
1565
|
+
if rep.backup_path:
|
|
1566
|
+
lines.append(f" Backup: {rep.backup_path}")
|
|
1567
|
+
lines.append(" Load this save in-game and check the gil -- if it now reads the new value, the extra "
|
|
1568
|
+
"file overrides the encrypted main block on load (the step-3 proof).")
|
|
1569
|
+
else:
|
|
1570
|
+
lines.append(" Re-run with --apply to write (a .bak backup is made first unless --no-backup).")
|
|
1571
|
+
return "\n".join(lines)
|
|
1572
|
+
|
|
1573
|
+
|
|
1574
|
+
def render_gil_dual(res: dict) -> str:
|
|
1575
|
+
"""Render a :func:`set_gil_in_save` outcome (the main block + the extra mirror)."""
|
|
1576
|
+
lines = [" [main block]"]
|
|
1577
|
+
lines += [" " + ln for ln in render_gil_write(res["main"]).splitlines()]
|
|
1578
|
+
if res.get("extra") is not None:
|
|
1579
|
+
lines.append(" [Memoria extra -- the load-authoritative store]")
|
|
1580
|
+
lines += [" " + ln for ln in render_gil_write(res["extra"]).splitlines()]
|
|
1581
|
+
else:
|
|
1582
|
+
lines.append(" (no Memoria extra for this slot -- a vanilla save; the main block governs in-game)")
|
|
1583
|
+
return "\n".join(lines)
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
def render_item_dual(res: dict) -> str:
|
|
1587
|
+
"""Render a :func:`set_item_in_save` outcome (the main block + the extra mirror)."""
|
|
1588
|
+
lines = [" [main block]"]
|
|
1589
|
+
lines += [" " + ln for ln in render_item_write(res["main"]).splitlines()]
|
|
1590
|
+
if res.get("extra") is not None:
|
|
1591
|
+
lines.append(" [Memoria extra -- the load-authoritative store]")
|
|
1592
|
+
lines += [" " + ln for ln in render_item_write(res["extra"]).splitlines()]
|
|
1593
|
+
else:
|
|
1594
|
+
lines.append(" (no Memoria extra for this slot -- a vanilla save; the main block governs in-game)")
|
|
1595
|
+
return "\n".join(lines)
|
|
1596
|
+
|
|
1597
|
+
|
|
1598
|
+
def render_equip_dual(res: dict) -> str:
|
|
1599
|
+
"""Render a :func:`set_equip_in_save` outcome (the main block + the extra mirror)."""
|
|
1600
|
+
lines = [" [main block]"]
|
|
1601
|
+
lines += [" " + ln for ln in render_equip_write(res["main"]).splitlines()]
|
|
1602
|
+
if res.get("extra") is not None:
|
|
1603
|
+
lines.append(" [Memoria extra -- the load-authoritative store]")
|
|
1604
|
+
lines += [" " + ln for ln in render_equip_write(res["extra"]).splitlines()]
|
|
1605
|
+
else:
|
|
1606
|
+
lines.append(" (no Memoria extra for this slot -- a vanilla save; the main block governs in-game)")
|
|
1607
|
+
return "\n".join(lines)
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
def render_keyitem_dual(res: dict) -> str:
|
|
1611
|
+
"""Render a :func:`set_keyitem_in_save` outcome (the main block + the extra mirror)."""
|
|
1612
|
+
lines = [" [main block]"]
|
|
1613
|
+
lines += [" " + ln for ln in render_keyitem_write(res["main"]).splitlines()]
|
|
1614
|
+
if res.get("extra") is not None:
|
|
1615
|
+
lines.append(" [Memoria extra -- the load-authoritative store]")
|
|
1616
|
+
lines += [" " + ln for ln in render_keyitem_write(res["extra"]).splitlines()]
|
|
1617
|
+
else:
|
|
1618
|
+
lines.append(" (no Memoria extra for this slot -- a vanilla save; the main block governs in-game)")
|
|
1619
|
+
return "\n".join(lines)
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
def render_stat_dual(res: dict) -> str:
|
|
1623
|
+
"""Render a :func:`set_stat_in_save` outcome (the main block + the extra mirror)."""
|
|
1624
|
+
lines = [" [main block]"]
|
|
1625
|
+
lines += [" " + ln for ln in render_stat_write(res["main"]).splitlines()]
|
|
1626
|
+
if res.get("extra") is not None:
|
|
1627
|
+
lines.append(" [Memoria extra -- the load-authoritative store]")
|
|
1628
|
+
lines += [" " + ln for ln in render_stat_write(res["extra"]).splitlines()]
|
|
1629
|
+
else:
|
|
1630
|
+
lines.append(" (no Memoria extra for this slot -- a vanilla save; the main block governs in-game)")
|
|
1631
|
+
return "\n".join(lines)
|
|
1632
|
+
|
|
1633
|
+
|
|
1634
|
+
def render_ability_dual(res: dict) -> str:
|
|
1635
|
+
"""Render a :func:`set_ap_in_save` outcome (the main block + the extra mirror)."""
|
|
1636
|
+
lines = [" [main block]"]
|
|
1637
|
+
lines += [" " + ln for ln in render_ability_write(res["main"]).splitlines()]
|
|
1638
|
+
if res.get("extra") is not None:
|
|
1639
|
+
lines.append(" [Memoria extra -- the load-authoritative store]")
|
|
1640
|
+
lines += [" " + ln for ln in render_ability_write(res["extra"]).splitlines()]
|
|
1641
|
+
else:
|
|
1642
|
+
lines.append(" (no Memoria extra for this slot -- a vanilla save; the main block governs in-game)")
|
|
1643
|
+
return "\n".join(lines)
|
|
1644
|
+
|
|
1645
|
+
|
|
1646
|
+
def render_item_write(rep: ItemWriteReport) -> str:
|
|
1647
|
+
"""A human-readable summary of a :func:`set_item` outcome."""
|
|
1648
|
+
name = rep.item_name or f"id {rep.item_id}"
|
|
1649
|
+
if rep.action == "unchanged":
|
|
1650
|
+
return f" {name} already x{rep.old_count} in {rep.path} -- nothing to change."
|
|
1651
|
+
verb = {"added": f"add x{rep.new_count}", "changed": f"x{rep.old_count} -> x{rep.new_count}",
|
|
1652
|
+
"removed": f"remove (was x{rep.old_count})"}[rep.action]
|
|
1653
|
+
head = "WROTE" if rep.wrote else "DRY RUN -- would"
|
|
1654
|
+
lines = [f" {head} {verb} of {name} (id {rep.item_id}) in {rep.path}"]
|
|
1655
|
+
lines.append(f" Backup: {rep.backup_path}" if rep.backup_path else
|
|
1656
|
+
(" (--no-backup: no backup written)" if rep.wrote else
|
|
1657
|
+
" Re-run with --apply to write (a .bak backup is made first unless --no-backup)."))
|
|
1658
|
+
return "\n".join(lines)
|
|
1659
|
+
|
|
1660
|
+
|
|
1661
|
+
def render_equip_write(rep: EquipWriteReport) -> str:
|
|
1662
|
+
"""A human-readable summary of a :func:`set_equip` outcome."""
|
|
1663
|
+
old = rep.old_name or ("(empty)" if rep.old_id == NO_ITEM else f"id {rep.old_id}")
|
|
1664
|
+
new = rep.new_name or ("(empty)" if rep.new_id == NO_ITEM else f"id {rep.new_id}")
|
|
1665
|
+
who = f"{rep.character or '?'} (slot {rep.slot_no})"
|
|
1666
|
+
if rep.old_id == rep.new_id:
|
|
1667
|
+
return f" {who} {rep.slot} already {new} in {rep.path} -- nothing to change."
|
|
1668
|
+
head = "WROTE" if rep.wrote else "DRY RUN -- would set"
|
|
1669
|
+
lines = [f" {head} {who} {rep.slot}: {old} -> {new} in {rep.path}"]
|
|
1670
|
+
lines.append(f" Backup: {rep.backup_path}" if rep.backup_path else
|
|
1671
|
+
(" (--no-backup: no backup written)" if rep.wrote else
|
|
1672
|
+
" Re-run with --apply to write (a .bak backup is made first unless --no-backup)."))
|
|
1673
|
+
return "\n".join(lines)
|