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,644 @@
1
+ """``[[weapon]]`` / ``[[armor]]`` / ``[[item]]`` -- tune EXISTING item stats via partial CSV deltas (no DLL).
2
+
3
+ The engine MERGES ``Data/Items/{Weapons,Armors,Items}.csv`` by id low->high, **whole-row-wins**
4
+ (``AssetManager.EnumerateCsvFromLowToHigh``), so a mod ships a PARTIAL delta = the base file's header block
5
+ (verbatim, so ``CsvReader`` parses the same columns + ``#!`` options) + only the patched rows, each COMPLETE.
6
+
7
+ The base rows are read LIVE from the user's install (the same provenance-clean pattern as :mod:`ff9mapkit.itemstats`)
8
+ -- so the kit commits NO game data; the delta is GENERATED at build time into the mod folder. Item-data patches
9
+ therefore need a reachable install (they degrade with a clear error otherwise).
10
+
11
+ * ``[[weapon]]`` patches the item's ItemAttack (``Weapons.csv``: ``Power`` / ``Elements`` + ``category`` /
12
+ ``status_index`` / ``rate`` -- the weapon's class, the ``StatusSets.csv`` row it inflicts on hit, and that
13
+ status's percent chance), located via the item's ``WeaponId`` in ``Items.csv``.
14
+ * ``[[armor]]`` patches its ItemDefence (``Armors.csv``: ``P.Def`` / ``P.Eva`` / ``M.Def`` / ``M.Eva``) via ``ArmorId``.
15
+ * ``[[item]]`` patches its ItemInfo (``Items.csv``: ``Price`` / ``SellingPrice`` + ``equippable_by`` -- the list of
16
+ characters who can equip it, which REWRITES the item's 12 equip-by-character bits) by item id directly.
17
+
18
+ [[weapon]]
19
+ name = "Mage Masher"
20
+ power = 30
21
+ elements = ["Fire"]
22
+ category = ["short-range", "throw"] # weapon class (here: throwable)
23
+ status_index = 9 # a StatusSets.csv row -> the status it can inflict on hit
24
+ rate = 30 # 30% chance to inflict that status
25
+
26
+ [[armor]]
27
+ name = "Bronze Armor"
28
+ p_def = 20
29
+
30
+ [[item]]
31
+ name = "Excalibur"
32
+ price = 5000
33
+ equippable_by = ["Vivi", "Garnet"] # exactly these characters can equip it (replaces the current set)
34
+
35
+ * ``[[equip_bonus]]`` patches the item's ItemStats (``Stats.csv``: the equip stat bonuses ``speed`` / ``strength`` /
36
+ ``magic`` / ``spirit`` -- the input the engine's level-up accumulator reads, ``ff9play.cs:302-305`` -- plus the
37
+ elemental-affinity bitmasks ``attack_element`` / ``guard_element`` / ``absorb_element`` / ``half_element`` /
38
+ ``weak_element``), located via the item's ``BonusId`` in ``Items.csv``. ★ ``BonusId`` is SHARED: ~100 items point
39
+ at the all-zero ``Empty`` row 0, so a block on such an item can't edit row 0 in place (it would buff every other
40
+ no-bonus item) -- it MINTS a fresh ``Stats.csv`` row and repoints the item's ``BonusId`` in an ``Items.csv`` delta,
41
+ isolating the edit. An item whose ``BonusId`` is dedicated (used by it alone) is edited in place.
42
+
43
+ [[equip_bonus]]
44
+ name = "Bone Wrist"
45
+ strength = 3
46
+ weak_element = ["Fire"]
47
+ """
48
+ from __future__ import annotations
49
+
50
+ from .. import abilities as _abilities
51
+ from .. import items as _items
52
+ from .. import itemstats as _itemstats
53
+
54
+ POWER_CAP = 255 # weapon Power / armor defence are small byte-range values in practice
55
+ PRICE_CAP = 9_999_999 # gil cap; a price above it is pointless (you can't hold that much gil)
56
+ RATE_CAP = 100 # a weapon's status-infliction Rate is a 0-100 percent chance (the engine clamps all
57
+ # accuracy to 100 -- BattleCalculator.cs; physical hit is fixed 100, so Rate ONLY gates
58
+ # the on-hit status, applied from add_status[StatusIndex] -- SBattleCalculator.cs:188).
59
+ STATUS_INDEX_CAP = 65535 # StatusIndex references a StatusSets.csv row; the REAL membership check is install-gated
60
+ # in build.validate (an over-range id is a KeyNotFound crash, like the Phase-4 trap).
61
+ EFFECT_POWER_CAP = 9999 # a use-effect's Power = the heal/damage magnitude (the in-game HP cap)
62
+ _ELEM_BY_NAME = {name.lower(): bit for bit, name in _itemstats.ELEMENTS} # "fire" -> 1, ...
63
+ _STATUS_BY_NAME = {name.lower(): bit for bit, name in _itemstats.STATUSES} # "poison" -> 1<<16, ...
64
+ # [[item_effect]] -> the ItemEffect (ItemEffects.csv) row of a USABLE item (located via its EffectId). The gameplay
65
+ # knobs the kit tunes (ScriptId/AnimationId/Targets stay -- they ARE the effect's behavior/VFX): power = heal/damage
66
+ # magnitude; rate = status chance; element; status (the BattleStatus mask inflicted OR cured -- the DIRECTION is the
67
+ # effect's ScriptId, which we don't touch); for_dead = usable on a KO'd target (Phoenix-Down style).
68
+ _EFFECT_INT_COLS = {"power": "Power", "rate": "Rate"}
69
+ EFFECT_KEYS = ("power", "rate", "element", "status", "for_dead")
70
+
71
+
72
+ def _norm(s) -> str:
73
+ """Loose name key: lowercased, alphanumerics only -- so "short-range" / "ShortRange" / "short range" match."""
74
+ return "".join(ch for ch in str(s).strip().lower() if ch.isalnum())
75
+
76
+
77
+ # WeaponCategory bits (Memoria.Data.WeaponCategory) by friendly name (+ the engine enum name "OfsDim" for bit 8).
78
+ _CATEGORY_BY_NAME = {_norm(name): bit for bit, name in _itemstats.WEAPON_CATEGORY}
79
+ _CATEGORY_BY_NAME["ofsdim"] = 8
80
+ _CHAR_BY_NAME = {c.lower(): c for c in _itemstats.CHARS} # equip-by-character names -> canonical CHARS
81
+
82
+ # Which Items.csv FK column + which target CSV a block patches, and the editable {toml key: CSV column} maps.
83
+ _WEAPON_COLS = {"power": "Power", "elements": "Elements",
84
+ "category": "Category", "status_index": "StatusIndex", "rate": "Rate"}
85
+ _ARMOR_COLS = {"p_def": "P.Def", "p_eva": "P.Eva", "m_def": "M.Def", "m_eva": "M.Eva"}
86
+ _ITEM_COLS = {"price": "Price", "sell": "SellingPrice"} # equippable_by is handled separately (12-column rewrite)
87
+
88
+ STAT_CAP = 255 # equip stat bonuses (dex/str/mgc/wpr) are Byte columns in Stats.csv (ItemStats.cs)
89
+ # [[equip_bonus]] -> the ItemStats (Stats.csv) row of an EQUIPPABLE item: the 4 growth-stat bonuses (the input
90
+ # the 32-level level-up accumulator reads, ff9play.cs:302-305) + the 5 elemental-affinity bitmask columns. Keys
91
+ # map to the Stats.csv legend (★ Dexterity = FF9 "Speed", Will = FF9 "Spirit").
92
+ _EQUIP_BONUS_STATS = {"speed": "Dexterity", "strength": "Strength", "magic": "Magic", "spirit": "Will"}
93
+ # Keys 1:1 with the Stats.csv column names (so the emitted delta matches the file the user can inspect). Engine
94
+ # meaning (ItemStats.cs raw[6..10] -> p_up_attr/def_attr): attack_element = STRENGTHENS attacks/magic of that
95
+ # element (a damage boost while worn), NOT "adds the element on hit"; guard_element = NULLIFY (immune); the other
96
+ # three = absorb (heal from) / take half / take extra damage. All are Byte element bitmasks.
97
+ _EQUIP_BONUS_ELEMS = {"attack_element": "AttackElement", "guard_element": "GuardElement",
98
+ "absorb_element": "AbsorbElement", "half_element": "HalfElement",
99
+ "weak_element": "WeakElement"}
100
+ EQUIP_BONUS_KEYS = (*_EQUIP_BONUS_STATS, *_EQUIP_BONUS_ELEMS)
101
+
102
+
103
+ def encode_elements(names) -> int:
104
+ """A list of element names (or a 0-255 bitmask int) -> the element bitmask. Raises ValueError on an unknown
105
+ name, an out-of-range / wrong-typed value. ★ Range-checked: the Elements column is a Byte (element bits sum to
106
+ 255), so a bare int MUST be 0..255 -- else the engine's ``Byte.Parse`` OverflowExceptions and HARD-QUITS at
107
+ weapon load. Every bad input raises ValueError so the single ``except ValueError`` in build/validate suffices."""
108
+ if isinstance(names, bool): # bool is an int subclass -- reject before the int path
109
+ raise ValueError("elements must be a list of element names or a 0-255 bitmask, not a bool")
110
+ if isinstance(names, int):
111
+ if not 0 <= names <= 255:
112
+ raise ValueError(f"element bitmask {names} out of range 0..255")
113
+ return names
114
+ if names is None:
115
+ return 0
116
+ if not isinstance(names, (list, tuple)):
117
+ raise ValueError(f"elements must be a list of element names (or a 0-255 bitmask), got {names!r}")
118
+ mask = 0
119
+ for n in names:
120
+ bit = _ELEM_BY_NAME.get(str(n).strip().lower())
121
+ if bit is None:
122
+ raise ValueError(f"unknown element {n!r} (one of {', '.join(nm for _, nm in _itemstats.ELEMENTS)})")
123
+ mask |= bit
124
+ return mask
125
+
126
+
127
+ def encode_category(names) -> int:
128
+ """A list of weapon-category names (``short-range`` / ``long-range`` / ``throw`` / ``offset``) OR a 0-255
129
+ bitmask int -> the WeaponCategory byte. Raises ValueError on an unknown name / out-of-range value -- the
130
+ Category column is a ``CsvParser.Byte`` so a >255 int would OverflowException + HARD-QUIT at weapon load
131
+ (the same trap as ``elements``). ``throw`` makes the weapon eligible for Amarant's Throw command."""
132
+ if isinstance(names, bool):
133
+ raise ValueError("category must be a list of category names or a 0-255 bitmask, not a bool")
134
+ if isinstance(names, int):
135
+ if not 0 <= names <= 255:
136
+ raise ValueError(f"category bitmask {names} out of range 0..255")
137
+ return names
138
+ if names is None:
139
+ return 0
140
+ if not isinstance(names, (list, tuple)):
141
+ raise ValueError(f"category must be a list of category names (or a 0-255 bitmask), got {names!r}")
142
+ mask = 0
143
+ for n in names:
144
+ bit = _CATEGORY_BY_NAME.get(_norm(n))
145
+ if bit is None:
146
+ raise ValueError(f"unknown weapon category {n!r} "
147
+ f"(one of {', '.join(nm for _, nm in _itemstats.WEAPON_CATEGORY)})")
148
+ mask |= bit
149
+ return mask
150
+
151
+
152
+ def encode_characters(names) -> list:
153
+ """A list of party-character names -> the canonical :data:`itemstats.CHARS` subset (de-duped, validated).
154
+ Raises ValueError on a non-list or an unknown name. ``[[item]] equippable_by`` uses this to REWRITE the 12
155
+ equip-by-character bits of an item (the listed characters can equip it; everyone else cannot)."""
156
+ if not isinstance(names, (list, tuple)):
157
+ raise ValueError(f"equippable_by must be a list of character names (any of {', '.join(_itemstats.CHARS)})")
158
+ out: list = []
159
+ for n in names:
160
+ c = _CHAR_BY_NAME.get(str(n).strip().lower())
161
+ if c is None:
162
+ raise ValueError(f"unknown character {n!r} (one of {', '.join(_itemstats.CHARS)})")
163
+ if c not in out:
164
+ out.append(c)
165
+ return out
166
+
167
+
168
+ def encode_statuses(names) -> int:
169
+ """A list of status names (Poison/Silence/Death/...) OR a non-negative int -> the ``BattleStatus`` mask
170
+ (``UInt64``). Raises ValueError on an unknown name / negative / wrong type. Whether a use-effect INFLICTS or
171
+ CURES the masked statuses is the effect's ``ScriptId`` (which the kit doesn't touch), so this only sets WHICH
172
+ statuses the effect concerns."""
173
+ if isinstance(names, bool):
174
+ raise ValueError("status must be a list of status names or a non-negative bitmask, not a bool")
175
+ if isinstance(names, int):
176
+ if not 0 <= names < 2 ** 64: # Status is a UInt64 -> a >2^64-1 mask OverflowExceptions
177
+ raise ValueError(f"status bitmask {names} out of range 0..2**64-1 (a UInt64)") # + HARD-QUITs at load
178
+ return names
179
+ if names is None:
180
+ return 0
181
+ if not isinstance(names, (list, tuple)):
182
+ raise ValueError(f"status must be a list of status names (or a bitmask int), got {names!r}")
183
+ mask = 0
184
+ for n in names:
185
+ bit = _STATUS_BY_NAME.get(str(n).strip().lower())
186
+ if bit is None:
187
+ raise ValueError(f"unknown status {n!r} (one of {', '.join(nm for _, nm in _itemstats.STATUSES)})")
188
+ mask |= bit
189
+ return mask
190
+
191
+
192
+ def _clamp_int(value, lo, hi, what) -> int:
193
+ if isinstance(value, bool) or not isinstance(value, int):
194
+ raise ValueError(f"{what} must be an int (got {value!r})")
195
+ return max(lo, min(hi, value))
196
+
197
+
198
+ # --- raw CSV read (preserve the header block verbatim; rows keyed by the Id column) ----------------
199
+
200
+ def read_base_csv(text: str):
201
+ """Parse a Memoria item CSV TEXT into ``(header_text, cols, id_col, rows_by_id)``. ``header_text`` is every
202
+ leading line up to the first data row, verbatim (the ``#!`` options + ``#``-legend + separators -- so a
203
+ re-emit parses identically). ``cols`` = column-name -> index (from the legend). ``rows_by_id`` = {id:
204
+ raw_row_string}. The first data row is the first line whose first non-space char is NOT ``#`` (a Stats.csv
205
+ ``Comment`` cell may itself contain ``#``, so only a LEADING ``#`` marks a comment)."""
206
+ header, cols, id_col, rows = [], None, None, {}
207
+ in_data = False
208
+ for line in text.splitlines():
209
+ s = line.strip()
210
+ if not in_data and (not s or s.startswith("#")):
211
+ header.append(line)
212
+ if cols is None and s.startswith("#"):
213
+ fields = [f.strip() for f in s.lstrip("#").strip().split(";")]
214
+ if "Id" in fields and len(fields) > 1:
215
+ cols = {n: i for i, n in enumerate(fields)}
216
+ id_col = cols["Id"]
217
+ continue
218
+ in_data = True
219
+ if id_col is None:
220
+ continue
221
+ parts = line.split(";")
222
+ try:
223
+ iid = int(parts[id_col].strip())
224
+ except (ValueError, IndexError):
225
+ continue
226
+ rows[iid] = line
227
+ return "\n".join(header), (cols or {}), id_col, rows
228
+
229
+
230
+ def _set_col(row: str, idx: int, value) -> str:
231
+ """Replace column ``idx`` of a ``;``-joined row with ``value`` (other cells -- incl. a trailing ``# name``
232
+ comment -- preserved verbatim)."""
233
+ parts = row.split(";")
234
+ if idx >= len(parts):
235
+ raise ValueError(f"row has {len(parts)} columns; cannot set column {idx}")
236
+ parts[idx] = str(value)
237
+ return ";".join(parts)
238
+
239
+
240
+ def _fk_of(items_rows, items_cols, item_id: int, fk_col: str, kind: str) -> int:
241
+ """The ``WeaponId`` / ``ArmorId`` of an item (from its ``Items.csv`` row). Raises if the item is missing or
242
+ not of that kind (FK < 0)."""
243
+ row = items_rows.get(item_id)
244
+ if row is None:
245
+ raise ValueError(f"item id {item_id} has no Items.csv row")
246
+ parts = row.split(";")
247
+ idx = items_cols.get(fk_col)
248
+ try:
249
+ fk = int(parts[idx].strip())
250
+ except (TypeError, ValueError, IndexError):
251
+ fk = -1
252
+ if fk < 0:
253
+ raise ValueError(f"{_items.name_of(item_id) or item_id} is not a {kind} (no {fk_col})")
254
+ return fk
255
+
256
+
257
+ def _edits_for(block, col_map, cols) -> dict:
258
+ """{CSV column index: new cell value} for a patch block, applying only the keys the block sets + clamps."""
259
+ edits = {}
260
+ for key, csv_col in col_map.items():
261
+ if key not in block:
262
+ continue
263
+ idx = cols.get(csv_col)
264
+ if idx is None:
265
+ raise ValueError(f"this install's CSV has no {csv_col!r} column")
266
+ v = block[key]
267
+ if key == "elements":
268
+ edits[idx] = encode_elements(v)
269
+ elif key == "category":
270
+ edits[idx] = encode_category(v)
271
+ elif key in ("price", "sell"):
272
+ edits[idx] = _clamp_int(v, 0, PRICE_CAP, key)
273
+ elif key == "status_index":
274
+ edits[idx] = _clamp_int(v, 0, STATUS_INDEX_CAP, key)
275
+ elif key == "rate":
276
+ edits[idx] = _clamp_int(v, 0, RATE_CAP, key)
277
+ else:
278
+ edits[idx] = _clamp_int(v, 0, POWER_CAP, key)
279
+ return edits
280
+
281
+
282
+ def _equip_mask_edits(names, cols) -> dict:
283
+ """{Items.csv character-column index: 0/1} that REWRITES an item's 12 equip-by-character bits to exactly
284
+ ``names`` (each listed character -> 1, every other -> 0). Raises if the install's Items.csv lacks a column."""
285
+ wanted = set(encode_characters(names))
286
+ edits = {}
287
+ for ch in _itemstats.CHARS:
288
+ idx = cols.get(ch)
289
+ if idx is None:
290
+ raise ValueError(f"this install's Items.csv has no {ch!r} equip column")
291
+ edits[idx] = 1 if ch in wanted else 0
292
+ return edits
293
+
294
+
295
+ def ability_tokens(entries) -> str:
296
+ """A list of ability NAMES / ``AA:X`` / ``SA:X`` tokens / numeric ids -> the ``Items.csv`` ``AbilityIds`` cell
297
+ text (``"AA:104, SA:19"``, or ``"0"`` for none -- the engine's no-abilities sentinel). Each entry is resolved
298
+ to its canonical token via :func:`abilities.resolve` + :func:`abilities.decode_token` (a NAME matched against
299
+ the live pools, a token/id decoded mod-agnostically); duplicates collapse, first-seen order kept. The cell is a
300
+ COMMA list inside one semicolon-cell, so the commas don't clash with the CSV's ``;`` delimiter -- the engine
301
+ reads it via ``CsvParser.AnyAbilityArray``. Raises ValueError on an unknown name / malformed token."""
302
+ if not isinstance(entries, (list, tuple)):
303
+ raise ValueError("teaches must be a list of ability names or AA:X / SA:X tokens")
304
+ seen, toks = set(), []
305
+ for e in entries:
306
+ tok = _abilities.decode_token(_abilities.resolve(None, e)) # name (global pool) / token / id -> token
307
+ if tok not in seen:
308
+ seen.add(tok)
309
+ toks.append(tok)
310
+ return ", ".join(toks) if toks else "0"
311
+
312
+
313
+ # --- delta builders (text) ------------------------------------------------------------------------
314
+
315
+ def _emit(header: str, rows_by_id: dict, banner: str) -> str:
316
+ body = "\n".join(rows_by_id[k] for k in sorted(rows_by_id))
317
+ return f"{banner}\n{header}\n{body}\n"
318
+
319
+
320
+ def build_weapons_delta(items_text: str, weapons_text: str, weapons) -> "str | None":
321
+ """A partial ``Weapons.csv`` text from ``[[weapon]]`` blocks (or ``None`` if none patch). Each block:
322
+ ``name`` (item name/id) + any of ``power`` / ``elements``."""
323
+ icols_t = read_base_csv(items_text)
324
+ wheader, wcols, _wid, wrows = read_base_csv(weapons_text)
325
+ _iheader, icols, _iid, irows = icols_t
326
+ patched: dict = {}
327
+ for b in weapons:
328
+ iid = _items.resolve(b["name"])
329
+ wid = _fk_of(irows, icols, iid, "WeaponId", "weapon")
330
+ base = patched.get(wid, wrows.get(wid))
331
+ if base is None:
332
+ raise ValueError(f"no Weapons.csv row for WeaponId {wid} ({b['name']})")
333
+ for idx, val in _edits_for(b, _WEAPON_COLS, wcols).items():
334
+ base = _set_col(base, idx, val)
335
+ patched[wid] = base
336
+ if not patched:
337
+ return None
338
+ return _emit(wheader, patched, "# ff9mapkit [[weapon]] -- Weapons.csv delta (merged by id, whole-row, over the base)")
339
+
340
+
341
+ def build_armors_delta(items_text: str, armors_text: str, armors) -> "str | None":
342
+ iheader_t = read_base_csv(items_text)
343
+ aheader, acols, _aid, arows = read_base_csv(armors_text)
344
+ _ih, icols, _iid, irows = iheader_t
345
+ patched: dict = {}
346
+ for b in armors:
347
+ iid = _items.resolve(b["name"])
348
+ aid = _fk_of(irows, icols, iid, "ArmorId", "armor")
349
+ base = patched.get(aid, arows.get(aid))
350
+ if base is None:
351
+ raise ValueError(f"no Armors.csv row for ArmorId {aid} ({b['name']})")
352
+ for idx, val in _edits_for(b, _ARMOR_COLS, acols).items():
353
+ base = _set_col(base, idx, val)
354
+ patched[aid] = base
355
+ if not patched:
356
+ return None
357
+ return _emit(aheader, patched, "# ff9mapkit [[armor]] -- Armors.csv delta (merged by id, whole-row, over the base)")
358
+
359
+
360
+ def build_items_delta(items_text: str, items, *, bonusid_repoints=None) -> "str | None":
361
+ """A partial ``Items.csv`` text from ``[[item]]`` blocks (keyed by item id directly). Each block: ``name`` +
362
+ any of ``price`` / ``sell`` / ``equippable_by`` (REWRITES the 12 equip-by-character bits) / ``teaches``
363
+ (REWRITES the ``AbilityIds`` cell -- the abilities the gear teaches). ``bonusid_repoints`` ({item_id: new
364
+ BonusId}) additionally repoints those items' ``BonusId`` column (from :func:`build_equip_bonus_delta`'s mint
365
+ path) -- ALL channels compose on one row (the engine merges whole-row, so price + equippable_by + teaches +
366
+ a repointed BonusId must ship together in the same Items.csv row)."""
367
+ header, cols, _idcol, rows = read_base_csv(items_text)
368
+ patched: dict = {}
369
+ for b in items:
370
+ iid = _items.resolve(b["name"])
371
+ base = patched.get(iid, rows.get(iid))
372
+ if base is None:
373
+ raise ValueError(f"no Items.csv row for item id {iid} ({b['name']})")
374
+ for idx, val in _edits_for(b, _ITEM_COLS, cols).items():
375
+ base = _set_col(base, idx, val)
376
+ if "equippable_by" in b:
377
+ for idx, val in _equip_mask_edits(b["equippable_by"], cols).items():
378
+ base = _set_col(base, idx, val)
379
+ if "teaches" in b: # REWRITE the AbilityIds cell (the abilities gear teaches)
380
+ aidx = cols.get("AbilityIds")
381
+ if aidx is None:
382
+ raise ValueError("this install's Items.csv has no AbilityIds column (can't set taught abilities)")
383
+ base = _set_col(base, aidx, ability_tokens(b["teaches"]))
384
+ patched[iid] = base
385
+ if bonusid_repoints:
386
+ bcol = cols.get("BonusId")
387
+ if bcol is None:
388
+ raise ValueError("this install's Items.csv has no BonusId column (can't repoint an equip bonus)")
389
+ for item_id, new_bonus in bonusid_repoints.items():
390
+ base = patched.get(item_id, rows.get(item_id))
391
+ if base is None:
392
+ raise ValueError(f"no Items.csv row for item id {item_id} (equip-bonus repoint)")
393
+ patched[item_id] = _set_col(base, bcol, new_bonus)
394
+ if not patched:
395
+ return None
396
+ return _emit(header, patched, "# ff9mapkit [[item]] -- Items.csv delta (merged by id, whole-row, over the base)")
397
+
398
+
399
+ # --- equip stat bonuses (Stats.csv / ItemStats) ---------------------------------------------------
400
+
401
+ def _edits_for_bonus(block, scols) -> dict:
402
+ """{Stats.csv column index: new cell value} for an ``[[equip_bonus]]`` block (stat ints clamped 0-255;
403
+ element keys via :func:`encode_elements`)."""
404
+ edits = {}
405
+ for key, csv_col in _EQUIP_BONUS_STATS.items():
406
+ if key in block:
407
+ idx = scols.get(csv_col)
408
+ if idx is None:
409
+ raise ValueError(f"this install's Stats.csv has no {csv_col!r} column")
410
+ edits[idx] = _clamp_int(block[key], 0, STAT_CAP, key)
411
+ for key, csv_col in _EQUIP_BONUS_ELEMS.items():
412
+ if key in block:
413
+ idx = scols.get(csv_col)
414
+ if idx is None:
415
+ raise ValueError(f"this install's Stats.csv has no {csv_col!r} column")
416
+ edits[idx] = encode_elements(block[key])
417
+ return edits
418
+
419
+
420
+ def _mint_comment(new_id: int, name) -> str:
421
+ """The Comment cell (col 0) of a kit-minted Stats.csv row. Sanitized: ``;`` would split into extra columns
422
+ (shifting the Id); a leading ``#`` would make CsvReader SKIP the whole line. The "Bonus NNNN # " prefix means
423
+ the cell never starts with ``#``, so the row always parses as data."""
424
+ safe = str(name).replace(";", ",").replace("\n", " ").replace("\r", " ").strip()
425
+ return f"Bonus {new_id:04d} # {safe} (ff9mapkit)"
426
+
427
+
428
+ def _synthetic_stat_row(width: int, new_id: int, name, id_col: int) -> str:
429
+ """An all-zero Stats.csv row (for an item whose current BonusId is Empty/dangling -- nothing to seed from).
430
+ ``width`` must hold every edited column (>= the real header width, so a >11-column modded Stats.csv is safe)."""
431
+ parts = ["0"] * max(width, 11)
432
+ parts[0] = _mint_comment(new_id, name)
433
+ parts[id_col] = str(new_id)
434
+ return ";".join(parts)
435
+
436
+
437
+ def build_equip_bonus_delta(items_text: str, stats_text: str, equip_bonuses):
438
+ """A partial ``Stats.csv`` text from ``[[equip_bonus]]`` blocks + the ``{item_id: new BonusId}`` repoints its
439
+ mint path needs in ``Items.csv``. Returns ``(stats_delta | None, repoints)``.
440
+
441
+ Each block: ``name`` (equippable item) + any of ``speed`` / ``strength`` / ``magic`` / ``spirit`` +
442
+ ``attack_element`` / ``guard_element`` / ``absorb_element`` / ``half_element`` / ``weak_element``. An item whose
443
+ ``BonusId`` is DEDICATED (used by it alone, and not the shared Empty row 0) is edited in place; otherwise a fresh
444
+ row is minted (seeded from the item's current bonus values so unchanged stats carry) and the item is repointed,
445
+ so the edit can NEVER leak onto another item that shared the row."""
446
+ _ih, icols, _iid_col, irows = read_base_csv(items_text)
447
+ sheader, scols, sid_col, srows = read_base_csv(stats_text)
448
+ bcol = icols.get("BonusId")
449
+ if bcol is None:
450
+ raise ValueError("this install's Items.csv has no BonusId column (can't tune equip bonuses)")
451
+ # How many items point at each BonusId -- only a row used by exactly ONE item (and not the shared row 0) is
452
+ # safe to edit in place; everything else mints a fresh row.
453
+ users: dict = {}
454
+ for row in irows.values():
455
+ parts = row.split(";")
456
+ try:
457
+ bid = int(parts[bcol].strip())
458
+ except (ValueError, IndexError):
459
+ continue
460
+ users[bid] = users.get(bid, 0) + 1
461
+ used_ids = {0} | set(srows) | set(users) # include 0 so a mint NEVER lands on the Empty row
462
+ mint_next = max(used_ids) + 1
463
+ row_width = max((len(r.split(";")) for r in srows.values()), default=0)
464
+ row_width = max(row_width, len(scols), 11) # a synthetic seed must hold every edited column
465
+
466
+ # Coalesce blocks per resolved item FIRST -- so two [[equip_bonus]] on the SAME item MERGE (later block wins
467
+ # per column) on BOTH the in-place and the mint path. (Without this, two blocks on a shared-row item would each
468
+ # mint a separate row, the last repoint would win, and the first block's edits would be silently lost + orphan a
469
+ # half-minted row.) first-seen order keeps the minted ids deterministic.
470
+ per_item: dict = {}
471
+ order: list = []
472
+ for b in equip_bonuses:
473
+ iid = _items.resolve(b["name"])
474
+ e = _edits_for_bonus(b, scols)
475
+ if not e:
476
+ continue
477
+ if iid not in per_item:
478
+ per_item[iid] = {"name": b["name"], "edits": {}}
479
+ order.append(iid)
480
+ per_item[iid]["edits"].update(e)
481
+
482
+ patched: dict = {}
483
+ repoints: dict = {}
484
+ for iid in order:
485
+ name = per_item[iid]["name"]
486
+ edits = per_item[iid]["edits"]
487
+ irow = irows.get(iid)
488
+ if irow is None:
489
+ raise ValueError(f"no Items.csv row for item id {iid} ({name})")
490
+ try:
491
+ cur = int(irow.split(";")[bcol].strip())
492
+ except (ValueError, IndexError):
493
+ cur = 0
494
+ dedicated = cur != 0 and cur in srows and users.get(cur, 0) == 1
495
+ if dedicated:
496
+ base = srows[cur] # 1:1 by definition, so touched once
497
+ for idx, val in edits.items():
498
+ base = _set_col(base, idx, val)
499
+ patched[cur] = base
500
+ else:
501
+ new_id = mint_next
502
+ mint_next += 1
503
+ seed = srows.get(cur)
504
+ if seed is None:
505
+ seed = _synthetic_stat_row(row_width, new_id, name, sid_col)
506
+ else:
507
+ seed = _set_col(seed, sid_col, new_id)
508
+ seed = _set_col(seed, 0, _mint_comment(new_id, name))
509
+ for idx, val in edits.items():
510
+ seed = _set_col(seed, idx, val)
511
+ patched[new_id] = seed
512
+ repoints[iid] = new_id
513
+ if not patched:
514
+ return None, {}
515
+ return (_emit(sheader, patched,
516
+ "# ff9mapkit [[equip_bonus]] -- Stats.csv delta (ItemStats, merged by id, whole-row, over the base)"),
517
+ repoints)
518
+
519
+
520
+ # --- consumable use-effects (ItemEffects.csv / ItemEffect) ----------------------------------------
521
+
522
+ def _effect_edits(block, ecols) -> dict:
523
+ """{ItemEffects.csv column index: new cell value} for an ``[[item_effect]]`` block. ``power``/``rate`` clamped
524
+ ints; ``element`` via :func:`encode_elements` (a Byte); ``status`` via :func:`encode_statuses` (the BattleStatus
525
+ mask); ``for_dead`` -> the ``Dead`` bit (0/1)."""
526
+ edits = {}
527
+ for key, csv_col in _EFFECT_INT_COLS.items():
528
+ if key in block:
529
+ idx = ecols.get(csv_col)
530
+ if idx is None:
531
+ raise ValueError(f"this install's ItemEffects.csv has no {csv_col!r} column")
532
+ cap = EFFECT_POWER_CAP if key == "power" else RATE_CAP
533
+ edits[idx] = _clamp_int(block[key], 0, cap, key)
534
+ for key, csv_col, enc in (("element", "Element", encode_elements), ("status", "Status", encode_statuses)):
535
+ if key in block:
536
+ idx = ecols.get(csv_col)
537
+ if idx is None:
538
+ raise ValueError(f"this install's ItemEffects.csv has no {csv_col!r} column")
539
+ edits[idx] = enc(block[key])
540
+ if "for_dead" in block:
541
+ idx = ecols.get("Dead")
542
+ if idx is None:
543
+ raise ValueError("this install's ItemEffects.csv has no 'Dead' column")
544
+ edits[idx] = 1 if block["for_dead"] else 0
545
+ return edits
546
+
547
+
548
+ def build_item_effects_delta(items_text: str, effects_text: str, item_effects) -> "str | None":
549
+ """A partial ``ItemEffects.csv`` text from ``[[item_effect]]`` blocks (or ``None`` if none patch). Each block:
550
+ ``name`` (a USABLE item) + any of ``power`` / ``rate`` / ``element`` / ``status`` / ``for_dead``. The item's
551
+ ``EffectId`` (from ``Items.csv``) locates its ItemEffect row, which is edited IN PLACE -- ``EffectId`` is 1:1
552
+ with a usable item (no shared ``Empty`` row, unlike ``BonusId``), so an in-place edit never leaks onto another
553
+ item. Whole-row merge by id (``ff9item.LoadItemEffects``), so the delta carries the base header (incl.
554
+ ``#! IncludeId``) verbatim + only the patched rows."""
555
+ _ih, icols, _iid, irows = read_base_csv(items_text)
556
+ eheader, ecols, _eidc, erows = read_base_csv(effects_text)
557
+ ecol = icols.get("EffectId")
558
+ if ecol is None:
559
+ raise ValueError("this install's Items.csv has no EffectId column (can't tune use-effects)")
560
+ patched: dict = {}
561
+ for b in item_effects:
562
+ iid = _items.resolve(b["name"])
563
+ irow = irows.get(iid)
564
+ if irow is None:
565
+ raise ValueError(f"no Items.csv row for item id {iid} ({b['name']})")
566
+ try:
567
+ eid = int(irow.split(";")[ecol].strip())
568
+ except (ValueError, IndexError):
569
+ eid = -1
570
+ if eid < 0:
571
+ raise ValueError(f"{_items.name_of(iid) or iid} has no use-effect (EffectId < 0 -- not a usable item)")
572
+ base = patched.get(eid, erows.get(eid))
573
+ if base is None:
574
+ raise ValueError(f"no ItemEffects.csv row for EffectId {eid} ({b['name']})")
575
+ for idx, val in _effect_edits(b, ecols).items():
576
+ base = _set_col(base, idx, val)
577
+ patched[eid] = base
578
+ if not patched:
579
+ return None
580
+ return _emit(eheader, patched,
581
+ "# ff9mapkit [[item_effect]] -- ItemEffects.csv delta (merged by id, whole-row, over the base)")
582
+
583
+
584
+ # --- write into the mod (reads the install's base CSVs) -------------------------------------------
585
+
586
+ def _base_dir(game=None):
587
+ from ..config import find_game_path
588
+ return find_game_path(game) / "StreamingAssets" / "Data" / "Items"
589
+
590
+
591
+ # The base CSVs are cp1252 (e.g. byte 0x92 = the apostrophe in "What's That!?"), NOT UTF-8 -- and a delta must
592
+ # round-trip those bytes so the engine (which reads them the same non-UTF-8 way) parses it identically. We
593
+ # decode/encode cp1252 and strip a leading UTF-8 BOM at the byte level (else it would corrupt the first header
594
+ # line). Edited cells are ASCII digits; unchanged cells (incl. comment names) keep their exact bytes.
595
+ CSV_ENCODING = "cp1252"
596
+
597
+
598
+ def _read_text(path) -> str:
599
+ raw = path.read_bytes()
600
+ if raw.startswith(b"\xef\xbb\xbf"): # a UTF-8 BOM -> drop it (cp1252 would mangle it)
601
+ raw = raw[3:]
602
+ return raw.decode(CSV_ENCODING, errors="replace")
603
+
604
+
605
+ def write_item_data(layout, weapons=(), armors=(), items=(), equip_bonuses=(), item_effects=(), *, game=None) -> None:
606
+ """Emit the ``[[weapon]]`` / ``[[armor]]`` / ``[[item]]`` / ``[[equip_bonus]]`` / ``[[item_effect]]`` deltas into
607
+ ``layout``'s mod root. Reads the base rows from the install (raises a clear ValueError if it isn't reachable --
608
+ the deltas need the base columns). An ``[[equip_bonus]]`` mint repoints an item's BonusId, so its Items.csv
609
+ repoints merge into the same Items.csv delta as any ``[[item]]`` price edits."""
610
+ if not (weapons or armors or items or equip_bonuses or item_effects):
611
+ return
612
+ from ..config import ConfigError # a RuntimeError (no resolvable install), NOT OSError --
613
+ try: # catch it too so build.py's `except ValueError` warns+skips
614
+ d = _base_dir(game)
615
+ items_text = _read_text(d / "Items.csv")
616
+ except (OSError, ConfigError) as e:
617
+ raise ValueError("item-data patches ([[weapon]]/[[armor]]/[[item]]/[[equip_bonus]]/[[item_effect]]) need "
618
+ f"your FF9 install to read the base Items.csv columns: {e}") from e
619
+ repoints: dict = {}
620
+ stats_delta = None
621
+ if equip_bonuses:
622
+ try:
623
+ stats_text = _read_text(d / "Stats.csv")
624
+ except OSError as e:
625
+ raise ValueError("equip-bonus patches ([[equip_bonus]]) need your FF9 install to read the base "
626
+ f"Stats.csv columns -- couldn't read {d / 'Stats.csv'}: {e}") from e
627
+ stats_delta, repoints = build_equip_bonus_delta(items_text, stats_text, equip_bonuses)
628
+ plan = []
629
+ if weapons:
630
+ plan.append((layout.weapons_csv, build_weapons_delta(items_text, _read_text(d / "Weapons.csv"), weapons)))
631
+ if armors:
632
+ plan.append((layout.armors_csv, build_armors_delta(items_text, _read_text(d / "Armors.csv"), armors)))
633
+ if items or repoints:
634
+ plan.append((layout.items_csv, build_items_delta(items_text, items, bonusid_repoints=repoints)))
635
+ if stats_delta is not None:
636
+ plan.append((layout.stats_csv, stats_delta))
637
+ if item_effects:
638
+ plan.append((layout.item_effects_csv,
639
+ build_item_effects_delta(items_text, _read_text(d / "ItemEffects.csv"), item_effects)))
640
+ for path, text in plan:
641
+ if text is None:
642
+ continue
643
+ path.parent.mkdir(parents=True, exist_ok=True)
644
+ path.write_text(text, encoding=CSV_ENCODING, newline="\n")