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,395 @@
1
+ """``[[battle_patch]]`` / ``[[battle_enemy]]`` / ``[[battle_attack]]`` -- author ``BattlePatch.txt`` by NAME or
2
+ index: the reflection channel for the enemy/attack/scene combat data that CSV can't reach and raw16 can only
3
+ reach by FORKING the scene. This is the Phase-4 emitter (see ``docs/BATTLE_DESIGN.md`` §2a/§8).
4
+
5
+ WHY a BattlePatch channel (vs the raw16 ``[scene]`` tuner):
6
+ * ``[scene]`` byte-patches a FORKED scene's ``dbfile0000.raw16`` -> it only works on a scene you ship a
7
+ modified raw16 for, and it can't reach fields that aren't in the raw16 disk layout at all.
8
+ * ``BattlePatch.txt`` patches ANY scene IN PLACE by reflection AFTER ``ReadBattleScene`` (no fork, no
9
+ repack), and it reaches the ``[Memoria.PatchableField]``-flagged fields -- INCLUDING ones with no raw16
10
+ slot: the drop/steal RATE arrays, ``BonusElement``, ``MaxDamageLimit``/``MaxMpDamageLimit``,
11
+ ``WinCardRate`` (``SB2_MON_PARM.cs:53-179``) -- and the per-enemy ATTACK table (``AA_DATA``/``BTL_REF``,
12
+ ``BTL_SCENE.cs:127-153``), which the kit could not touch at all before.
13
+ * The by-NAME selectors (``AnyEnemyByName:`` / ``AnyAttackByName:``) patch EVERY enemy/attack of that name
14
+ across ALL scenes -- the campaign-wide WIN over Hades Workshop ("buff every Goblin across the chain").
15
+
16
+ THE ENGINE FORMAT (``Memoria.DataPatchers.PatchBattles`` / ``ApplyBattlePatch``, ``DataPatchers.cs:538-682``):
17
+ ``BattlePatch.txt`` is a STATEFUL line list. A *selector* line opens a patch context; subsequent *field*
18
+ lines (``FieldName value``) set the named ``[PatchableField]`` on the struct for that context's token type:
19
+ * ``Battle: <id|name>`` -> a SCENE patch (sets ``BTL_SCENE_INFO`` scene flags). Narrow it with:
20
+ ``Pattern: <i>`` -> a PATTERN patch (``SB2_PATTERN``: Rate/Camera/AP)
21
+ ``Enemy: <i>`` / ``EnemyByName: <n>`` -> an ENEMY patch (``SB2_MON_PARM`` + ``SB2_ELEMENT``)
22
+ ``Attack: <i>`` / ``AttackByName: <n>`` -> an ATTACK patch (``AA_DATA`` + ``BTL_REF`` + cmd info)
23
+ * ``AnyEnemyByName: <name>`` -> a global ENEMY patch (every scene)
24
+ * ``AnyAttackByName: <name>`` -> a global ATTACK patch (every scene)
25
+ Each narrower REUSES the current patch's scene-applicability, so within a ``Battle:`` block the order is:
26
+ scene flags first (they bind to the ``Battle:`` Scene patch), THEN the ``Pattern:``/``Enemy:``/``Attack:``
27
+ sub-blocks -- which is exactly how this module emits.
28
+
29
+ VALUE ENCODING (``ExtensionMethodsString.TryTypeParse`` / ``TryArrayParse``, ``DataPatchers.cs:572-581``):
30
+ a field's value string is parsed by its C# type -- ``String`` verbatim, an ``enum`` via ``Enum.Parse`` (which
31
+ accepts EITHER flag names OR a plain integer), numerics/Boolean via ``TryParse``, an array space-separated.
32
+ Because ``Enum.Parse`` takes integers, we emit INTEGER masks for every enum/flags/element/status/item field
33
+ (via the committed :mod:`battlecsv` name<->bit tables + :func:`ff9mapkit.items.resolve`) -- so NO new enum-name
34
+ table is committed (provenance: only your authored overrides live in the toml; the emitted ``BattlePatch.txt``
35
+ is mod build-output, never committed). Booleans emit ``True``/``False``. Narrow engine column types are
36
+ RANGE-CHECKED offline (a value past a Byte/UInt16/UInt32 cap would otherwise be silently dropped by the
37
+ engine's ``TryParse``).
38
+ """
39
+ from __future__ import annotations
40
+
41
+ from .. import items
42
+ from . import battlecsv
43
+
44
+ _U16 = 0xFFFF
45
+ _U32 = 0xFFFFFFFF
46
+ _I32 = 2 ** 31 - 1
47
+ _U64 = 2 ** 64 - 1
48
+ _STATUS_SET_MAX = 38 # the highest StatusSetId the base engine defines (StatusSetId.cs: None=0 .. =38)
49
+
50
+ # A field spec = (EngineFieldName, encoder, max). `max` is None for the multi-value encoders (items/rates),
51
+ # which range-check each element themselves. Encoders: int / bool / elements / status / script / items / rates.
52
+ # The EngineFieldName is the EXACT C# field name DataPatchers matches by reflection (case-sensitive).
53
+
54
+ # ---- ENEMY token: SB2_MON_PARM + SB2_ELEMENT [PatchableField]s (SB2_MON_PARM.cs:33-179, SB2_ELEMENT.cs) ----
55
+ ENEMY_FIELDS = {
56
+ "max_hp": ("MaxHP", "int", _U32), "max_mp": ("MaxMP", "int", _U32),
57
+ "gil": ("WinGil", "int", _U32), "exp": ("WinExp", "int", _U32),
58
+ "level": ("Level", "int", 0xFF), "category": ("Category", "int", 0xFF),
59
+ "hit_rate": ("HitRate", "int", 0xFF),
60
+ "phys_def": ("PhysicalDefence", "int", _I32), "phys_evade": ("PhysicalEvade", "int", _I32),
61
+ "mag_def": ("MagicalDefence", "int", _I32), "mag_evade": ("MagicalEvade", "int", _I32),
62
+ "blue_magic": ("BlueMagic", "int", _I32),
63
+ # SB2_ELEMENT (the 4 core battle stats; reflection routes these via scene.MonAddr[i].Element)
64
+ "speed": ("Speed", "int", 0xFF), "strength": ("Strength", "int", 0xFF),
65
+ "magic": ("Magic", "int", 0xFF), "spirit": ("Spirit", "int", 0xFF),
66
+ # element-affinity bitmasks (Byte each; element NAMES -> bitmask). `null`/`guard` = nullified/immune.
67
+ "null": ("GuardElement", "elements", 0xFF), "guard": ("GuardElement", "elements", 0xFF),
68
+ "absorb": ("AbsorbElement", "elements", 0xFF), "half": ("HalfElement", "elements", 0xFF),
69
+ "weak": ("WeakElement", "elements", 0xFF),
70
+ "bonus_element": ("BonusElement", "elements", 0xFF), # BP-only: the element the enemy's OWN attacks carry
71
+ # status masks (BattleStatus, a 64-bit [Flags] enum; status NAMES -> bitmask)
72
+ "resist_status": ("ResistStatus", "status", _U64), "auto_status": ("AutoStatus", "status", _U64),
73
+ "initial_status": ("InitialStatus", "status", _U64),
74
+ # rewards: 4-item drop/steal lists (names/ids; "none"->255) + their odds arrays + the Tetra card
75
+ "drop": ("WinItems", "items", None), "drop_rates": ("WinItemRates", "rates", None),
76
+ "steal": ("StealItems", "items", None), "steal_rates": ("StealItemRates", "rates", None),
77
+ "win_card": ("WinCard", "int", 0xFF), "win_card_rate": ("WinCardRate", "int", _U16),
78
+ "max_damage_limit": ("MaxDamageLimit", "int", _U32), # BP-only: per-enemy >9999 break
79
+ "max_mp_damage_limit": ("MaxMpDamageLimit", "int", _U32),
80
+ }
81
+
82
+ # ---- ATTACK token: BTL_REF + AA_DATA [PatchableField]s (BTL_REF.cs, AA_DATA.cs:30-39) ----
83
+ ATTACK_FIELDS = {
84
+ "power": ("Power", "int", _I32), # BTL_REF (single BYTE on disk, but Int32 in mem)
85
+ "element": ("Elements", "elements", 0xFF), "elements": ("Elements", "elements", 0xFF),
86
+ "rate": ("Rate", "int", _I32),
87
+ "script": ("ScriptId", "script", _I32), "script_id": ("ScriptId", "script", _I32),
88
+ "mp": ("MP", "int", _I32), # AA_DATA
89
+ "category": ("Category", "int", 0xFF), "type": ("Type", "int", 0xFF),
90
+ # AddStatusNo is a StatusSetId enum (a StatusSets.csv ROW id, NOT a status bitmask). The engine parses it
91
+ # via Enum.Parse, which casts ANY integer through WITHOUT bounds-checking, then indexes it with a RAW
92
+ # Dictionary get (FF9Battle.add_status[...] / StatusSets[...]) built only from the 0..38 base rows -> an
93
+ # undefined id (39+) is a KeyNotFoundException CRASH at command-build, not a no-op. So cap at the engine max.
94
+ "status_set": ("AddStatusNo", "int", _STATUS_SET_MAX),
95
+ }
96
+
97
+ # ---- PATTERN token: SB2_PATTERN [PatchableField]s (SB2_PATTERN.cs:12-24; MonsterCount/Monster are NOT) ----
98
+ PATTERN_FIELDS = {
99
+ "rate": ("Rate", "int", 0xFF), "camera": ("Camera", "int", 0xFF), "ap": ("AP", "int", _U32),
100
+ }
101
+
102
+ # ---- SCENE token: BTL_SCENE_INFO [PatchableField] Booleans (BTL_SCENE_INFO.cs:7-47; SB2_HEAD has none) ----
103
+ SCENE_FLAGS = {
104
+ "special_start": ("SpecialStart", "bool", None), "preemptive": ("Preemptive", "bool", None),
105
+ "back_attack": ("BackAttack", "bool", None), "no_game_over": ("NoGameOver", "bool", None),
106
+ "no_exp": ("NoExp", "bool", None), "win_pose": ("WinPose", "bool", None),
107
+ "runaway": ("Runaway", "bool", None), "can_escape": ("Runaway", "bool", None),
108
+ "no_neighboring": ("NoNeighboring", "bool", None), "no_magical": ("NoMagical", "bool", None),
109
+ "reverse_attack": ("ReverseAttack", "bool", None),
110
+ "fixed_camera1": ("FixedCamera1", "bool", None), "fixed_camera2": ("FixedCamera2", "bool", None),
111
+ "after_event": ("AfterEvent", "bool", None), "field_bgm": ("FieldBGM", "bool", None),
112
+ }
113
+
114
+ # keys that select/structure a block rather than set a field
115
+ _ENEMY_SEL = {"index", "name"}
116
+ _ATTACK_SEL = {"index", "name"}
117
+ _PATTERN_SEL = {"index"}
118
+ _SCENE_STRUCT = {"scene", "enemy", "attack", "pattern"}
119
+
120
+
121
+ class BattlePatchError(ValueError):
122
+ pass
123
+
124
+
125
+ # ---- value encoding (shared by build + offline validate) ---------------------------------------------
126
+ def _to_int(value, key) -> int:
127
+ if isinstance(value, bool) or not isinstance(value, (int, str)):
128
+ raise BattlePatchError(f"{key} must be an integer (got {value!r})")
129
+ try:
130
+ return int(value)
131
+ except ValueError:
132
+ raise BattlePatchError(f"{key} must be an integer (got {value!r})")
133
+
134
+
135
+ def _resolve_items(value, key) -> list[int]:
136
+ """4 drop/steal slots: each a name/id (engine RegularItem) or "none"/""/"-" -> 255 (NoItem). Mirrors
137
+ scene_data._resolve_items (in-game proven on the Phase-1 Goblin drop)."""
138
+ if not isinstance(value, (list, tuple)) or len(value) != 4:
139
+ raise BattlePatchError(f"{key} must be a list of exactly 4 items (name/id; \"none\" or 255 = empty)")
140
+ out = []
141
+ for it in value:
142
+ if isinstance(it, str) and it.strip().lower() in ("none", "", "-"):
143
+ out.append(255)
144
+ else:
145
+ try:
146
+ out.append(items.resolve(it))
147
+ except (ValueError, TypeError) as ex:
148
+ raise BattlePatchError(f"{key}: {ex}")
149
+ return out
150
+
151
+
152
+ def _resolve_rates(value, key) -> list[int]:
153
+ """4 drop/steal ODDS (UInt16 each). The engine reads the WHOLE array, so all 4 are required."""
154
+ if not isinstance(value, (list, tuple)) or len(value) != 4:
155
+ raise BattlePatchError(f"{key} must be a list of exactly 4 rates (UInt16 0-{_U16}; the engine reads "
156
+ f"all 4 -- defaults are drop {{256,96,32,1}} / steal {{256,64,16,1}})")
157
+ out = []
158
+ for r in value:
159
+ v = _to_int(r, key)
160
+ if not 0 <= v <= _U16:
161
+ raise BattlePatchError(f"{key} value {v} out of range (0-{_U16})")
162
+ out.append(v)
163
+ return out
164
+
165
+
166
+ def encode_field(key, value, spec, *, warnings=None) -> str:
167
+ """Resolve + RANGE-CHECK one override value -> its BattlePatch value string (space-joined for arrays).
168
+ Raises BattlePatchError offline so a bad value fails the lint/build, never the running game."""
169
+ engine_name, enc, vmax = spec
170
+ if enc == "int":
171
+ v = _to_int(value, key)
172
+ elif enc == "bool":
173
+ if not isinstance(value, bool):
174
+ raise BattlePatchError(f"{key} must be true or false (got {value!r})")
175
+ return "True" if value else "False"
176
+ elif enc == "elements":
177
+ try:
178
+ v = battlecsv.encode_elements(value)
179
+ except (ValueError, TypeError) as ex:
180
+ raise BattlePatchError(f"{key}: {ex}")
181
+ elif enc == "status":
182
+ try:
183
+ v = battlecsv.encode_status(value)
184
+ except (ValueError, TypeError) as ex:
185
+ raise BattlePatchError(f"{key}: {ex}")
186
+ elif enc == "script":
187
+ if isinstance(value, str) and not value.strip().lstrip("-").isdigit():
188
+ sid = {n.lower(): i for i, n in battlecsv.SCRIPT_IDS.items()}.get(value.strip().lower())
189
+ if sid is None:
190
+ raise BattlePatchError(f"{key}: unknown scriptId formula {value!r} "
191
+ f"(see `ff9mapkit battle-actions --script-ids`)")
192
+ v = sid
193
+ else:
194
+ v = _to_int(value, key)
195
+ if warnings is not None and not battlecsv.is_stock_script(v):
196
+ warnings.append(f"{key}: scriptId {v} is not in the externalized formula catalog -- re-pointing an "
197
+ f"attack at an existing formula is data, but a BRAND-NEW formula needs a "
198
+ f"Memoria.Scripts.<Mod>.dll (not the engine DLL)")
199
+ elif enc == "items":
200
+ return " ".join(str(i) for i in _resolve_items(value, key))
201
+ elif enc == "rates":
202
+ return " ".join(str(i) for i in _resolve_rates(value, key))
203
+ else:
204
+ raise BattlePatchError(f"internal: bad encoder {enc!r}")
205
+ if vmax is not None and not 0 <= v <= vmax:
206
+ raise BattlePatchError(f"{key}={v} out of range (0-{vmax})")
207
+ return str(v)
208
+
209
+
210
+ # ---- field-line emission for one token's override dict ------------------------------------------------
211
+ def _field_lines(overrides, fields_map, *, ctx, warnings) -> list[str]:
212
+ """``FieldName value`` lines for every override key found in ``fields_map`` (skipping the selector keys
213
+ already consumed by the caller). Raises on an unknown field key."""
214
+ lines: list[str] = []
215
+ for k, val in overrides.items():
216
+ spec = fields_map.get(k)
217
+ if spec is None:
218
+ raise BattlePatchError(f"{ctx}: unknown field {k!r} (known: {', '.join(sorted(fields_map))})")
219
+ try:
220
+ lines.append(f"{spec[0]} {encode_field(k, val, spec, warnings=warnings)}")
221
+ except BattlePatchError as ex:
222
+ raise BattlePatchError(f"{ctx} {ex}")
223
+ return lines
224
+
225
+
226
+ def _selector_name(token, value, key) -> str:
227
+ """A by-name selector arg -- the US battle-text name, verbatim (spaces kept; the engine matches the WHOLE
228
+ remainder of the line)."""
229
+ if not isinstance(value, str) or not value.strip():
230
+ raise BattlePatchError(f"{key} must be a non-empty enemy/attack name (a string)")
231
+ return value.strip()
232
+
233
+
234
+ def _require_table(blk, ctx) -> dict:
235
+ """A battle block must be a table (TOML dict). Raise BattlePatchError (NOT a TypeError/AttributeError) so a
236
+ malformed toml fails the lint/build cleanly -- the linter must never traceback on bad input."""
237
+ if not isinstance(blk, dict):
238
+ raise BattlePatchError(f"{ctx} must be a table (got {type(blk).__name__}) -- "
239
+ f"e.g. {{ name = \"Goblin\", max_hp = 500 }}")
240
+ return blk
241
+
242
+
243
+ def _scene_selector(value, ctx) -> str:
244
+ """The ``Battle:`` selector arg: an int scene id (the engine parses it with Int32.TryParse) OR a non-empty
245
+ BSC_ scene name. A float/list/over-Int32 value would emit a DEAD ``Battle:`` line the engine never matches
246
+ (the block silently no-ops + is pruned) -- exactly the silent-drop this module exists to prevent."""
247
+ if isinstance(value, int) and not isinstance(value, bool):
248
+ if not 0 <= value <= _I32:
249
+ raise BattlePatchError(f"{ctx} scene id {value} out of range (a battle scene id, 0-{_I32})")
250
+ return str(value)
251
+ if isinstance(value, str) and value.strip():
252
+ return value.strip()
253
+ raise BattlePatchError(f"{ctx} scene must be an int scene id or a \"BSC_...\" name (got {value!r})")
254
+
255
+
256
+ def _enemy_block(e, *, ctx, warnings, scoped) -> list[str]:
257
+ """Emit one enemy patch: a selector (``Enemy: i`` / ``EnemyByName: n`` within a scene, or
258
+ ``AnyEnemyByName: n`` globally) + its SB2_MON_PARM/SB2_ELEMENT field lines."""
259
+ _require_table(e, ctx)
260
+ has_idx, has_name = "index" in e, "name" in e
261
+ if scoped:
262
+ if has_idx == has_name:
263
+ raise BattlePatchError(f"{ctx} needs exactly one of index = <type 0..> or name = \"<enemy name>\"")
264
+ sel = f"Enemy: {_to_int(e['index'], ctx + ' index')}" if has_idx \
265
+ else f"EnemyByName: {_selector_name('enemy', e['name'], ctx + ' name')}"
266
+ else: # global [[battle_enemy]] -> AnyEnemyByName
267
+ if not has_name or has_idx:
268
+ raise BattlePatchError(f"{ctx} is global (every scene) -- it needs name = \"<enemy name>\" "
269
+ f"(use a scene-scoped [[battle_patch.enemy]] with index = N to target a slot)")
270
+ sel = f"AnyEnemyByName: {_selector_name('enemy', e['name'], ctx + ' name')}"
271
+ body = {k: v for k, v in e.items() if k not in _ENEMY_SEL}
272
+ if not body:
273
+ raise BattlePatchError(f"{ctx} sets no fields (give e.g. max_hp = 500 or weak = [\"Fire\"])")
274
+ return [sel, *_field_lines(body, ENEMY_FIELDS, ctx=ctx, warnings=warnings)]
275
+
276
+
277
+ def _attack_block(a, *, ctx, warnings, scoped) -> list[str]:
278
+ _require_table(a, ctx)
279
+ has_idx, has_name = "index" in a, "name" in a
280
+ if scoped:
281
+ if has_idx == has_name:
282
+ raise BattlePatchError(f"{ctx} needs exactly one of index = <attack 0..> or name = \"<attack name>\"")
283
+ sel = f"Attack: {_to_int(a['index'], ctx + ' index')}" if has_idx \
284
+ else f"AttackByName: {_selector_name('attack', a['name'], ctx + ' name')}"
285
+ else:
286
+ if not has_name or has_idx:
287
+ raise BattlePatchError(f"{ctx} is global (every scene) -- it needs name = \"<attack name>\"")
288
+ sel = f"AnyAttackByName: {_selector_name('attack', a['name'], ctx + ' name')}"
289
+ body = {k: v for k, v in a.items() if k not in _ATTACK_SEL}
290
+ if not body:
291
+ raise BattlePatchError(f"{ctx} sets no fields (give e.g. power = 40 or element = [\"Fire\"])")
292
+ return [sel, *_field_lines(body, ATTACK_FIELDS, ctx=ctx, warnings=warnings)]
293
+
294
+
295
+ def _pattern_block(p, *, ctx, warnings) -> list[str]:
296
+ _require_table(p, ctx)
297
+ if "index" not in p:
298
+ raise BattlePatchError(f"{ctx} needs index = <pattern 0..> (which formation)")
299
+ sel = f"Pattern: {_to_int(p['index'], ctx + ' index')}"
300
+ body = {k: v for k, v in p.items() if k not in _PATTERN_SEL}
301
+ if not body:
302
+ raise BattlePatchError(f"{ctx} sets no fields (give e.g. rate = 16 or ap = 12)")
303
+ return [sel, *_field_lines(body, PATTERN_FIELDS, ctx=ctx, warnings=warnings)]
304
+
305
+
306
+ def _scene_block(blk, n, *, warnings) -> list[str]:
307
+ """One ``[[battle_patch]]`` -> ``Battle: <id>`` + scene flags + nested pattern/enemy/attack sub-blocks."""
308
+ ctx = f"[[battle_patch]] #{n}"
309
+ _require_table(blk, ctx)
310
+ scene = blk.get("scene")
311
+ if scene is None or isinstance(scene, bool):
312
+ raise BattlePatchError(f"{ctx} needs scene = <id or BSC_ name> (the BTL_SCENE to patch)")
313
+ lines = [f"Battle: {_scene_selector(scene, ctx)}"]
314
+ # scene flags bind to the Battle (Scene) patch -> they MUST come before any Pattern/Enemy/Attack narrower
315
+ flags = {k: v for k, v in blk.items() if k not in _SCENE_STRUCT}
316
+ lines += _field_lines(flags, SCENE_FLAGS, ctx=ctx, warnings=warnings)
317
+ for i, p in enumerate(_as_list(blk.get("pattern"), f"{ctx} [[battle_patch.pattern]]")):
318
+ lines += _pattern_block(p, ctx=f"{ctx} pattern #{i}", warnings=warnings)
319
+ for i, e in enumerate(_as_list(blk.get("enemy"), f"{ctx} [[battle_patch.enemy]]")):
320
+ lines += _enemy_block(e, ctx=f"{ctx} enemy #{i}", warnings=warnings, scoped=True)
321
+ for i, a in enumerate(_as_list(blk.get("attack"), f"{ctx} [[battle_patch.attack]]")):
322
+ lines += _attack_block(a, ctx=f"{ctx} attack #{i}", warnings=warnings, scoped=True)
323
+ if len(lines) == 1: # only "Battle: X", no fields anywhere -> a no-op block
324
+ raise BattlePatchError(f"{ctx} sets nothing -- add a scene flag, or a [[battle_patch.enemy]] / "
325
+ f"[[battle_patch.attack]] / [[battle_patch.pattern]] sub-block")
326
+ return lines
327
+
328
+
329
+ def _as_list(value, ctx):
330
+ if value is None:
331
+ return []
332
+ if not isinstance(value, list):
333
+ raise BattlePatchError(f"{ctx} must be a list of tables")
334
+ return value
335
+
336
+
337
+ # ---- public: build the BattlePatch lines from aggregated toml blocks ----------------------------------
338
+ def build_lines(scene_patches=None, enemies=None, attacks=None) -> tuple[list[str], list[str]]:
339
+ """Aggregate ``[[battle_patch]]`` (scene-scoped) + ``[[battle_enemy]]`` / ``[[battle_attack]]`` (global
340
+ by-name) blocks -> (battle_patch_lines, warnings). Pure + offline (no install needed -- names/ids are the
341
+ author's, masks come from the committed tables)."""
342
+ warnings: list[str] = []
343
+ lines: list[str] = []
344
+ for n, blk in enumerate(_as_list(scene_patches, "[[battle_patch]]")):
345
+ lines += _scene_block(blk, n, warnings=warnings)
346
+ for n, e in enumerate(_as_list(enemies, "[[battle_enemy]]")):
347
+ lines += _enemy_block(e, ctx=f"[[battle_enemy]] #{n}", warnings=warnings, scoped=False)
348
+ for n, a in enumerate(_as_list(attacks, "[[battle_attack]]")):
349
+ lines += _attack_block(a, ctx=f"[[battle_attack]] #{n}", warnings=warnings, scoped=False)
350
+ return lines, warnings
351
+
352
+
353
+ # ---- offline structural + range validation (for `lint`, no install) ----------------------------------
354
+ def validate_blocks(scene_patches=None, enemies=None, attacks=None) -> list[str]:
355
+ """Re-run the emission on a copy and surface every BattlePatchError as a message (empty => OK). All checks
356
+ are install-free: structure, the field-name/range/encoder guards, and the selector rules."""
357
+ problems: list[str] = []
358
+ try:
359
+ build_lines(scene_patches, enemies, attacks)
360
+ except BattlePatchError as ex:
361
+ # build_lines stops at the first error; surface it (the author fixes one at a time, like the scene lint)
362
+ problems.append(str(ex))
363
+ return problems
364
+
365
+
366
+ # ---- non-clobbering merge into a live BattlePatch.txt (deploy) ---------------------------------------
367
+ def _markers(field_id):
368
+ return (f"// >>> ff9mapkit field {field_id} BattlePatch (auto -- edit the field.toml, not here)",
369
+ f"// <<< ff9mapkit field {field_id}")
370
+
371
+
372
+ def merge_battle_patch(live_text: str, block_lines, field_id) -> str:
373
+ """Splice ``block_lines`` into ``live_text`` between this field's ``//`` sentinel markers, REPLACING any
374
+ prior block for the same id and PRESERVING every other line (a co-deployed battle's BGM/repoint lines, a
375
+ stacked worktree's lines). The engine skips ``//`` lines (``DataPatchers.cs:551``), so the markers are inert.
376
+ An empty ``block_lines`` just strips our prior block (a redeploy after the toml's battle blocks were removed).
377
+ Idempotent: re-merging the same block yields the same text."""
378
+ begin, end = _markers(field_id)
379
+ kept, skip = [], False
380
+ for ln in live_text.splitlines():
381
+ if ln.strip() == begin:
382
+ skip = True
383
+ continue
384
+ if ln.strip() == end:
385
+ skip = False
386
+ continue
387
+ if not skip:
388
+ kept.append(ln)
389
+ while kept and not kept[-1].strip(): # trim trailing blank lines before re-appending
390
+ kept.pop()
391
+ block = [ln for ln in (block_lines or []) if ln.strip()]
392
+ out = list(kept)
393
+ if block:
394
+ out += [begin, *block, end]
395
+ return ("\n".join(out) + "\n") if out else ""