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,441 @@
1
+ """``[[battle_action]]`` / ``[[status]]`` -- author the shared PLAYER abilities + status definitions as
2
+ partial-CSV deltas (the kit's WIN over Hades Workshop: declarative, campaign-wide, provenance-clean).
3
+
4
+ [[battle_action]] # rebalance a shared ability (Actions.csv, id 0-191)
5
+ action = "Fire" # name (resolved from the base CSV) or a 0-191 id
6
+ power = 30 # damage: power / element(s) / rate / mp / script / category / type
7
+ element = ["Ice"] # element names -> the `elements` bitmask
8
+ targets = "AllEnemy" # targeting (TargetType name/id) + menu_window (TargetDisplay) +
9
+ default_ally = true # default_ally / for_dead / default_on_dead / camera (bools) + vfx1 / vfx2
10
+ status_index = 70 # the StatusSets.csv row this action inflicts/cures
11
+
12
+ [[status]] # retune a status ailment (StatusData.csv, id 0-32)
13
+ status = "Poison"
14
+ tick = 30 # OprCount (per-tick effect, 0-255)
15
+ duration = 0 # ContiCount (0 = until cured, 0-65535)
16
+ clear_on_apply = ["Sleep"] # BattleStatus lists: what applying it CLEARS / what it grants IMMUNITY to
17
+ immunity_provided = ["Poison"]
18
+
19
+ WHY a delta + read-base: the engine merges these CSVs by **whole-ROW replacement** keyed on id
20
+ (``FF9BattleDB`` / ``EnumerateCsvFromLowToHigh``), so a partial file overrides only the rows it lists while
21
+ the base supplies the rest. To change ONE field we must therefore emit the COMPLETE row with the base game's
22
+ other columns -- so this reads the base ``Actions.csv`` / ``StatusData.csv`` LIVE from the install (provenance:
23
+ the authored ``field.toml`` holds only your overrides; the emitted CSV is mod build-output, never committed).
24
+ ★ The base file's ``#!`` option lines (e.g. ``#! IncludeCastingTitleType``) are LOAD-BEARING -- the engine
25
+ parses by column POSITION and ``#!`` toggles optional columns -- so the delta repeats them verbatim
26
+ (``CsvReader`` resets metadata per file). ★ The CSVs are **cp1252** (not UTF-8: a few names carry a 0x92 curly
27
+ apostrophe), read+written byte-faithfully. ★ Narrow engine column types (Byte/UInt16) are RANGE-CHECKED
28
+ offline: an out-of-range value would otherwise crash the battle DB at boot (``Byte.Parse`` overflow ->
29
+ ``ConfirmQuit``). These blocks are mod-GLOBAL (always-on, not new-game-scoped); they live on a ``field.toml``
30
+ and emit at the mod-write stage. See ``docs/BATTLE_DESIGN.md`` Phase 3.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import re
35
+
36
+ from . import battlecsv
37
+
38
+ _I32 = 2 ** 31 - 1
39
+ # friendly TOML key -> (CSV column, encoder, max). The capped columns are narrow engine types (Byte 0-255 /
40
+ # UInt16 0-65535); a value past the cap is rejected OFFLINE (else Byte.Parse overflows -> a boot crash).
41
+ ACTION_FIELDS = {
42
+ "power": ("power", "int", _I32),
43
+ "element": ("elements", "elements", 255), "elements": ("elements", "elements", 255),
44
+ "rate": ("rate", "int", _I32),
45
+ "mp": ("mp", "int", _I32),
46
+ "script": ("scriptid", "script", _I32), "script_id": ("scriptid", "script", _I32),
47
+ "category": ("category", "int", 255),
48
+ "type": ("type", "int", 255),
49
+ # targeting + presentation (cols 3-10): the engine parses these as TargetType/TargetDisplay ENUMS (by
50
+ # ``Name(value)``), Booleans (``1``/``0``), and signed/unsigned Int16 anim ids -- see the encoders below.
51
+ "targets": ("targets", "target_type", 0),
52
+ "menu_window": ("menuwindow", "target_display", 0),
53
+ "default_ally": ("defaultally", "bool", 0),
54
+ "for_dead": ("fordead", "bool", 0),
55
+ "default_on_dead": ("defaultondead", "bool", 0),
56
+ "camera": ("defaultcamera", "bool", 0),
57
+ "vfx1": ("animationid1", "sint", 32767), "animation1": ("animationid1", "sint", 32767),
58
+ "vfx2": ("animationid2", "int", 65535), "animation2": ("animationid2", "int", 65535),
59
+ "status_index": ("statusindex", "int", _I32), # the StatusSets.csv row this action inflicts/cures
60
+ }
61
+ STATUS_FIELDS = {
62
+ "tick": ("oprcount", "int", 255),
63
+ "duration": ("conticount", "int", 65535),
64
+ # what this status clears / blocks: a BattleStatus list (``Name(idx), ...``) via encode_status_list.
65
+ "clear_on_apply": ("clearonapply", "statuslist", 0),
66
+ "immunity_provided": ("immunityprovided", "statuslist", 0),
67
+ }
68
+ _ACTION_MAX_ID = 191
69
+ _STATUS_MAX_ID = 32
70
+ _STATUS_SET_MAX_ID = 65535 # StatusSetId is Int32; 0-38 are the base sets, >=39 = custom (the band an action's
71
+ # status_index points at). Cap generously -- catches a typo, never the real type.
72
+
73
+
74
+ class ActionDeltaError(ValueError):
75
+ pass
76
+
77
+
78
+ def _norm_name(s: str) -> str:
79
+ """Lowercase + straighten curly apostrophes so ``"Dragon's Crest"`` matches the cp1252 base name."""
80
+ return s.strip().lower().replace("’", "'").replace("‘", "'")
81
+
82
+
83
+ # ---- read the base CSV (cp1252, byte-faithful), preserving the #! options + the column legend ---------
84
+ def _read_raw(path) -> tuple:
85
+ """Parse a Memoria battle CSV for DELTA authoring -> ``(options, legend, cols, rows)``:
86
+ * ``options`` -- the ``#!`` lines, VERBATIM (load-bearing: they toggle optional columns).
87
+ * ``legend`` -- the ``# Comment;id;...`` header line (cosmetic; re-emitted for humans), or None.
88
+ * ``cols`` -- column name (normalized lower, ``Foo(bar)``->``foo``) -> index.
89
+ * ``rows`` -- ``{id: [cells...]}`` the FULL split cells of each data row (kept verbatim for re-emit).
90
+ Decoded as cp1252 (the install's real encoding) so a 0x92 apostrophe round-trips byte-faithfully."""
91
+ data = path.read_bytes()
92
+ if data.startswith(b"\xef\xbb\xbf"): # strip a stray UTF-8 BOM if one ever appears
93
+ data = data[3:]
94
+ options: list = []
95
+ legend = None
96
+ cols: "dict | None" = None
97
+ rows: dict = {}
98
+ for raw in data.decode("cp1252", errors="replace").splitlines():
99
+ s = raw.strip()
100
+ if not s:
101
+ continue
102
+ if s.startswith("#!"):
103
+ options.append(s)
104
+ continue
105
+ if s.startswith("#"):
106
+ if cols is None:
107
+ fields = [f.strip().split("(")[0].strip().lower() for f in s.lstrip("#").strip().split(";")]
108
+ if "id" in fields and len(fields) > 1:
109
+ cols = {name: i for i, name in enumerate(fields)}
110
+ legend = s
111
+ continue
112
+ if cols is None:
113
+ continue
114
+ cells = raw.split(";")
115
+ idx = cols["id"]
116
+ if idx >= len(cells):
117
+ continue
118
+ try:
119
+ rid = int(cells[idx].strip())
120
+ except ValueError:
121
+ continue
122
+ rows[rid] = cells
123
+ return options, legend, (cols or {}), rows
124
+
125
+
126
+ def _name_index(rows, cols) -> dict:
127
+ """``{normalized Comment name: [ids]}`` from the base rows (a name may map to several ids -> ambiguous)."""
128
+ nidx = cols.get("comment", 0)
129
+ out: dict = {}
130
+ for rid, cells in rows.items():
131
+ if nidx < len(cells):
132
+ nm = _norm_name(cells[nidx].split("#")[0])
133
+ if nm:
134
+ out.setdefault(nm, []).append(rid)
135
+ return out
136
+
137
+
138
+ def _resolve_id(token, rows, names, *, kind, max_id):
139
+ if token is None or isinstance(token, bool):
140
+ raise ActionDeltaError(f"[[{kind}]] needs a '{kind}' (a name or a 0-{max_id} id)")
141
+ if isinstance(token, int) or (isinstance(token, str) and token.strip().lstrip("-").isdigit()):
142
+ rid = int(token)
143
+ if not 0 <= rid <= max_id:
144
+ raise ActionDeltaError(f"[[{kind}]] id {rid} out of range (0-{max_id})")
145
+ if rid not in rows:
146
+ raise ActionDeltaError(f"[[{kind}]] id {rid} is not in the base CSV")
147
+ return rid
148
+ ids = names.get(_norm_name(str(token)))
149
+ if not ids:
150
+ raise ActionDeltaError(f"[[{kind}]] unknown name {token!r} (not a row in the base CSV)")
151
+ if len(ids) > 1:
152
+ raise ActionDeltaError(f"[[{kind}]] name {token!r} is ambiguous (ids "
153
+ f"{', '.join(str(i) for i in sorted(ids))}) -- use the id")
154
+ return ids[0]
155
+
156
+
157
+ # ---- value encoding + range guard (shared by build + offline validate) --------------------------------
158
+ def _to_int(value, key) -> int:
159
+ if isinstance(value, bool) or not isinstance(value, (int, str)):
160
+ raise ActionDeltaError(f"{key} must be an integer (got {value!r})")
161
+ try:
162
+ return int(value)
163
+ except ValueError:
164
+ raise ActionDeltaError(f"{key} must be an integer (got {value!r})")
165
+
166
+
167
+ def _resolve_script(value) -> int:
168
+ if isinstance(value, str) and not value.strip().lstrip("-").isdigit():
169
+ sid = {n.lower(): i for i, n in battlecsv.SCRIPT_IDS.items()}.get(value.strip().lower())
170
+ if sid is None:
171
+ raise ActionDeltaError(f"unknown scriptId formula {value!r} (see `ff9mapkit battle-actions "
172
+ f"--script-ids`)")
173
+ return sid
174
+ return _to_int(value, "script")
175
+
176
+
177
+ def _encode_value(key, value, spec, *, warnings=None) -> str:
178
+ """Resolve + RANGE-CHECK an override value -> the CSV cell string. Raises ActionDeltaError offline (so a
179
+ bad value fails the build/lint, never the game). ``warnings`` (optional) collects the script-catalog note."""
180
+ col, enc, vmax = spec
181
+ if enc == "int":
182
+ v = _to_int(value, key)
183
+ elif enc == "sint": # a SIGNED column (Int16 anim id): -vmax-1 .. vmax
184
+ v = _to_int(value, key)
185
+ if not -(vmax + 1) <= v <= vmax:
186
+ raise ActionDeltaError(f"{key}={v} out of range ({-(vmax + 1)}..{vmax})")
187
+ return str(v)
188
+ elif enc == "bool":
189
+ return _encode_bool(value, key) # the CSV stores Booleans as 1/0
190
+ elif enc in ("target_type", "target_display", "statuslist"):
191
+ fn = {"target_type": battlecsv.encode_target_type, "target_display": battlecsv.encode_target_display,
192
+ "statuslist": battlecsv.encode_status_list}[enc]
193
+ try:
194
+ return fn(value) # returns the final cell string (Name(value) / Name(idx) list)
195
+ except (ValueError, TypeError) as ex:
196
+ raise ActionDeltaError(f"{key}: {ex}")
197
+ elif enc == "elements":
198
+ try:
199
+ v = battlecsv.encode_elements(value)
200
+ except (ValueError, TypeError) as ex:
201
+ raise ActionDeltaError(f"{key}: {ex}")
202
+ elif enc == "script":
203
+ v = _resolve_script(value)
204
+ if warnings is not None and not battlecsv.is_stock_script(v):
205
+ warnings.append(f"scriptId {v} is not in the externalized formula catalog -- re-pointing an action "
206
+ f"at an existing (incl. base-engine) formula is data, but a BRAND-NEW formula needs "
207
+ f"a Memoria.Scripts.<Mod>.dll (not the engine DLL)")
208
+ else:
209
+ raise ActionDeltaError(f"internal: bad encoder {enc!r}")
210
+ if not 0 <= v <= vmax:
211
+ raise ActionDeltaError(f"{key}={v} out of range (0-{vmax})")
212
+ return str(v)
213
+
214
+
215
+ def _encode_bool(value, key) -> str:
216
+ """A bool / 0|1 / "true"|"false" -> the CSV "1"/"0" cell."""
217
+ if isinstance(value, bool):
218
+ return "1" if value else "0"
219
+ if isinstance(value, int) and value in (0, 1):
220
+ return str(value)
221
+ if isinstance(value, str) and value.strip().lower() in ("0", "1", "true", "false"):
222
+ return "1" if value.strip().lower() in ("1", "true") else "0"
223
+ raise ActionDeltaError(f"{key} must be a boolean (true/false or 1/0)")
224
+
225
+
226
+ def _apply_entries(entries, rows, cols, names, fields_map, *, kind, max_id) -> tuple:
227
+ """Apply ``[[kind]]`` override dicts onto the base ``rows`` in place. Returns (changed_ids, warnings)."""
228
+ warnings: list = []
229
+ changed: dict = {} # id -> first entry index that touched it (dup lint)
230
+ selector = "action" if kind == "battle_action" else "status"
231
+ for n, e in enumerate(entries):
232
+ rid = _resolve_id(e.get(selector), rows, names, kind=kind, max_id=max_id)
233
+ if rid in changed:
234
+ warnings.append(f"[[{kind}]] #{n} and #{changed[rid]} both target id {rid} -- they MERGE (a field "
235
+ f"set by both: the later wins)")
236
+ changed.setdefault(rid, n)
237
+ cells = rows[rid]
238
+ for k, v in e.items():
239
+ if k == selector:
240
+ continue
241
+ if k not in fields_map:
242
+ raise ActionDeltaError(f"[[{kind}]] {e.get(selector)!r}: unknown field {k!r} "
243
+ f"(known: {', '.join(sorted(fields_map))})")
244
+ col = fields_map[k][0]
245
+ ci = cols.get(col)
246
+ if ci is None:
247
+ warnings.append(f"[[{kind}]]: column {col!r} not present in this install's CSV -- {k} skipped")
248
+ continue
249
+ if ci >= len(cells):
250
+ raise ActionDeltaError(f"[[{kind}]] id {rid}: base row has no column {col!r}")
251
+ try:
252
+ cells[ci] = _encode_value(k, v, fields_map[k], warnings=warnings)
253
+ except ActionDeltaError as ex:
254
+ raise ActionDeltaError(f"[[{kind}]] {e.get(selector)!r} {ex}")
255
+ return list(changed), warnings
256
+
257
+
258
+ def _render(options, legend, rows, changed_ids, *, note) -> str:
259
+ lines = [note]
260
+ lines += options # the #! option lines, VERBATIM (load-bearing)
261
+ if legend:
262
+ lines.append(legend) # the column legend (cosmetic, for humans)
263
+ for rid in sorted(changed_ids):
264
+ lines.append(";".join(rows[rid]))
265
+ return "\n".join(lines) + "\n"
266
+
267
+
268
+ # ---- public: read the install + build the delta text -------------------------------------------------
269
+ def _csv_path(name, game):
270
+ from ..config import find_game_path
271
+ return find_game_path(game) / "StreamingAssets" / "Data" / "Battle" / name
272
+
273
+
274
+ def _build(name, entries, fields_map, *, kind, max_id, note, game):
275
+ try:
276
+ options, legend, cols, rows = _read_raw(_csv_path(name, game))
277
+ except (FileNotFoundError, OSError, RuntimeError) as ex: # incl. config.ConfigError (install not found)
278
+ raise ActionDeltaError(f"[[{kind}]] needs your FF9 install to read the base {name} ({ex})")
279
+ if not cols or not rows:
280
+ raise ActionDeltaError(f"could not parse the base {name} (no id column / no rows)")
281
+ changed, warnings = _apply_entries(entries, rows, cols, _name_index(rows, cols),
282
+ fields_map, kind=kind, max_id=max_id)
283
+ return _render(options, legend, rows, changed, note=note), warnings
284
+
285
+
286
+ def build_actions_delta(entries, *, game=None) -> tuple:
287
+ """Read the base Actions.csv + apply ``[[battle_action]]`` entries -> (delta_text, warnings)."""
288
+ note = ("# ff9mapkit [[battle_action]] -- a partial Actions.csv delta (merged over the base by the engine; "
289
+ "the #! lines below are load-bearing).")
290
+ return _build("Actions.csv", entries, ACTION_FIELDS, kind="battle_action", max_id=_ACTION_MAX_ID,
291
+ note=note, game=game)
292
+
293
+
294
+ def build_status_delta(entries, *, game=None) -> tuple:
295
+ note = ("# ff9mapkit [[status]] -- a partial StatusData.csv delta (merged over the base by the engine; "
296
+ "the #! lines below are load-bearing).")
297
+ return _build("StatusData.csv", entries, STATUS_FIELDS, kind="status", max_id=_STATUS_MAX_ID,
298
+ note=note, game=game)
299
+
300
+
301
+ def build_status_sets(entries, *, game=None) -> tuple:
302
+ """``[[status_set]]`` -> a partial ``StatusSets.csv`` (the named multi-status BUNDLES an action's
303
+ ``status_index`` points at). Emits ONLY the author's rows -- the engine merges low->high BY ID
304
+ (``FF9BattleDB.LoadStatusSets``), so no base read is needed (fully offline + provenance-clean). Row format
305
+ ``Name;Id;StatusList`` with ``#! UnshiftStatuses`` (the ``Name(idx)`` status list, reusing the StatusData
306
+ encoder). Returns (text, warnings)."""
307
+ note = ("# ff9mapkit [[status_set]] -- a partial StatusSets.csv (merged per-id over the base; ids 0-38 are "
308
+ "the base sets, use >=39 for a custom one). An action points at a set via its `status_index`.")
309
+ lines, warnings, seen = [note, "#! UnshiftStatuses"], [], {}
310
+ for n, e in enumerate(entries if isinstance(entries, list) else [entries]):
311
+ ctx = f"[[status_set]] #{n}"
312
+ if not isinstance(e, dict):
313
+ raise ActionDeltaError(f"{ctx} must be a table")
314
+ sid = _to_int(e.get("id"), f"{ctx} id")
315
+ if not 0 <= sid <= _STATUS_SET_MAX_ID:
316
+ raise ActionDeltaError(f"{ctx}: id {sid} out of range (0-{_STATUS_SET_MAX_ID}; 0-38 = base sets, "
317
+ f">=39 = custom)")
318
+ if sid in seen:
319
+ warnings.append(f"{ctx}: id {sid} already set by #{seen[sid]} -- the later wins")
320
+ seen[sid] = n
321
+ name = re.sub(r"[;\r\n]+", " ", str(e.get("name", f"Set {sid}"))).strip() or f"Set {sid}"
322
+ try:
323
+ statuses = battlecsv.encode_status_list(e.get("statuses"))
324
+ except (ValueError, TypeError) as ex:
325
+ raise ActionDeltaError(f"{ctx}: {ex}")
326
+ lines.append(f"{name};{sid};{statuses};# {name}")
327
+ return "\n".join(lines) + "\n", warnings
328
+
329
+
330
+ def validate_status_sets(entries) -> list:
331
+ """Offline structural + range problems for ``[[status_set]]`` (empty => OK)."""
332
+ try:
333
+ build_status_sets(entries)
334
+ except ActionDeltaError as ex:
335
+ return [str(ex)]
336
+ return []
337
+
338
+
339
+ def _ability_list(value, ctx) -> str:
340
+ """A list of active-ability ids (0-191, or ``"AA:n"`` tokens) -> the ``AA:n, AA:n`` Ability[] cell."""
341
+ if value is None:
342
+ return ""
343
+ out = []
344
+ for a in (value if isinstance(value, list) else [value]):
345
+ if isinstance(a, str) and a.strip().upper().startswith("AA:"):
346
+ a = a.strip()[3:]
347
+ aid = _to_int(a, ctx)
348
+ if not 0 <= aid <= _ACTION_MAX_ID:
349
+ raise ActionDeltaError(f"{ctx}: ability id {aid} out of range (0-{_ACTION_MAX_ID})")
350
+ out.append(f"AA:{aid}")
351
+ return ", ".join(out)
352
+
353
+
354
+ def build_magic_sword_sets(entries, *, game=None) -> tuple:
355
+ """``[[magic_sword_set]]`` -> a partial ``MagicSwordSets.csv`` (Steiner+Vivi-style combo unlocks): a
356
+ Supporter's BaseAbilities unlock the Beneficiary's UnlockedAbilities, unless a blocking status is present.
357
+ Per-id partial merge (``LoadMagicSwordSets`` via ``EnumerateCsvFromLowToHigh``) -> emits ONLY the author's
358
+ rows (no base read; offline + provenance-clean). Row ``Id;Sup;Ben;AA[];AA[];Status[];Status[]``."""
359
+ from . import characterdelta as _cd
360
+ note = ("# ff9mapkit [[magic_sword_set]] -- a partial MagicSwordSets.csv (merged per-id over the base). The "
361
+ "Supporter's base_abilities unlock the Beneficiary's unlocked_abilities (e.g. Vivi's Black Magic -> "
362
+ "Steiner's Magic Sword), unless a blocking status is on the supporter/beneficiary.")
363
+ lines, warnings, seen = [note, "#! IncludeStatusBlockers"], [], {}
364
+ for n, e in enumerate(entries if isinstance(entries, list) else [entries]):
365
+ ctx = f"[[magic_sword_set]] #{n}"
366
+ if not isinstance(e, dict):
367
+ raise ActionDeltaError(f"{ctx} must be a table")
368
+ sid = _to_int(e.get("id"), f"{ctx} id")
369
+ if not 0 <= sid <= _STATUS_SET_MAX_ID:
370
+ raise ActionDeltaError(f"{ctx}: id {sid} out of range (0-{_STATUS_SET_MAX_ID})")
371
+ if sid in seen:
372
+ warnings.append(f"{ctx}: id {sid} already set by #{seen[sid]} -- the later wins")
373
+ seen[sid] = n
374
+ try:
375
+ sup, ben = _cd._resolve_char_id(e.get("supporter")), _cd._resolve_char_id(e.get("beneficiary"))
376
+ except _cd.CharacterDeltaError as ex:
377
+ raise ActionDeltaError(f"{ctx} (supporter/beneficiary): {str(ex).split(': ', 1)[-1]}")
378
+ base = _ability_list(e.get("base_abilities"), f"{ctx} base_abilities")
379
+ unlocked = _ability_list(e.get("unlocked_abilities"), f"{ctx} unlocked_abilities")
380
+ try:
381
+ sup_b = battlecsv.encode_status_list(e.get("supporter_blocking_status"))
382
+ ben_b = battlecsv.encode_status_list(e.get("beneficiary_blocking_status"))
383
+ except (ValueError, TypeError) as ex:
384
+ raise ActionDeltaError(f"{ctx}: {ex}")
385
+ cmt = re.sub(r"[;\r\n]+", " ", str(e.get("name", f"magic sword set {sid}"))).strip() or f"set {sid}"
386
+ lines.append(f"{sid};{sup};{ben};{base};{unlocked};{sup_b};{ben_b};# {cmt}")
387
+ return "\n".join(lines) + "\n", warnings
388
+
389
+
390
+ def validate_magic_sword_sets(entries) -> list:
391
+ """Offline structural + range problems for ``[[magic_sword_set]]`` (empty => OK)."""
392
+ try:
393
+ build_magic_sword_sets(entries)
394
+ except ActionDeltaError as ex:
395
+ return [str(ex)]
396
+ return []
397
+
398
+
399
+ def write_battle_data(layout, *, actions=None, statuses=None, status_sets=None, magic_sword_sets=None,
400
+ game=None) -> list:
401
+ """Emit the Actions / StatusData / StatusSets / MagicSwordSets CSV deltas into ``layout`` (mod-write stage).
402
+ Returns warnings. Written cp1252 (byte-faithful with the base) + LF; the engine StreamReader is EOL-agnostic."""
403
+ warnings: list = []
404
+ for entries, path, builder in ((actions, layout.actions_csv, build_actions_delta),
405
+ (statuses, layout.status_data_csv, build_status_delta),
406
+ (status_sets, layout.status_sets_csv, build_status_sets),
407
+ (magic_sword_sets, layout.magic_sword_sets_csv, build_magic_sword_sets)):
408
+ if entries:
409
+ text, w = builder(entries, game=game)
410
+ path.parent.mkdir(parents=True, exist_ok=True)
411
+ path.write_text(text, encoding="cp1252", errors="replace", newline="\n")
412
+ warnings += w
413
+ return warnings
414
+
415
+
416
+ # ---- offline (no-install) structural + range validation ----------------------------------------------
417
+ def validate_entry(entry, *, kind) -> list:
418
+ """Checks that don't need the install: structural + the value range/encoders (so `lint` catches an
419
+ out-of-Byte-range value or a bad element/script name offline). The name->id resolution of the
420
+ action/status itself happens at build (which has the install to read the base row)."""
421
+ fields_map = ACTION_FIELDS if kind == "battle_action" else STATUS_FIELDS
422
+ selector = "action" if kind == "battle_action" else "status"
423
+ problems: list = []
424
+ if not isinstance(entry, dict):
425
+ return [f"[[{kind}]] must be a table ({selector} = \"...\", a field = value)"]
426
+ if entry.get(selector) is None or isinstance(entry.get(selector), bool):
427
+ problems.append(f"[[{kind}]] needs a '{selector}' (a name or an id)")
428
+ overrides = [k for k in entry if k != selector]
429
+ if not overrides:
430
+ problems.append(f"[[{kind}]] {entry.get(selector)!r} sets no fields (give e.g. "
431
+ f"{'power = 30' if kind == 'battle_action' else 'tick = 30'})")
432
+ for k in overrides:
433
+ if k not in fields_map:
434
+ problems.append(f"[[{kind}]] {entry.get(selector)!r}: unknown field {k!r} "
435
+ f"(known: {', '.join(sorted(fields_map))})")
436
+ continue
437
+ try:
438
+ _encode_value(k, entry[k], fields_map[k])
439
+ except ActionDeltaError as ex:
440
+ problems.append(f"[[{kind}]] {entry.get(selector)!r} {ex}")
441
+ return problems