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,789 @@
1
+ """``[[character]]`` / ``[[leveling]]`` -- author the PLAYER-side balance CSVs (``Data/Characters``) as deltas:
2
+ the Phase-5 twin of :mod:`actiondelta` (which does the enemy/ability side). See ``docs/BATTLE_DESIGN.md`` §8.
3
+
4
+ [[character]] # per-character base stats (BaseStats.csv, CharacterId 0-11)
5
+ character = "Vivi" # name (Zidane..Beatrix) or a 0-11 id
6
+ strength = 30 # any of: dexterity / strength / magic / will / gems
7
+ magic = 40
8
+
9
+ [[leveling]] # the 99-step growth curve (Leveling.csv, by level 1-99)
10
+ level = 50 # 1-99
11
+ exp = 250000 # experience to the NEXT level (UInt32)
12
+ bonus_hp = 4000 # HP at this level grows BonusHP*Strength/50 (UInt16)
13
+ bonus_mp = 600 # MP grows BonusMP*Magic/100 (UInt16)
14
+
15
+ WHY the two channels differ (this is the whole design):
16
+ * **BaseStats.csv merges PER-ID** -- ``EnumerateCsvFromLowToHigh`` then ``result[id]=row`` (``ff9level.cs:30``),
17
+ so a PARTIAL file overrides only the characters it lists; the base supplies the other 11. A delta is legal.
18
+ * **Leveling.csv is read WHOLE-FILE** -- ``GetCsvWithHighestPriority`` (``ff9level.cs:53``) returns the single
19
+ highest-priority file (it never accumulates rows) and the loader GATES at ``levels.Length >= 99``
20
+ (``ff9level.cs:59``). So a partial Leveling.csv would **WIPE** every level it omits -> we read the base 99
21
+ rows LIVE, patch the named levels, and re-emit ALL 99. (Like ``InitialItems.csv``, a higher-priority stacked
22
+ mod folder's Leveling.csv SHADOWS ours -- warned.)
23
+
24
+ Both read the base CSV LIVE from the install (provenance: only your overrides live in the toml; the emitted CSV
25
+ is mod build-output, never committed). The full ``#`` header block is preserved verbatim. Narrow engine column
26
+ types (Byte / UInt16 / UInt32) are RANGE-CHECKED offline so an out-of-range value fails the build/lint -- never
27
+ the game's boot (``CsvParser.Byte`` would overflow -> ``ff9level`` ``ConfirmQuit`` at load). cp1252 + LF, matching
28
+ the install.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import re
33
+
34
+ _U16 = 0xFFFF
35
+ _U32 = 0xFFFFFFFF
36
+ _I32 = 2 ** 31 - 1
37
+
38
+ # committed CharacterId name->id (the open-source Memoria enum, CharacterId.cs: Zidane=0 .. Beatrix=11). The
39
+ # 8-11 guests (Cinna/Marcus/Blank/Beatrix) are valid BaseStats ids too. Provenance-clean (enum names, no SE data).
40
+ CHARACTER_IDS = {
41
+ "zidane": 0, "vivi": 1, "garnet": 2, "steiner": 3, "freya": 4, "quina": 5, "eiko": 6, "amarant": 7,
42
+ "cinna": 8, "marcus": 9, "blank": 10, "beatrix": 11,
43
+ }
44
+ _MAX_CHAR_ID = 11
45
+
46
+ # friendly TOML key -> (BaseStats column name, max). Dexterity/Strength/Magic/Will are Byte (the base stat; the
47
+ # engine formula later clamps the DERIVED stat to 50/99). Gems is UInt32.
48
+ CHARACTER_FIELDS = {
49
+ "dexterity": ("dexterity", 0xFF), "dex": ("dexterity", 0xFF),
50
+ "strength": ("strength", 0xFF), "str": ("strength", 0xFF),
51
+ "magic": ("magic", 0xFF), "mag": ("magic", 0xFF),
52
+ "will": ("will", 0xFF), "spirit": ("will", 0xFF),
53
+ "gems": ("gems", _U32),
54
+ }
55
+
56
+ # Leveling has NO id column -- it is keyed by ROW ORDER (line N = level N). friendly key -> (column INDEX, max).
57
+ LEVELING_FIELDS = {
58
+ "exp": (0, _U32), "experience": (0, _U32),
59
+ "bonus_hp": (1, _U16), "hp": (1, _U16),
60
+ "bonus_mp": (2, _U16), "mp": (2, _U16),
61
+ }
62
+ _LEVEL_COUNT = 99
63
+
64
+ # committed SupportAbility names by id (the open-source Memoria enum SupportAbility.cs: id 0-62 real, 63=Void
65
+ # sentinel). Provenance-clean (enum names, no SE data). The CSV's Comment column ("Auto-Reflect") differs from
66
+ # these enum names ("AutoReflect"), so we key by Id and match input by a normalized name (strip non-alphanumerics).
67
+ _SA_NAMES = (
68
+ "AutoReflect", "AutoFloat", "AutoHaste", "AutoRegen", "AutoLife", "HP10", "HP20", "MP10", "MP20", "Accuracy",
69
+ "Distract", "LongReach", "MPAttack", "BirdKiller", "BugKiller", "StoneKiller", "UndeadKiller", "DragonKiller",
70
+ "DevilKiller", "BeastKiller", "ManEater", "HighJump", "MasterThief", "StealGil", "Healer", "AddStatus",
71
+ "GambleDefence", "Chemist", "PowerThrow", "PowerUp", "ReflectNull", "Reflectx2", "MagElemNull", "Concentrate",
72
+ "HalfMP", "HighTide", "Counter", "Cover", "ProtectGirls", "Eye4Eye", "BodyTemp", "Alert", "Initiative",
73
+ "LevelUp", "AbilityUp", "Millionaire", "FleeGil", "GuardianMog", "Insomniac", "Antibody", "BrightEyes",
74
+ "Loudmouth", "RestoreHP", "Jelly", "ReturnMagic", "AbsorbMP", "AutoPotion", "Locomotion", "ClearHeaded",
75
+ "Boost", "OdinSword", "Mug", "Bandit", "Void",
76
+ )
77
+ _MAX_SA_ID = len(_SA_NAMES) - 1 # 63 (Void)
78
+
79
+
80
+ def _norm_sa(s) -> str:
81
+ return re.sub(r"[^0-9a-z]", "", str(s).lower())
82
+
83
+
84
+ _SA_BY_NORM = {_norm_sa(n): i for i, n in enumerate(_SA_NAMES)}
85
+ # id 60's CSV display Comment is "Odin's Sword" (possessive) -> normalizes to "odinssword" (the apostrophe-s adds
86
+ # an extra 's'), differing from the enum "OdinSword" -> "odinsword". It is the ONLY one of 64 whose display name
87
+ # diverges this way, so alias it -> a user copying the name the `ability-gems` catalog prints resolves correctly.
88
+ _SA_BY_NORM.setdefault("odinssword", 60)
89
+
90
+
91
+ class CharacterDeltaError(ValueError):
92
+ pass
93
+
94
+
95
+ def _csv_path(name, game):
96
+ from ..config import find_game_path
97
+ return find_game_path(game) / "StreamingAssets" / "Data" / "Characters" / name
98
+
99
+
100
+ def _to_int(value, key) -> int:
101
+ if isinstance(value, bool) or not isinstance(value, (int, str)):
102
+ raise CharacterDeltaError(f"{key} must be an integer (got {value!r})")
103
+ try:
104
+ return int(value)
105
+ except ValueError:
106
+ raise CharacterDeltaError(f"{key} must be an integer (got {value!r})")
107
+
108
+
109
+ def _range(v, vmax, key) -> str:
110
+ if not 0 <= v <= vmax:
111
+ raise CharacterDeltaError(f"{key}={v} out of range (0-{vmax})")
112
+ return str(v)
113
+
114
+
115
+ # ---- read the base CSV (cp1252, byte-faithful), preserving the FULL header block verbatim -----------------
116
+ def _read_csv(path) -> tuple:
117
+ """Parse a ``Data/Characters`` CSV -> ``(header_lines, legend_cols, data_rows)``:
118
+ * ``header_lines`` -- every ``#`` line (comments / ``#!`` options / legend / type row), VERBATIM + in order.
119
+ * ``legend_cols`` -- ``{lower column name: index}`` from the first ``#``-legend with an ``id`` field (BaseStats
120
+ has one; Leveling does NOT -> ``{}``, the caller keys by row order instead).
121
+ * ``data_rows`` -- the list of ``;``-split data rows, IN ORDER (verbatim cells, for re-emit)."""
122
+ data = path.read_bytes()
123
+ if data.startswith(b"\xef\xbb\xbf"):
124
+ data = data[3:]
125
+ header: list = []
126
+ legend: "dict | None" = None
127
+ rows: list = []
128
+ for raw in data.decode("cp1252", errors="replace").splitlines():
129
+ s = raw.strip()
130
+ if not s:
131
+ continue
132
+ if s.startswith("#"):
133
+ header.append(raw) # keep comments/#!/legend/types verbatim
134
+ if legend is None and not s.startswith("#!"):
135
+ fields = [f.strip().split("(")[0].strip().lower() for f in s.lstrip("#").strip().split(";")]
136
+ if "id" in fields and len(fields) > 1:
137
+ legend = {name: i for i, name in enumerate(fields)}
138
+ continue
139
+ rows.append(raw.split(";"))
140
+ return header, (legend or {}), rows
141
+
142
+
143
+ # ---- read-live catalog (for the `characters` CLI -- the import->SEE->tune view) ---------------------------
144
+ def basestats_catalog(game=None):
145
+ """``[(name, id, [(stat, value)...])...]`` per character from the live BaseStats.csv, or None if unreadable
146
+ (offline-safe). The provenance-clean READ side (names/ids/the live values shown, never committed)."""
147
+ try:
148
+ _header, cols, rows = _read_csv(_csv_path("BaseStats.csv", game))
149
+ except (FileNotFoundError, OSError, RuntimeError):
150
+ return None
151
+ if not cols or not rows:
152
+ return None
153
+ nidx = cols.get("comment", 0)
154
+ out = []
155
+ for cells in rows:
156
+ try:
157
+ cid = int(cells[cols["id"]].strip())
158
+ except (ValueError, IndexError, KeyError):
159
+ continue
160
+ name = cells[nidx].strip() if nidx < len(cells) else str(cid)
161
+ stats = [(s, cells[cols[s]].strip()) for s in ("dexterity", "strength", "magic", "will", "gems")
162
+ if cols.get(s) is not None and cols[s] < len(cells)]
163
+ out.append((name, cid, stats))
164
+ return sorted(out, key=lambda t: t[1])
165
+
166
+
167
+ def ability_gems_catalog(game=None):
168
+ """``[(name, id, gems)...]`` per SupportAbility from the live AbilityGems.csv, or None if unreadable. The
169
+ name is the CSV's display Comment (e.g. ``Auto-Reflect``); ``[[ability_gem]]`` accepts that, the enum name
170
+ (``AutoReflect``), or the id."""
171
+ try:
172
+ _h, cols, rows = _read_csv(_csv_path("Abilities/AbilityGems.csv", game))
173
+ except (FileNotFoundError, OSError, RuntimeError):
174
+ return None
175
+ if not cols or not rows:
176
+ return None
177
+ nidx, gem_col = cols.get("comment", 0), cols.get("gems", cols.get("gemscount", 2))
178
+ out = []
179
+ for cells in rows:
180
+ try:
181
+ aid = int(cells[cols["id"]].strip())
182
+ except (ValueError, IndexError, KeyError):
183
+ continue
184
+ name = cells[nidx].strip() if nidx < len(cells) else _SA_NAMES[aid] if aid <= _MAX_SA_ID else str(aid)
185
+ gems = cells[gem_col].strip() if gem_col < len(cells) else "?"
186
+ out.append((name, aid, gems))
187
+ return sorted(out, key=lambda t: t[1])
188
+
189
+
190
+ # ---- [[character]] -> BaseStats.csv (per-id PARTIAL delta) ------------------------------------------------
191
+ def _resolve_char_id(token):
192
+ if token is None or isinstance(token, bool):
193
+ raise CharacterDeltaError("[[character]] needs a 'character' (a name or a 0-11 id)")
194
+ if isinstance(token, int) or (isinstance(token, str) and token.strip().lstrip("-").isdigit()):
195
+ cid = int(token)
196
+ if not 0 <= cid <= _MAX_CHAR_ID:
197
+ raise CharacterDeltaError(f"[[character]] id {cid} out of range (0-{_MAX_CHAR_ID})")
198
+ return cid
199
+ cid = CHARACTER_IDS.get(str(token).strip().lower())
200
+ if cid is None:
201
+ raise CharacterDeltaError(f"[[character]] unknown character {token!r} "
202
+ f"(known: {', '.join(n.title() for n in CHARACTER_IDS)})")
203
+ return cid
204
+
205
+
206
+ def build_basestats_delta(entries, *, game=None) -> tuple:
207
+ """Read the base BaseStats.csv + apply ``[[character]]`` entries -> (delta_text, warnings). A PARTIAL delta:
208
+ only the changed character rows are emitted; the engine supplies the rest per-id."""
209
+ try:
210
+ header, cols, rows = _read_csv(_csv_path("BaseStats.csv", game))
211
+ except (FileNotFoundError, OSError, RuntimeError) as ex:
212
+ raise CharacterDeltaError(f"[[character]] needs your FF9 install to read the base BaseStats.csv ({ex})")
213
+ if not cols or not rows:
214
+ raise CharacterDeltaError("could not parse the base BaseStats.csv (no id-legend / no rows)")
215
+ if not isinstance(entries, list):
216
+ raise CharacterDeltaError("[[character]] must be a list of tables")
217
+ idx = cols["id"]
218
+ by_id = {}
219
+ for cells in rows:
220
+ try:
221
+ by_id[int(cells[idx].strip())] = cells
222
+ except (ValueError, IndexError):
223
+ continue
224
+ warnings: list = []
225
+ changed: dict = {}
226
+ for n, e in enumerate(entries):
227
+ if not isinstance(e, dict):
228
+ raise CharacterDeltaError(f"[[character]] #{n} must be a table (got {type(e).__name__})")
229
+ cid = _resolve_char_id(e.get("character"))
230
+ if cid not in by_id:
231
+ raise CharacterDeltaError(f"[[character]] id {cid} is not in the base BaseStats.csv")
232
+ if cid in changed:
233
+ warnings.append(f"[[character]] #{n} and #{changed[cid]} both target id {cid} -- they MERGE "
234
+ f"(a field set by both: the later wins)")
235
+ changed.setdefault(cid, n)
236
+ cells = by_id[cid]
237
+ for k, v in e.items():
238
+ if k == "character":
239
+ continue
240
+ spec = CHARACTER_FIELDS.get(k)
241
+ if spec is None:
242
+ raise CharacterDeltaError(f"[[character]] {e.get('character')!r}: unknown field {k!r} "
243
+ f"(known: {', '.join(sorted(set(s[0] for s in CHARACTER_FIELDS.values())))})")
244
+ col, vmax = spec
245
+ ci = cols.get(col)
246
+ if ci is None or ci >= len(cells):
247
+ raise CharacterDeltaError(f"[[character]] id {cid}: base row has no column {col!r}")
248
+ cells[ci] = _range(_to_int(v, f"{e.get('character')} {k}"), vmax, f"[[character]] {e.get('character')!r} {k}")
249
+ note = "# ff9mapkit [[character]] -- a partial BaseStats.csv delta (merged per-CharacterId over the base)."
250
+ out = [note] + header + [";".join(by_id[c]) for c in sorted(changed)]
251
+ return "\n".join(out) + "\n", warnings
252
+
253
+
254
+ # ---- [[leveling]] -> Leveling.csv (WHOLE-FILE; read all 99, patch by level, re-emit all 99) ----------------
255
+ def build_leveling_file(entries, *, game=None) -> tuple:
256
+ """Read the base Leveling.csv + apply ``[[leveling]]`` entries -> (full_99_row_text, warnings). WHOLE-FILE:
257
+ the engine reads only the highest-priority Leveling.csv and gates at >=99 rows, so we re-emit ALL 99."""
258
+ try:
259
+ header, _cols, rows = _read_csv(_csv_path("Leveling.csv", game))
260
+ except (FileNotFoundError, OSError, RuntimeError) as ex:
261
+ raise CharacterDeltaError(f"[[leveling]] needs your FF9 install to read the base Leveling.csv ({ex})")
262
+ if len(rows) < _LEVEL_COUNT:
263
+ raise CharacterDeltaError(f"the base Leveling.csv has {len(rows)} rows, need >= {_LEVEL_COUNT}")
264
+ if not isinstance(entries, list):
265
+ raise CharacterDeltaError("[[leveling]] must be a list of tables")
266
+ warnings: list = []
267
+ seen: dict = {}
268
+ for n, e in enumerate(entries):
269
+ if not isinstance(e, dict):
270
+ raise CharacterDeltaError(f"[[leveling]] #{n} must be a table (got {type(e).__name__})")
271
+ lvl = _to_int(e.get("level"), "[[leveling]] level")
272
+ if not 1 <= lvl <= _LEVEL_COUNT:
273
+ raise CharacterDeltaError(f"[[leveling]] level {lvl} out of range (1-{_LEVEL_COUNT})")
274
+ if lvl in seen:
275
+ warnings.append(f"[[leveling]] #{n} and #{seen[lvl]} both target level {lvl} -- the later wins")
276
+ seen.setdefault(lvl, n)
277
+ overrides = [k for k in e if k != "level"]
278
+ if not overrides:
279
+ raise CharacterDeltaError(f"[[leveling]] level {lvl} sets no fields (give exp / bonus_hp / bonus_mp)")
280
+ cells = rows[lvl - 1] # row order == level (line N = level N)
281
+ for k in overrides:
282
+ spec = LEVELING_FIELDS.get(k)
283
+ if spec is None:
284
+ raise CharacterDeltaError(f"[[leveling]] level {lvl}: unknown field {k!r} "
285
+ f"(known: exp, bonus_hp, bonus_mp)")
286
+ ci, vmax = spec
287
+ if ci >= len(cells):
288
+ raise CharacterDeltaError(f"[[leveling]] level {lvl}: base row has no column index {ci}")
289
+ cells[ci] = _range(_to_int(e[k], f"level {lvl} {k}"), vmax, f"[[leveling]] level {lvl} {k}")
290
+ warnings.append("[[leveling]] -> Leveling.csv is WHOLE-FILE (highest-priority-wins): it REPLACES the entire "
291
+ "growth curve, and a stacked higher-priority mod folder's Leveling.csv would SHADOW it")
292
+ note = "# ff9mapkit [[leveling]] -- the COMPLETE 99-row Leveling.csv (whole-file; patched levels + the base rest)."
293
+ out = [note] + header + [";".join(r) for r in rows[:_LEVEL_COUNT]] + [";".join(r) for r in rows[_LEVEL_COUNT:]]
294
+ return "\n".join(out) + "\n", warnings
295
+
296
+
297
+ # ---- [[ability_gem]] -> AbilityGems.csv (per-SupportAbility PARTIAL delta; the gem-COST balance lever) -----
298
+ def _resolve_sa_id(token):
299
+ if token is None or isinstance(token, bool):
300
+ raise CharacterDeltaError("[[ability_gem]] needs an 'ability' (a SupportAbility name or a 0-63 id)")
301
+ if isinstance(token, int) or (isinstance(token, str) and token.strip().lstrip("-").isdigit()):
302
+ aid = int(token)
303
+ if not 0 <= aid <= _MAX_SA_ID:
304
+ raise CharacterDeltaError(f"[[ability_gem]] id {aid} out of range (0-{_MAX_SA_ID})")
305
+ return aid
306
+ aid = _SA_BY_NORM.get(_norm_sa(token))
307
+ if aid is None:
308
+ raise CharacterDeltaError(f"[[ability_gem]] unknown ability {token!r} "
309
+ f"(a SupportAbility name like 'Auto-Haste'/'AutoHaste', or a 0-{_MAX_SA_ID} id)")
310
+ return aid
311
+
312
+
313
+ def build_ability_gems_delta(entries, *, game=None) -> tuple:
314
+ """Read the base AbilityGems.csv + apply ``[[ability_gem]]`` entries -> (delta_text, warnings). A PARTIAL
315
+ delta keyed per-SupportAbility (``EnumerateCsvFromLowToHigh``, ff9abil.cs:409); only the changed rows are
316
+ emitted, the base supplies the other 63. The ``#! IncludeBoosted`` option + the Boosted column are preserved
317
+ verbatim in the header/rows (load-bearing: the engine parses Boosted only when that option is present)."""
318
+ try:
319
+ header, cols, rows = _read_csv(_csv_path("Abilities/AbilityGems.csv", game))
320
+ except (FileNotFoundError, OSError, RuntimeError) as ex:
321
+ raise CharacterDeltaError(f"[[ability_gem]] needs your FF9 install to read the base AbilityGems.csv ({ex})")
322
+ if not cols or not rows:
323
+ raise CharacterDeltaError("could not parse the base AbilityGems.csv (no id-legend / no rows)")
324
+ if not isinstance(entries, list):
325
+ raise CharacterDeltaError("[[ability_gem]] must be a list of tables")
326
+ idx = cols["id"]
327
+ gem_col = cols.get("gems", cols.get("gemscount", 2))
328
+ by_id = {}
329
+ for cells in rows:
330
+ try:
331
+ by_id[int(cells[idx].strip())] = cells
332
+ except (ValueError, IndexError):
333
+ continue
334
+ warnings: list = []
335
+ changed: dict = {}
336
+ for n, e in enumerate(entries):
337
+ if not isinstance(e, dict):
338
+ raise CharacterDeltaError(f"[[ability_gem]] #{n} must be a table (got {type(e).__name__})")
339
+ aid = _resolve_sa_id(e.get("ability"))
340
+ if aid not in by_id:
341
+ raise CharacterDeltaError(f"[[ability_gem]] id {aid} is not in the base AbilityGems.csv")
342
+ if aid in changed:
343
+ warnings.append(f"[[ability_gem]] #{n} and #{changed[aid]} both target ability {aid} -- the later wins")
344
+ changed.setdefault(aid, n)
345
+ overrides = [k for k in e if k != "ability"]
346
+ if not overrides:
347
+ raise CharacterDeltaError(f"[[ability_gem]] {e.get('ability')!r} sets no fields (give gems = N)")
348
+ for k in overrides:
349
+ if k != "gems":
350
+ raise CharacterDeltaError(f"[[ability_gem]] {e.get('ability')!r}: unknown field {k!r} (known: gems)")
351
+ cells = by_id[aid]
352
+ if gem_col >= len(cells):
353
+ raise CharacterDeltaError(f"[[ability_gem]] id {aid}: base row has no gems column")
354
+ cells[gem_col] = _range(_to_int(e[k], f"{e.get('ability')} gems"), _I32,
355
+ f"[[ability_gem]] {e.get('ability')!r} gems")
356
+ note = "# ff9mapkit [[ability_gem]] -- a partial AbilityGems.csv delta (merged per-SupportAbility over the base)."
357
+ out = [note] + header + [";".join(by_id[a]) for a in sorted(changed)]
358
+ return "\n".join(out) + "\n", warnings
359
+
360
+
361
+ # ---- CharacterPresetId 0-19 (the per-preset Abilities/<Name>.csv learn files + the CommandSets/menu_type key) -
362
+ # DISTINCT from CHARACTER_IDS (0-11): guests split into two preset slots (Cinna1/2 etc.), and the canonical enum
363
+ # NAME is the filename. Committed open-source names (CharacterPresetId.cs); provenance-clean.
364
+ _PRESET_NAMES = ("Zidane", "Vivi", "Garnet", "Steiner", "Freya", "Quina", "Eiko", "Amarant",
365
+ "Cinna1", "Cinna2", "Marcus1", "Marcus2", "Blank1", "Blank2", "Beatrix1", "Beatrix2",
366
+ "StageZidane", "StageCinna", "StageMarcus", "StageBlank")
367
+ PRESET_IDS = {n.lower(): i for i, n in enumerate(_PRESET_NAMES)}
368
+ _MAX_PRESET_ID = len(_PRESET_NAMES) - 1
369
+ _AMBIGUOUS_PRESETS = {"cinna": ("Cinna1", "Cinna2"), "marcus": ("Marcus1", "Marcus2"),
370
+ "blank": ("Blank1", "Blank2"), "beatrix": ("Beatrix1", "Beatrix2")}
371
+
372
+
373
+ def _resolve_preset(token, ctx="[[learn]]"):
374
+ """A CharacterPresetId name or 0-19 id -> (id, canonical_name). Bare Cinna/Marcus/Blank/Beatrix = ambiguous."""
375
+ if token is None or isinstance(token, bool):
376
+ raise CharacterDeltaError(f"{ctx} needs a 'preset' (a CharacterPresetId name or a 0-{_MAX_PRESET_ID} id)")
377
+ if isinstance(token, int) or (isinstance(token, str) and token.strip().lstrip("-").isdigit()):
378
+ pid = int(token)
379
+ if not 0 <= pid <= _MAX_PRESET_ID:
380
+ raise CharacterDeltaError(f"{ctx} preset id {pid} out of range (0-{_MAX_PRESET_ID})")
381
+ return pid, _PRESET_NAMES[pid]
382
+ key = str(token).strip().lower()
383
+ if key in _AMBIGUOUS_PRESETS:
384
+ raise CharacterDeltaError(f"{ctx} preset {token!r} is ambiguous -- use {' or '.join(_AMBIGUOUS_PRESETS[key])}")
385
+ pid = PRESET_IDS.get(key)
386
+ if pid is None:
387
+ raise CharacterDeltaError(f"{ctx} unknown preset {token!r} (a CharacterPresetId name or 0-{_MAX_PRESET_ID} id)")
388
+ return pid, _PRESET_NAMES[pid]
389
+
390
+
391
+ # ---- [[character_param]] -> CharacterParameters.csv (PARTIAL per-id; FIXED-INDEX cols -- legend names are stale) -
392
+ # All numerics are CsvParser.Byte (0-255; the legend type row "Int32;Boolean" is a LIE). Cols 6/7 are Strings.
393
+ CHARACTER_PARAM_FIELDS = {
394
+ "row": (1, "int", 0xFF), "win_pose": (2, "int", 0xFF), "category": (3, "int", 0xFF),
395
+ "menu_type": (4, "preset", 0xFF), "preset": (4, "preset", 0xFF),
396
+ "equipment_set": (5, "int", 0xFF), "equip_set": (5, "int", 0xFF),
397
+ "serial_formula": (6, "str", 0), "name_keyword": (7, "str", 0),
398
+ }
399
+
400
+
401
+ def _resolve_char_id_as(token, ctx):
402
+ try:
403
+ return _resolve_char_id(token)
404
+ except CharacterDeltaError as ex:
405
+ raise CharacterDeltaError(str(ex).replace("[[character]]", ctx, 1))
406
+
407
+
408
+ def _encode_param(value, kind, vmax, key) -> str:
409
+ if kind == "str":
410
+ s = str(value)
411
+ if ";" in s:
412
+ raise CharacterDeltaError(f"{key}: a String value can't contain ';' (the CSV delimiter)")
413
+ return s
414
+ if kind == "preset":
415
+ return str(_resolve_preset(value, key)[0]) # a CharacterPresetId: bounded to 0-19 (name OR id), NOT
416
+ return str(_range(_to_int(value, key), vmax, key)) # 0-255 -- a 20-254 menu_type crashes at battle entry
417
+
418
+
419
+ def build_character_params_delta(entries, *, game=None) -> tuple:
420
+ """Read CharacterParameters.csv + apply ``[[character_param]]`` -> (partial delta, warnings). PER-id (0-11):
421
+ only the changed rows are emitted; the base supplies the rest. Columns are written by FIXED INDEX."""
422
+ try:
423
+ header, cols, rows = _read_csv(_csv_path("CharacterParameters.csv", game))
424
+ except (FileNotFoundError, OSError, RuntimeError) as ex:
425
+ raise CharacterDeltaError(f"[[character_param]] needs your FF9 install to read CharacterParameters.csv ({ex})")
426
+ if not rows:
427
+ raise CharacterDeltaError("could not parse the base CharacterParameters.csv (no rows)")
428
+ if not isinstance(entries, list):
429
+ raise CharacterDeltaError("[[character_param]] must be a list of tables")
430
+ idx = cols.get("id", 0) # Id is col 0 (the legend may not name it)
431
+ by_id, warnings, changed = {}, [], {}
432
+ for cells in rows:
433
+ try:
434
+ by_id[int(cells[idx].strip())] = cells
435
+ except (ValueError, IndexError):
436
+ continue
437
+ for n, e in enumerate(entries):
438
+ if not isinstance(e, dict):
439
+ raise CharacterDeltaError(f"[[character_param]] #{n} must be a table (got {type(e).__name__})")
440
+ cid = _resolve_char_id_as(e.get("character"), "[[character_param]]")
441
+ if cid not in by_id:
442
+ raise CharacterDeltaError(f"[[character_param]] id {cid} is not in the base CharacterParameters.csv")
443
+ if cid in changed:
444
+ warnings.append(f"[[character_param]] #{n} and #{changed[cid]} both target id {cid} -- the later wins")
445
+ changed.setdefault(cid, n)
446
+ cells = by_id[cid]
447
+ for k, v in e.items():
448
+ if k == "character":
449
+ continue
450
+ spec = CHARACTER_PARAM_FIELDS.get(k)
451
+ if spec is None:
452
+ raise CharacterDeltaError(f"[[character_param]] {e.get('character')!r}: unknown field {k!r} "
453
+ f"(known: {', '.join(sorted(CHARACTER_PARAM_FIELDS))})")
454
+ ci, kind, vmax = spec
455
+ if ci >= len(cells):
456
+ raise CharacterDeltaError(f"[[character_param]] id {cid}: base row has no column index {ci}")
457
+ cells[ci] = _encode_param(v, kind, vmax, f"[[character_param]] {e.get('character')!r} {k}")
458
+ note = "# ff9mapkit [[character_param]] -- a partial CharacterParameters.csv delta (merged per-CharacterId)."
459
+ return "\n".join([note] + header + [";".join(by_id[c]) for c in sorted(changed)]) + "\n", warnings
460
+
461
+
462
+ # ---- [[command_set]] -> CommandSets.csv (PARTIAL per-preset; tab-padded -> strip + index slots by position) ----
463
+ COMMANDSET_SLOTS = {
464
+ "attack": 1, "defend": 2, "ability1": 3, "ability2": 4, "item": 5, "change": 6,
465
+ "attack_trance": 7, "defend_trance": 8, "ability1_trance": 9, "ability2_trance": 10,
466
+ "item_trance": 11, "change_trance": 12,
467
+ }
468
+ _MAX_COMMAND_ID = 47 # BattleCommandId slot value; >=48 = system/boundary
469
+
470
+
471
+ def build_command_set_delta(entries, *, game=None) -> tuple:
472
+ """Read CommandSets.csv + apply ``[[command_set]]`` -> (partial delta, warnings). PER-preset (0-19): re-point
473
+ a character's battle-menu command SLOTS to existing BattleCommandIds (e.g. give Vivi a different ability
474
+ command). The file is tab-padded + its legend collides Attack(Trance), so slots are written by FIXED INDEX
475
+ and every emitted cell is stripped clean."""
476
+ try:
477
+ header, cols, rows = _read_csv(_csv_path("CommandSets.csv", game))
478
+ except (FileNotFoundError, OSError, RuntimeError) as ex:
479
+ raise CharacterDeltaError(f"[[command_set]] needs your FF9 install to read CommandSets.csv ({ex})")
480
+ if not rows:
481
+ raise CharacterDeltaError("could not parse the base CommandSets.csv (no rows)")
482
+ if not isinstance(entries, list):
483
+ raise CharacterDeltaError("[[command_set]] must be a list of tables")
484
+ idx = cols.get("id", 0)
485
+ by_id, warnings, changed = {}, [], {}
486
+ for cells in rows:
487
+ try:
488
+ by_id[int(cells[idx].strip())] = [c.strip() for c in cells] # strip the tab-padding
489
+ except (ValueError, IndexError):
490
+ continue
491
+ for n, e in enumerate(entries):
492
+ if not isinstance(e, dict):
493
+ raise CharacterDeltaError(f"[[command_set]] #{n} must be a table (got {type(e).__name__})")
494
+ pid, pname = _resolve_preset(e.get("preset"), "[[command_set]]")
495
+ if pid not in by_id:
496
+ raise CharacterDeltaError(f"[[command_set]] preset {pname} (id {pid}) is not in the base CommandSets.csv")
497
+ if pid in changed:
498
+ warnings.append(f"[[command_set]] #{n} and #{changed[pid]} both target preset {pname} -- the later wins")
499
+ changed.setdefault(pid, n)
500
+ cells = by_id[pid]
501
+ for k, v in e.items():
502
+ if k == "preset":
503
+ continue
504
+ slot = COMMANDSET_SLOTS.get(k)
505
+ if slot is None:
506
+ raise CharacterDeltaError(f"[[command_set]] {pname}: unknown slot {k!r} "
507
+ f"(known: {', '.join(sorted(COMMANDSET_SLOTS))})")
508
+ if slot >= len(cells):
509
+ raise CharacterDeltaError(f"[[command_set]] {pname}: base row has no slot index {slot}")
510
+ cid = _to_int(v, f"[[command_set]] {pname} {k}")
511
+ if not 0 <= cid <= _MAX_COMMAND_ID:
512
+ raise CharacterDeltaError(f"[[command_set]] {pname} {k}={cid} out of range (0-{_MAX_COMMAND_ID})")
513
+ cells[slot] = str(cid)
514
+ note = "# ff9mapkit [[command_set]] -- a partial CommandSets.csv delta (merged per-preset over the base)."
515
+ return "\n".join([note] + header + [";".join(by_id[c]) for c in sorted(changed)]) + "\n", warnings
516
+
517
+
518
+ # ---- [[learn]] -> Abilities/<Preset>.csv (WHOLE-FILE per preset; the ability-progression curve) -------------
519
+ def _resolve_learn_token(token, *, game=None) -> str:
520
+ """An ability -> the canonical Abilities-CSV cell. Forms: ``0`` / ``AA:n`` / ``SA:n`` (passthrough + range);
521
+ an SA NAME -> ``SA:id`` (committed table); an active-ability NAME -> ``AA:id`` (live Actions.csv, needs install)."""
522
+ if token is None or isinstance(token, bool):
523
+ raise CharacterDeltaError("[[learn.ability]] needs an 'ability' (0, AA:n, SA:n, or a name)")
524
+ s = str(token).strip()
525
+ if s == "0":
526
+ return "0"
527
+ up = s.upper()
528
+ if up.startswith(("AA:", "SA:")):
529
+ n = _to_int(up[3:], f"[[learn]] {up[:2]}")
530
+ vmax = 191 if up.startswith("AA:") else _MAX_SA_ID
531
+ if not 0 <= n <= vmax:
532
+ raise CharacterDeltaError(f"[[learn]] {up[:3]}{n} out of range (0-{vmax})")
533
+ return f"{up[:3]}{n}"
534
+ said = _SA_BY_NORM.get(_norm_sa(s)) # an SA name (committed) -> SA:id
535
+ if said is not None:
536
+ return f"SA:{said}"
537
+ from . import actiondelta as _ad # else an active-ability name -> AA:id (live Actions.csv)
538
+ try:
539
+ _o, _l, cols, rows = _ad._read_raw(_ad._csv_path("Actions.csv", game))
540
+ aid = _ad._resolve_id(s, rows, _ad._name_index(rows, cols), kind="learn.ability", max_id=191)
541
+ except _ad.ActionDeltaError as ex:
542
+ raise CharacterDeltaError(f"[[learn.ability]] {ex}")
543
+ except (FileNotFoundError, OSError, RuntimeError) as ex:
544
+ raise CharacterDeltaError(f"[[learn.ability]] {s!r}: an active-ability name needs the install to resolve "
545
+ f"via Actions.csv ({ex})")
546
+ return f"AA:{aid}"
547
+
548
+
549
+ def _group_learns(learns):
550
+ """``[[learn]]`` blocks -> ``{preset_name: {abilities:[...], removes:[...]}}`` (blocks for the same preset MERGE)."""
551
+ grouped: dict = {}
552
+ for n, e in enumerate(learns if isinstance(learns, list) else [learns]):
553
+ if not isinstance(e, dict):
554
+ raise CharacterDeltaError(f"[[learn]] #{n} must be a table (got {type(e).__name__})")
555
+ _pid, pname = _resolve_preset(e.get("preset"), "[[learn]]")
556
+ g = grouped.setdefault(pname, {"abilities": [], "removes": []})
557
+ abil = e.get("ability", [])
558
+ g["abilities"] += abil if isinstance(abil, list) else [abil]
559
+ rem = e.get("remove", [])
560
+ g["removes"] += rem if isinstance(rem, list) else [rem]
561
+ return grouped
562
+
563
+
564
+ def build_learn_file(preset_name, abilities, removes, *, game=None) -> tuple:
565
+ """Read Abilities/<preset_name>.csv + apply the learn edits -> (WHOLE-FILE text, warnings). Override an
566
+ existing token's AP, append a new token, drop a removed token, re-emit ALL rows (the whole file replaces the
567
+ base, highest-priority-wins). Rows are ``<token>;<ap>;# <name>``."""
568
+ try:
569
+ header, _cols, rows = _read_csv(_csv_path(f"Abilities/{preset_name}.csv", game))
570
+ except (FileNotFoundError, OSError, RuntimeError) as ex:
571
+ raise CharacterDeltaError(f"[[learn]] preset {preset_name}: can't read Abilities/{preset_name}.csv -- "
572
+ f"presets 0-15 must exist; Stage* (16-19) have no base file ({ex})")
573
+ by_token: dict = {}
574
+ order: list = []
575
+ for cells in rows:
576
+ tok = cells[0].strip() if cells else ""
577
+ if tok and tok not in by_token:
578
+ by_token[tok] = [c.strip() for c in cells]
579
+ order.append(tok)
580
+ warnings: list = []
581
+ for r in removes or []: # drop removed tokens
582
+ tok = _resolve_learn_token(r, game=game)
583
+ if tok in by_token:
584
+ del by_token[tok]
585
+ order.remove(tok)
586
+ else:
587
+ warnings.append(f"[[learn]] {preset_name}: remove {r!r} ({tok}) is not in the base list -- ignored")
588
+ for ab in abilities or []: # override AP / append new
589
+ if not isinstance(ab, dict):
590
+ raise CharacterDeltaError(f"[[learn]] {preset_name}: each [[learn.ability]] must be a table")
591
+ tok = _resolve_learn_token(ab.get("ability"), game=game)
592
+ ap = _to_int(ab.get("ap", 0), f"[[learn]] {preset_name} {tok} ap")
593
+ if not 0 <= ap <= _I32: # CharacterAbility.Ap is Int32 (CsvParser.Int32), NOT UInt32
594
+ raise CharacterDeltaError(f"[[learn]] {preset_name} {tok}: ap {ap} out of range (0-{_I32})")
595
+ if tok in by_token:
596
+ cells = by_token[tok]
597
+ while len(cells) < 2:
598
+ cells.append("0")
599
+ cells[1] = str(ap)
600
+ else:
601
+ name = str(ab.get("name", "")).strip()
602
+ by_token[tok] = [tok, str(ap), f"# {name}" if name else f"# {tok}"]
603
+ order.append(tok)
604
+ note = f"# ff9mapkit [[learn]] -- the COMPLETE {preset_name} learn list (whole-file; highest-priority-wins)."
605
+ warnings.append(f"[[learn]] -> Abilities/{preset_name}.csv is WHOLE-FILE: it REPLACES the entire learn list, "
606
+ f"and a stacked higher-priority mod folder's {preset_name}.csv would SHADOW it")
607
+ return "\n".join([note] + header + [";".join(by_token[t]) for t in order]) + "\n", warnings
608
+
609
+
610
+ def validate_learn(entry) -> list:
611
+ """Offline structural problems for ``[[learn]]`` (empty => OK). Token FORMS (0/AA:/SA:/SA-name) check offline;
612
+ an active-ability NAME defers to build (it needs the install's Actions.csv)."""
613
+ if not isinstance(entry, dict):
614
+ return ["[[learn]] must be a table (preset = \"...\", [[learn.ability]] blocks)"]
615
+ problems: list = []
616
+ try:
617
+ _resolve_preset(entry.get("preset"), "[[learn]]")
618
+ except CharacterDeltaError as ex:
619
+ problems.append(str(ex))
620
+ abil = entry.get("ability", [])
621
+ abil = abil if isinstance(abil, list) else [abil]
622
+ if not abil and not entry.get("remove"):
623
+ problems.append("[[learn]] sets nothing (add a [[learn.ability]] block or remove = [...])")
624
+ for ab in abil:
625
+ if not isinstance(ab, dict) or ab.get("ability") is None:
626
+ problems.append("[[learn.ability]] needs an 'ability' (0, AA:n, SA:n, or a name)")
627
+ continue
628
+ s = str(ab.get("ability")).strip().upper()
629
+ if s == "0" or s.startswith(("AA:", "SA:")) or _SA_BY_NORM.get(_norm_sa(s)) is not None:
630
+ try:
631
+ _resolve_learn_token(ab.get("ability")) # offline-resolvable form -> check the range now
632
+ except CharacterDeltaError as ex:
633
+ problems.append(str(ex))
634
+ # else: an active-ability name -> resolution (+ presence) deferred to build (needs the install)
635
+ return problems
636
+
637
+
638
+ # ---- mod-write stage -------------------------------------------------------------------------------------
639
+ def write_character_data(layout, *, characters=None, levelings=None, ability_gems=None, character_params=None,
640
+ command_sets=None, learns=None, game=None) -> list:
641
+ """Emit BaseStats / Leveling / AbilityGems / CharacterParameters / CommandSets (per-id deltas) + the per-preset
642
+ Abilities/<Name>.csv learn lists into ``layout``. cp1252 + LF."""
643
+ warnings: list = []
644
+ for entries, path, builder in ((characters, layout.base_stats_csv, build_basestats_delta),
645
+ (levelings, layout.leveling_csv, build_leveling_file),
646
+ (ability_gems, layout.ability_gems_csv, build_ability_gems_delta),
647
+ (character_params, layout.character_parameters_csv, build_character_params_delta),
648
+ (command_sets, layout.command_sets_csv, build_command_set_delta)):
649
+ if entries:
650
+ text, w = builder(entries, game=game)
651
+ path.parent.mkdir(parents=True, exist_ok=True)
652
+ path.write_text(text, encoding="cp1252", errors="replace", newline="\n")
653
+ warnings += w
654
+ if learns: # the learn lists are a FILE SET (one whole file per preset)
655
+ for pname, g in _group_learns(learns).items():
656
+ text, w = build_learn_file(pname, g["abilities"], g["removes"], game=game)
657
+ p = layout.abilities_csv(pname)
658
+ p.parent.mkdir(parents=True, exist_ok=True)
659
+ p.write_text(text, encoding="cp1252", errors="replace", newline="\n")
660
+ warnings += w
661
+ return warnings
662
+
663
+
664
+ def validate_character_param(entry) -> list:
665
+ """Offline structural problems for ``[[character_param]]`` (empty => OK; field resolution at build)."""
666
+ if not isinstance(entry, dict):
667
+ return ["[[character_param]] must be a table (character = \"...\", a field = value)"]
668
+ problems = []
669
+ if entry.get("character") is None:
670
+ problems.append("[[character_param]] needs a 'character' (a name or a 0-11 id)")
671
+ overrides = [k for k in entry if k != "character"]
672
+ if not overrides:
673
+ problems.append("[[character_param]] sets no fields (e.g. row = 1, menu_type = \"Steiner\")")
674
+ for k in overrides:
675
+ if k not in CHARACTER_PARAM_FIELDS:
676
+ problems.append(f"[[character_param]]: unknown field {k!r} (known: {', '.join(sorted(CHARACTER_PARAM_FIELDS))})")
677
+ continue
678
+ ci, kind, vmax = CHARACTER_PARAM_FIELDS[k]
679
+ try:
680
+ _encode_param(entry[k], kind, vmax, f"[[character_param]] {k}")
681
+ except CharacterDeltaError as ex:
682
+ problems.append(str(ex))
683
+ return problems
684
+
685
+
686
+ def validate_command_set(entry) -> list:
687
+ """Offline structural problems for ``[[command_set]]`` (empty => OK)."""
688
+ if not isinstance(entry, dict):
689
+ return ["[[command_set]] must be a table (preset = \"...\", a slot = command id)"]
690
+ problems = []
691
+ try:
692
+ _resolve_preset(entry.get("preset"), "[[command_set]]")
693
+ except CharacterDeltaError as ex:
694
+ problems.append(str(ex))
695
+ overrides = [k for k in entry if k != "preset"]
696
+ if not overrides:
697
+ problems.append("[[command_set]] sets no slots (e.g. ability1 = 8)")
698
+ for k in overrides:
699
+ if k not in COMMANDSET_SLOTS:
700
+ problems.append(f"[[command_set]]: unknown slot {k!r} (known: {', '.join(sorted(COMMANDSET_SLOTS))})")
701
+ continue
702
+ try:
703
+ cid = _to_int(entry[k], f"[[command_set]] {k}")
704
+ if not 0 <= cid <= _MAX_COMMAND_ID:
705
+ problems.append(f"[[command_set]] {k}={cid} out of range (0-{_MAX_COMMAND_ID})")
706
+ except CharacterDeltaError as ex:
707
+ problems.append(str(ex))
708
+ return problems
709
+
710
+
711
+ # ---- offline (no-install) structural + range validation --------------------------------------------------
712
+ def validate_character(entry) -> list:
713
+ problems: list = []
714
+ if not isinstance(entry, dict):
715
+ return ["[[character]] must be a table (character = \"...\", a stat = value)"]
716
+ ch = entry.get("character")
717
+ if ch is None or isinstance(ch, bool):
718
+ problems.append("[[character]] needs a 'character' (a name or a 0-11 id)")
719
+ elif not isinstance(ch, (int, str)):
720
+ problems.append(f"[[character]] character must be a name or a 0-11 id (got {type(ch).__name__})")
721
+ elif isinstance(ch, str) and not ch.strip().lstrip("-").isdigit() and ch.strip().lower() not in CHARACTER_IDS:
722
+ problems.append(f"[[character]] unknown character {ch!r}")
723
+ overrides = [k for k in entry if k != "character"]
724
+ if not overrides:
725
+ problems.append(f"[[character]] {entry.get('character')!r} sets no stats (give e.g. strength = 30)")
726
+ for k in overrides:
727
+ spec = CHARACTER_FIELDS.get(k)
728
+ if spec is None:
729
+ problems.append(f"[[character]] {entry.get('character')!r}: unknown field {k!r}")
730
+ continue
731
+ try:
732
+ _range(_to_int(entry[k], k), spec[1], f"[[character]] {entry.get('character')!r} {k}")
733
+ except CharacterDeltaError as ex:
734
+ problems.append(str(ex))
735
+ return problems
736
+
737
+
738
+ def validate_leveling(entry) -> list:
739
+ problems: list = []
740
+ if not isinstance(entry, dict):
741
+ return ["[[leveling]] must be a table (level = N, a field = value)"]
742
+ lvl = entry.get("level")
743
+ if lvl is None or isinstance(lvl, bool) or not isinstance(lvl, (int, str)):
744
+ problems.append("[[leveling]] needs a 'level' (1-99)")
745
+ else:
746
+ try:
747
+ lv = int(lvl)
748
+ if not 1 <= lv <= _LEVEL_COUNT:
749
+ problems.append(f"[[leveling]] level {lv} out of range (1-{_LEVEL_COUNT})")
750
+ except (ValueError, TypeError):
751
+ problems.append(f"[[leveling]] level must be an integer 1-{_LEVEL_COUNT} (got {lvl!r})")
752
+ overrides = [k for k in entry if k != "level"]
753
+ if not overrides:
754
+ problems.append("[[leveling]] sets no fields (give exp / bonus_hp / bonus_mp)")
755
+ for k in overrides:
756
+ spec = LEVELING_FIELDS.get(k)
757
+ if spec is None:
758
+ problems.append(f"[[leveling]] level {entry.get('level')}: unknown field {k!r} (known: exp, bonus_hp, bonus_mp)")
759
+ continue
760
+ try:
761
+ _range(_to_int(entry[k], k), spec[1], f"[[leveling]] {k}")
762
+ except CharacterDeltaError as ex:
763
+ problems.append(str(ex))
764
+ return problems
765
+
766
+
767
+ def validate_ability_gem(entry) -> list:
768
+ problems: list = []
769
+ if not isinstance(entry, dict):
770
+ return ["[[ability_gem]] must be a table (ability = \"...\", gems = N)"]
771
+ ab = entry.get("ability")
772
+ if ab is None or isinstance(ab, bool):
773
+ problems.append("[[ability_gem]] needs an 'ability' (a SupportAbility name or a 0-63 id)")
774
+ elif not isinstance(ab, (int, str)):
775
+ problems.append(f"[[ability_gem]] ability must be a name or a 0-{_MAX_SA_ID} id (got {type(ab).__name__})")
776
+ elif isinstance(ab, str) and not ab.strip().lstrip("-").isdigit() and _norm_sa(ab) not in _SA_BY_NORM:
777
+ problems.append(f"[[ability_gem]] unknown ability {ab!r}")
778
+ overrides = [k for k in entry if k != "ability"]
779
+ if not overrides:
780
+ problems.append(f"[[ability_gem]] {entry.get('ability')!r} sets no fields (give gems = N)")
781
+ for k in overrides:
782
+ if k != "gems":
783
+ problems.append(f"[[ability_gem]] {entry.get('ability')!r}: unknown field {k!r} (known: gems)")
784
+ continue
785
+ try:
786
+ _range(_to_int(entry[k], k), _I32, f"[[ability_gem]] {entry.get('ability')!r} gems")
787
+ except CharacterDeltaError as ex:
788
+ problems.append(str(ex))
789
+ return problems