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,21 @@
1
+ """Custom FF9 battle-background (BBG) authoring — the battle analogue of the field pillar.
2
+
3
+ A battle background is a real textured 3D Unity model whose child meshes are named Group_0/2/4/8.
4
+ Memoria's ModelImporter loads a loose ``.fbx`` from the mod folder INSTEAD of the bundle, so a custom
5
+ battle map ships as an ASCII FBX (+ ``image#.png`` textures) at ``ModLayout.battlemap_dir(bbg)`` — no
6
+ engine rebuild. Proven in-game 2026-06-09 (texture reskin, a synthetic quad, and a faithful BBG_B013
7
+ geometry round-trip).
8
+
9
+ Loop (mirrors fields' import -> build -> deploy):
10
+ ff9mapkit battle-import BBG_B013 --out my_map # fork a real map -> battle.toml + FBX + textures
11
+ # edit my_map/BBG_B013.fbx in Blender (keep meshes named Group_0/2/4/8) / repaint the PNGs
12
+ ff9mapkit battle-build my_map/battle.toml --out dist
13
+ py tools/deploy_battle.py my_map/battle.toml # reversible install into the per-worktree mod folder
14
+
15
+ Modules: ``fbx`` (pure ASCII-FBX emitter + geometry model), ``extract`` (fork a real BBG via UnityPy),
16
+ ``build`` (BattleProject + build_battle_mod). Provenance: extraction reads the user's install at
17
+ runtime; nothing extracted is committed.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from . import fbx # noqa: F401 (pure, no I/O — safe to import always)
@@ -0,0 +1,294 @@
1
+ """Emit a drop-in Memoria ``AbilityFeatures.txt`` from ``[[ability_feature]]`` -- the no-DLL DSL for what
2
+ support/active abilities DO: Auto-Haste, killers (Man Eater), MP+20%, Counter, gil-gated casts, command
3
+ disables, etc. (Memoria wiki: Supporting-/Active-ability-features.)
4
+
5
+ The kit emits a PARTIAL file (only the author's ``>SA``/``>AA``/``>CMD`` blocks); the engine reads every mod
6
+ folder low->high and accumulates per-ability over the base, so the base supplies the 64 SAs / 192 AAs we don't
7
+ touch. We pass the ``[code=...]`` / feature-line body through OPAQUE -- the engine validates the NCalc formula at
8
+ load; the kit only STRUCTURE-checks (id range per kind, balanced ``[code]``/``[/code]``, no nested header, known
9
+ ``[code=TAG]`` for the closed AA/CMD sets, the cumulate/replace merge flag).
10
+
11
+ Header grammar (the engine regex, ``ff9abil.cs:515``): ``^(>SA|>AA|>CMD)\\s+(\\d+|GlobalEnemyLast|GlobalEnemy|
12
+ GlobalLast|Global)(\\+?)`` -- a kind, a numeric id OR a special word, an optional ``+`` (cumulate). ``+`` ADDS
13
+ the block on top of the base + lower folders; NO ``+`` CLEARS the lower-priority features for that id first (full
14
+ override). Default here is ``cumulate=true`` (the safe partial). Provenance: emits only the author's own blocks;
15
+ the SA name<->id table is committed (open-source ``SupportAbility`` enum, reused from :mod:`characterdelta`); AA
16
+ names resolve LIVE against the install's ``Actions.csv`` (ships nothing).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import re
21
+
22
+ from . import characterdelta as _cd
23
+
24
+ _KINDS = ("SA", "AA", "CMD")
25
+ _SA_MAX, _AA_MAX = 63, 191
26
+
27
+ # the special-id words -> canonical casing + which kinds they actually ACT for (the engine silently no-ops the
28
+ # rest: for >AA/>CMD only "Global" reaches a branch, the other three fall through both -> a dead block).
29
+ _SPECIALS = {"global": "Global", "globallast": "GlobalLast",
30
+ "globalenemy": "GlobalEnemy", "globalenemylast": "GlobalEnemyLast"}
31
+ _SPECIAL_OK = {"SA": {"Global", "GlobalLast", "GlobalEnemy", "GlobalEnemyLast"},
32
+ "AA": {"Global"}, "CMD": {"Global"}}
33
+
34
+ # >SA feature-type verbs (a body line's first token) -- structural recognition only (the formula args are opaque).
35
+ _SA_FEATURE_KW = ("Permanent", "BattleStart", "BattleResult", "StatusInit", "Ability", "Command",
36
+ "EnemyFeature", "MorphFeature")
37
+ # the CLOSED [code=TAG] sets for >AA / >CMD; a tag outside them is a silent no-op in the engine -> warn (not a
38
+ # hard error -- the set could be incomplete, and the formula text is the author's to own).
39
+ _AA_TAGS = frozenset({"Condition", "Patch", "Priority", "Power", "HitRate", "Element", "Status",
40
+ "Target", "SpecialEffect", "GilCost", "MPCost", "ItemRequirement", "Disable"})
41
+ _CMD_TAGS = frozenset({"Condition", "Patch", "Disable", "HardDisable"})
42
+
43
+ _HEADER_RE = re.compile(r"^\s*>(SA|AA|CMD)\b", re.I)
44
+ _CODE_OPEN = re.compile(r"\[code=([^\]]*)\]", re.I)
45
+ _CODE_CLOSE = re.compile(r"\[/code\]", re.I)
46
+ _FILE_HEADER = "# ff9mapkit [[ability_feature]] -- a partial AbilityFeatures.txt (merged per-ability over the base)."
47
+
48
+
49
+ class AbilityFeatureError(ValueError):
50
+ pass
51
+
52
+
53
+ def _as_list(features):
54
+ if features is None:
55
+ return []
56
+ if isinstance(features, dict):
57
+ return [features]
58
+ if isinstance(features, list):
59
+ return features
60
+ raise AbilityFeatureError("[[ability_feature]] must be a table or a list of tables")
61
+
62
+
63
+ def _norm(s) -> str:
64
+ return re.sub(r"[^0-9a-z]", "", str(s).lower())
65
+
66
+
67
+ def _resolve_kind(blk, ctx) -> str:
68
+ k = str(blk.get("kind", "SA")).strip().upper()
69
+ if k not in _KINDS:
70
+ raise AbilityFeatureError(f"{ctx}: kind {blk.get('kind')!r} must be one of SA / AA / CMD")
71
+ return k
72
+
73
+
74
+ def _resolve_cumulate(blk, ctx) -> bool:
75
+ """``cumulate`` (default True) -> the trailing ``+``. ``replace`` is the inverse alias (replace=True ==
76
+ cumulate=False = full override). Both given must agree (cumulate != replace)."""
77
+ cum, rep = blk.get("cumulate"), blk.get("replace")
78
+ if cum is not None and rep is not None:
79
+ if bool(cum) == bool(rep):
80
+ raise AbilityFeatureError(f"{ctx}: cumulate and replace are inverses -- set one, or make them differ")
81
+ return bool(cum)
82
+ if rep is not None:
83
+ return not bool(rep)
84
+ if cum is not None:
85
+ return bool(cum)
86
+ return True # safe partial: stack on top of the base
87
+
88
+
89
+ def _resolve_ability(blk, kind, *, game, strict, ctx):
90
+ """``ability`` -> (header_id_str, display_name). For an AA NAME with strict=False and no install, returns
91
+ (None, name) -- deferred to build (the offline lint can't read Actions.csv)."""
92
+ tok = blk.get("ability")
93
+ if tok is None or isinstance(tok, bool):
94
+ raise AbilityFeatureError(f"{ctx}: needs an 'ability' (a name, an id, or Global/GlobalLast/...)")
95
+ if isinstance(tok, str) and _norm(tok) in _SPECIALS: # a special-id word
96
+ word = _SPECIALS[_norm(tok)]
97
+ if word not in _SPECIAL_OK[kind]:
98
+ raise AbilityFeatureError(f"{ctx}: '{word}' has no effect for >{kind} (only "
99
+ f"{'/'.join(sorted(_SPECIAL_OK[kind]))} act; the engine ignores the rest)")
100
+ return word, word
101
+ _is_int = isinstance(tok, int) or (isinstance(tok, str) and tok.strip().lstrip("-").isdigit())
102
+ if kind == "SA":
103
+ if _is_int:
104
+ aid = int(tok)
105
+ if not 0 <= aid <= _SA_MAX:
106
+ raise AbilityFeatureError(f"{ctx}: SA id {aid} out of range (0-{_SA_MAX})")
107
+ return str(aid), _cd._SA_NAMES[aid]
108
+ aid = _cd._SA_BY_NORM.get(_cd._norm_sa(tok))
109
+ if aid is None:
110
+ raise AbilityFeatureError(f"{ctx}: unknown SupportAbility {tok!r} (a name like 'Auto-Haste', "
111
+ f"or a 0-{_SA_MAX} id)")
112
+ return str(aid), _cd._SA_NAMES[aid]
113
+ if kind == "CMD":
114
+ if _is_int:
115
+ cid = int(tok)
116
+ if cid < 1:
117
+ raise AbilityFeatureError(f"{ctx}: CMD id {cid} invalid (id 0 no-ops; use a command id >= 1)")
118
+ return str(cid), str(cid)
119
+ raise AbilityFeatureError(f"{ctx}: CMD is id-only (give an int command id, not a name {tok!r})")
120
+ # kind == "AA"
121
+ if _is_int:
122
+ aid = int(tok)
123
+ if not 0 <= aid <= _AA_MAX:
124
+ raise AbilityFeatureError(f"{ctx}: AA id {aid} out of range (0-{_AA_MAX})")
125
+ return str(aid), str(aid)
126
+ if not strict and game is None:
127
+ return None, str(tok) # AA-by-name defers to build (needs Actions.csv)
128
+ from . import actiondelta as _ad
129
+ try:
130
+ _opt, _leg, cols, rows = _ad._read_raw(_ad._csv_path("Actions.csv", game))
131
+ aid = _ad._resolve_id(tok, rows, _ad._name_index(rows, cols), kind="ability_feature", max_id=_AA_MAX)
132
+ except _ad.ActionDeltaError as ex:
133
+ raise AbilityFeatureError(f"{ctx}: {ex}")
134
+ except (FileNotFoundError, OSError, RuntimeError) as ex:
135
+ raise AbilityFeatureError(f"{ctx}: AA-by-name needs your FF9 install to read Actions.csv ({ex})")
136
+ return str(aid), str(tok)
137
+
138
+
139
+ def _features_text(blk, ctx) -> str:
140
+ present = [k for k in ("features", "code", "body") if blk.get(k) is not None]
141
+ if not present:
142
+ raise AbilityFeatureError(f"{ctx}: needs a 'features' block (the [code=...] / feature-line body)")
143
+ if len(present) > 1:
144
+ raise AbilityFeatureError(f"{ctx}: set only one of features/code/body (got {', '.join(present)})")
145
+ v = blk[present[0]]
146
+ if not isinstance(v, str):
147
+ raise AbilityFeatureError(f"{ctx}: '{present[0]}' must be a string")
148
+ return v
149
+
150
+
151
+ def _check_body(body, kind, ctx, warnings) -> None:
152
+ if not body.strip():
153
+ raise AbilityFeatureError(f"{ctx}: empty 'features' body (a header with no feature patches nothing)")
154
+ lines = body.splitlines()
155
+ for ln in lines: # (d) a body line that is itself a header would split blocks
156
+ if _HEADER_RE.match(ln):
157
+ raise AbilityFeatureError(f"{ctx}: a body line is itself a >SA/>AA/>CMD header ({ln.strip()!r}) -- "
158
+ f"use ONE [[ability_feature]] per header")
159
+ toks = sorted([(m.start(), 1) for m in _CODE_OPEN.finditer(body)] # (e) [code]/[/code] balance + no nesting
160
+ + [(m.start(), -1) for m in _CODE_CLOSE.finditer(body)])
161
+ depth = 0
162
+ for _pos, d in toks:
163
+ depth += d
164
+ if depth < 0 or depth > 1:
165
+ raise AbilityFeatureError(f"{ctx}: unbalanced or nested [code=...]/[/code] tags")
166
+ if depth != 0:
167
+ raise AbilityFeatureError(f"{ctx}: unbalanced [code=...]/[/code] tags (a [code=...] without [/code])")
168
+ for m in re.finditer(r"\[code=.*?\[/code\]", body, re.DOTALL): # the engine [code] regex is NOT multiline ->
169
+ if "\n" in m.group(0): # a [code=...] spanning lines is silently ignored
170
+ warnings.append(f"{ctx}: a [code=...]...[/code] spans multiple lines -- the engine's [code] regex is "
171
+ f"NOT multiline and will IGNORE it; keep each [code=...] block on one line")
172
+ break
173
+ if kind in ("AA", "CMD"): # (g) closed tag set -> warn on an unknown / cross-kind tag
174
+ allowed = _AA_TAGS if kind == "AA" else _CMD_TAGS
175
+ other_kind, other_set = ("CMD", _CMD_TAGS) if kind == "AA" else ("AA", _AA_TAGS)
176
+ for m in _CODE_OPEN.finditer(body):
177
+ tag = m.group(1).strip()
178
+ if tag and tag not in allowed:
179
+ hint = f" (it's a >{other_kind} tag)" if tag in other_set else ""
180
+ warnings.append(f"{ctx}: [code={tag}] is not a known >{kind} feature tag{hint} -- the engine "
181
+ f"silently ignores an unknown tag")
182
+ elif kind == "SA": # (f) first body line should be a feature verb (lenient)
183
+ first = next((ln.strip() for ln in lines if ln.strip()), "")
184
+ first_tok = first.split(None, 1)[0] if first else "" # exact token (no prefix false-accept like "Abilityx")
185
+ if first_tok not in _SA_FEATURE_KW:
186
+ warnings.append(f"{ctx}: a >SA body should start with a feature type "
187
+ f"({' / '.join(_SA_FEATURE_KW)}); got {first_tok!r}")
188
+
189
+
190
+ def _resolve_comment(blk, default_name, ctx) -> str:
191
+ c = blk.get("comment")
192
+ if c is None:
193
+ return str(default_name)
194
+ return re.sub(r"\s+", " ", str(c)).strip() # the engine ignores the tail, but keep it one line
195
+
196
+
197
+ def _emit_block(blk, n, *, game, strict, warnings, seen) -> list:
198
+ ctx = f"[[ability_feature]] #{n}"
199
+ if not isinstance(blk, dict):
200
+ raise AbilityFeatureError(f"{ctx} must be a table")
201
+ kind = _resolve_kind(blk, ctx)
202
+ cumulate = _resolve_cumulate(blk, ctx)
203
+ id_str, display = _resolve_ability(blk, kind, game=game, strict=strict, ctx=ctx)
204
+ if kind == "AA" and id_str == "0":
205
+ warnings.append(f"{ctx}: >AA id 0 is Void (a no-op active ability) -- this block won't apply")
206
+ has_body = any(blk.get(k) is not None for k in ("features", "code", "body"))
207
+ body = _features_text(blk, ctx) if has_body else ""
208
+ if body.strip():
209
+ _check_body(body, kind, ctx, warnings)
210
+ elif cumulate: # a `+` header with no features patches nothing
211
+ raise AbilityFeatureError(f"{ctx}: empty 'features' -- a cumulate (+) header with no body patches "
212
+ f"nothing; add a feature body, or set replace=true to CLEAR the ability's "
213
+ f"base features")
214
+ # else: empty body + replace (no `+`) = a legitimate "clear all lower-priority features" override (header only)
215
+ if id_str is not None: # a 2nd block for the same id with replace WIPES the first
216
+ key = (kind, id_str)
217
+ if key in seen and not cumulate:
218
+ warnings.append(f"{ctx}: a 2nd >{kind} {id_str} with replace/no-cumulate WIPES the earlier block")
219
+ seen[key] = n
220
+ comment = _resolve_comment(blk, display, ctx)
221
+ header = f">{kind} {id_str if id_str is not None else display}" + ("+" if cumulate else "")
222
+ if comment:
223
+ header += " " + comment
224
+ # STRIP each line (not just rstrip): a >SA feature verb must sit at column 0 -- the engine matcher `^verb\b`
225
+ # is Multiline but does NOT consume leading whitespace, so an indented verb is silently dropped. Indentation
226
+ # is never meaningful in this DSL (the [code=...] regex is position-free), so normalizing is safe.
227
+ body_lines = [ln.strip() for ln in body.splitlines()]
228
+ while body_lines and not body_lines[0]:
229
+ body_lines.pop(0)
230
+ while body_lines and not body_lines[-1]:
231
+ body_lines.pop()
232
+ return [header, *body_lines, ""]
233
+
234
+
235
+ def build_lines(features, *, game=None, strict=True):
236
+ """``[[ability_feature]]`` blocks -> (lines, warnings). Offline for SA/CMD-id + AA-id; an AA NAME needs the
237
+ install (``game``) -- with ``strict=False`` + no install it structure-checks and defers id resolution."""
238
+ blocks = _as_list(features)
239
+ if not blocks:
240
+ return [], []
241
+ warnings, seen, out = [], {}, [_FILE_HEADER, ""]
242
+ for n, blk in enumerate(blocks):
243
+ out += _emit_block(blk, n, game=game, strict=strict, warnings=warnings, seen=seen)
244
+ return out, warnings
245
+
246
+
247
+ def validate_blocks(features, *, game=None) -> list:
248
+ """Offline structural + range problems (empty => OK). Re-runs emission on a copy; AA-by-name id resolution
249
+ defers to build (install-gated), like ``[[battle_attack]]``'s by-name path."""
250
+ try:
251
+ build_lines(features, game=game, strict=False)
252
+ except AbilityFeatureError as ex:
253
+ return [str(ex)]
254
+ return []
255
+
256
+
257
+ def write_ability_features(layout, features, *, game=None) -> list:
258
+ """Build + write ``layout.ability_features_txt`` (cp1252 / LF, byte-faithful with the base). Returns
259
+ warnings; writes nothing when there are no blocks."""
260
+ lines, warnings = build_lines(features, game=game, strict=True)
261
+ if not lines:
262
+ return warnings
263
+ path = layout.ability_features_txt
264
+ path.parent.mkdir(parents=True, exist_ok=True)
265
+ path.write_text("\n".join(lines) + "\n", encoding="cp1252", errors="replace", newline="\n")
266
+ return warnings
267
+
268
+
269
+ # ---- non-clobbering merge into a live AbilityFeatures.txt (deferred -- the MVP deploy whole-file-copies) ----
270
+ def _markers(marker_id):
271
+ return (f"## >>> ff9mapkit ability_feature {marker_id} (auto -- edit the toml, not here)",
272
+ f"## <<< ff9mapkit ability_feature {marker_id}")
273
+
274
+
275
+ def merge_ability_features(live_text: str, block_lines, marker_id) -> str:
276
+ """Splice ``block_lines`` between ``##`` sentinel markers, replacing a prior same-id block + preserving the
277
+ rest. ``##`` lines don't start with ``>SA/>AA/>CMD`` so the engine skips them. Idempotent; an empty
278
+ ``block_lines`` just strips our prior block."""
279
+ begin, end = _markers(marker_id)
280
+ kept, skip = [], False
281
+ for ln in live_text.splitlines():
282
+ if ln.strip() == begin:
283
+ skip = True
284
+ continue
285
+ if ln.strip() == end:
286
+ skip = False
287
+ continue
288
+ if not skip:
289
+ kept.append(ln)
290
+ while kept and not kept[-1].strip():
291
+ kept.pop()
292
+ if block_lines:
293
+ kept += ["", begin, *block_lines, end]
294
+ return "\n".join(kept) + "\n"