ff9mapkit 1.0.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. ff9mapkit/__init__.py +18 -0
  2. ff9mapkit/__main__.py +36 -0
  3. ff9mapkit/_animdb.py +2994 -0
  4. ff9mapkit/_animdb_all.py +14125 -0
  5. ff9mapkit/_fieldtable.py +1516 -0
  6. ff9mapkit/_fieldtext.py +845 -0
  7. ff9mapkit/_held_poses.py +44 -0
  8. ff9mapkit/_itemdb.py +65 -0
  9. ff9mapkit/_modeldb.py +725 -0
  10. ff9mapkit/_narrowmap_data.py +10 -0
  11. ff9mapkit/_npcparams.py +634 -0
  12. ff9mapkit/_regen_animdb.py +72 -0
  13. ff9mapkit/_regen_animdb_all.py +66 -0
  14. ff9mapkit/_regen_fieldtable.py +95 -0
  15. ff9mapkit/_regen_fieldtext.py +66 -0
  16. ff9mapkit/_regen_modeldb.py +67 -0
  17. ff9mapkit/_regen_npcparams.py +123 -0
  18. ff9mapkit/_regen_scenedb.py +57 -0
  19. ff9mapkit/_scenedb.py +869 -0
  20. ff9mapkit/abilities.py +225 -0
  21. ff9mapkit/animations.py +120 -0
  22. ff9mapkit/archetypes.py +218 -0
  23. ff9mapkit/areatitle.py +76 -0
  24. ff9mapkit/battle/__init__.py +21 -0
  25. ff9mapkit/battle/abilityfeatures.py +294 -0
  26. ff9mapkit/battle/actiondelta.py +441 -0
  27. ff9mapkit/battle/aiauthor.py +305 -0
  28. ff9mapkit/battle/ailint.py +140 -0
  29. ff9mapkit/battle/aipatch.py +175 -0
  30. ff9mapkit/battle/battleai.py +148 -0
  31. ff9mapkit/battle/battlecsv.py +390 -0
  32. ff9mapkit/battle/battlepatch.py +395 -0
  33. ff9mapkit/battle/build.py +558 -0
  34. ff9mapkit/battle/camera_codec.py +332 -0
  35. ff9mapkit/battle/camera_data.py +128 -0
  36. ff9mapkit/battle/characterdelta.py +789 -0
  37. ff9mapkit/battle/event_data.py +72 -0
  38. ff9mapkit/battle/extract.py +540 -0
  39. ff9mapkit/battle/fbx.py +223 -0
  40. ff9mapkit/battle/reskin.py +149 -0
  41. ff9mapkit/battle/scene_codec.py +314 -0
  42. ff9mapkit/battle/scene_data.py +369 -0
  43. ff9mapkit/battle/scenelint.py +125 -0
  44. ff9mapkit/battle/seqasm.py +131 -0
  45. ff9mapkit/battle/seqauthor.py +220 -0
  46. ff9mapkit/battle/seqcodec.py +300 -0
  47. ff9mapkit/battle/seqdis.py +106 -0
  48. ff9mapkit/battle/seqpatch.py +137 -0
  49. ff9mapkit/battle_bgm.py +133 -0
  50. ff9mapkit/binutils.py +60 -0
  51. ff9mapkit/build.py +5445 -0
  52. ff9mapkit/campaign.py +1276 -0
  53. ff9mapkit/catalog.py +316 -0
  54. ff9mapkit/chain.py +358 -0
  55. ff9mapkit/cli.py +3114 -0
  56. ff9mapkit/config.py +360 -0
  57. ff9mapkit/content/__init__.py +13 -0
  58. ff9mapkit/content/areatitle.py +36 -0
  59. ff9mapkit/content/ate.py +118 -0
  60. ff9mapkit/content/camera.py +123 -0
  61. ff9mapkit/content/chest.py +186 -0
  62. ff9mapkit/content/choice.py +163 -0
  63. ff9mapkit/content/conductor.py +217 -0
  64. ff9mapkit/content/cutscene.py +290 -0
  65. ff9mapkit/content/encounter.py +41 -0
  66. ff9mapkit/content/entry_settle.py +50 -0
  67. ff9mapkit/content/equipment.py +93 -0
  68. ff9mapkit/content/event.py +191 -0
  69. ff9mapkit/content/gateway.py +101 -0
  70. ff9mapkit/content/inventory.py +59 -0
  71. ff9mapkit/content/itemdata.py +644 -0
  72. ff9mapkit/content/itemtext.py +168 -0
  73. ff9mapkit/content/jump.py +114 -0
  74. ff9mapkit/content/ladder.py +633 -0
  75. ff9mapkit/content/movement.py +53 -0
  76. ff9mapkit/content/music.py +97 -0
  77. ff9mapkit/content/npc.py +348 -0
  78. ff9mapkit/content/object.py +340 -0
  79. ff9mapkit/content/onentry.py +135 -0
  80. ff9mapkit/content/party.py +111 -0
  81. ff9mapkit/content/pathfind.py +138 -0
  82. ff9mapkit/content/platform.py +314 -0
  83. ff9mapkit/content/player.py +168 -0
  84. ff9mapkit/content/prop.py +75 -0
  85. ff9mapkit/content/region.py +340 -0
  86. ff9mapkit/content/reinit.py +59 -0
  87. ff9mapkit/content/savepoint.py +90 -0
  88. ff9mapkit/content/shop.py +178 -0
  89. ff9mapkit/content/sps_trigger.py +66 -0
  90. ff9mapkit/content/startup.py +71 -0
  91. ff9mapkit/content/synthesis.py +106 -0
  92. ff9mapkit/content/text.py +183 -0
  93. ff9mapkit/content/textcarry.py +290 -0
  94. ff9mapkit/content/verbatim.py +86 -0
  95. ff9mapkit/content/walkmesh_hotfix.py +38 -0
  96. ff9mapkit/data/__init__.py +48 -0
  97. ff9mapkit/data/_regen_provenance.py +142 -0
  98. ff9mapkit/data/provenance/blank.es.patch +1 -0
  99. ff9mapkit/data/provenance/blank.fr.patch +1 -0
  100. ff9mapkit/data/provenance/blank.gr.patch +1 -0
  101. ff9mapkit/data/provenance/blank.it.patch +1 -0
  102. ff9mapkit/data/provenance/blank.jp.patch +1 -0
  103. ff9mapkit/data/provenance/blank.uk.patch +1 -0
  104. ff9mapkit/data/provenance/blank.us.patch +1 -0
  105. ff9mapkit/data/provenance/manifest.json +65 -0
  106. ff9mapkit/data/provenance/region_template.patch +1 -0
  107. ff9mapkit/data/reference_arcs.toml +89 -0
  108. ff9mapkit/data/region_catalog.toml +593 -0
  109. ff9mapkit/deploystack.py +358 -0
  110. ff9mapkit/dialogue.py +803 -0
  111. ff9mapkit/eb/__init__.py +12 -0
  112. ff9mapkit/eb/_exprtable.py +59 -0
  113. ff9mapkit/eb/_membertable.py +38 -0
  114. ff9mapkit/eb/_optables.py +537 -0
  115. ff9mapkit/eb/_regen_optables.py +76 -0
  116. ff9mapkit/eb/cmdasm.py +323 -0
  117. ff9mapkit/eb/disasm.py +332 -0
  118. ff9mapkit/eb/edit.py +439 -0
  119. ff9mapkit/eb/exprasm.py +158 -0
  120. ff9mapkit/eb/model.py +178 -0
  121. ff9mapkit/eb/opcodes.py +463 -0
  122. ff9mapkit/eblint.py +177 -0
  123. ff9mapkit/editor/__init__.py +20 -0
  124. ff9mapkit/editor/app.py +950 -0
  125. ff9mapkit/editor/battle_forms.py +240 -0
  126. ff9mapkit/editor/breadcrumb.py +89 -0
  127. ff9mapkit/editor/dialogs.py +116 -0
  128. ff9mapkit/editor/feedback.py +208 -0
  129. ff9mapkit/editor/forms.py +632 -0
  130. ff9mapkit/editor/graphview.py +350 -0
  131. ff9mapkit/editor/jobs.py +342 -0
  132. ff9mapkit/editor/model.py +243 -0
  133. ff9mapkit/editor/picker.py +120 -0
  134. ff9mapkit/editor/theme.py +212 -0
  135. ff9mapkit/eventscan.py +1441 -0
  136. ff9mapkit/extract.py +2279 -0
  137. ff9mapkit/flags.py +693 -0
  138. ff9mapkit/forkreport.py +1383 -0
  139. ff9mapkit/hub.py +477 -0
  140. ff9mapkit/idgated.py +101 -0
  141. ff9mapkit/infohub.py +580 -0
  142. ff9mapkit/items.py +63 -0
  143. ff9mapkit/itemstats.py +346 -0
  144. ff9mapkit/journey.py +1902 -0
  145. ff9mapkit/keyitems.py +93 -0
  146. ff9mapkit/logic_add.py +632 -0
  147. ff9mapkit/logic_edit.py +728 -0
  148. ff9mapkit/logic_map.py +526 -0
  149. ff9mapkit/pack.py +175 -0
  150. ff9mapkit/playerswap.py +231 -0
  151. ff9mapkit/prop_archetypes.py +228 -0
  152. ff9mapkit/provision.py +282 -0
  153. ff9mapkit/refarc.py +825 -0
  154. ff9mapkit/save.py +337 -0
  155. ff9mapkit/save_items.py +1673 -0
  156. ff9mapkit/scene/__init__.py +11 -0
  157. ff9mapkit/scene/arena.py +63 -0
  158. ff9mapkit/scene/bgart.py +140 -0
  159. ff9mapkit/scene/bgi.py +732 -0
  160. ff9mapkit/scene/bgs.py +174 -0
  161. ff9mapkit/scene/bgx.py +185 -0
  162. ff9mapkit/scene/cam.py +345 -0
  163. ff9mapkit/scene/guide.py +311 -0
  164. ff9mapkit/scene/paint.py +506 -0
  165. ff9mapkit/scene/placeholder.py +107 -0
  166. ff9mapkit/sjbinary.py +285 -0
  167. ff9mapkit/sps/__init__.py +17 -0
  168. ff9mapkit/sps/author.py +294 -0
  169. ff9mapkit/sps/catalog.py +88 -0
  170. ff9mapkit/sps/codec.py +264 -0
  171. ff9mapkit/sps/edit.py +184 -0
  172. ff9mapkit/sps/lint.py +58 -0
  173. ff9mapkit/sps/render.py +116 -0
  174. ff9mapkit/sps/templates.py +47 -0
  175. ff9mapkit/sps/texture.py +131 -0
  176. ff9mapkit/walkmesh_hotfixes.py +163 -0
  177. ff9mapkit/workspace/__init__.py +18 -0
  178. ff9mapkit/workspace/battledoc.py +985 -0
  179. ff9mapkit/workspace/builddoc.py +607 -0
  180. ff9mapkit/workspace/forms_qt.py +586 -0
  181. ff9mapkit/workspace/importdoc.py +665 -0
  182. ff9mapkit/workspace/mapview.py +131 -0
  183. ff9mapkit/workspace/palette.py +85 -0
  184. ff9mapkit/workspace/savedoc.py +664 -0
  185. ff9mapkit/workspace/shell.py +6907 -0
  186. ff9mapkit/workspace/style.py +105 -0
  187. ff9mapkit/workspace/tuningdialog.py +223 -0
  188. ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
  189. ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
  190. ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
  191. ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
  192. ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
  193. ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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)