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
ff9mapkit/logic_add.py ADDED
@@ -0,0 +1,632 @@
1
+ """Phase 4a: length-CHANGING in-place ADDITIONS to a verbatim fork's ``.eb`` -- PREPEND a guarded effect to
2
+ an existing routine. The structural sibling of :mod:`ff9mapkit.logic_edit` (length-PRESERVING value swaps).
3
+
4
+ Where ``[[logic_edit]]`` overwrites an operand same-width, ``[[logic_add]]`` ADDS instructions, which changes
5
+ the ``.eb``'s length. Two placements:
6
+ * ``where = "prepend"`` (default, Phase 4a) -- :func:`ff9mapkit.eb.edit.insert_in_function` at ``rel_off=0``,
7
+ the 676/676-proven ALWAYS-safe prepend (the engine is uniformly IP-relative, so the whole function body
8
+ shifts together; safe even over a 0x06/0x0B switch table).
9
+ * ``where = "after"`` (Phase 4b) -- insert the effect AFTER the ``after_nth``-th ``after_op`` instruction, via
10
+ the keystone (:func:`ff9mapkit.eb.cmdasm.disassemble_items` -> splice the effect's labeled source ->
11
+ :func:`ff9mapkit.eb.cmdasm.assemble_block` -> :func:`ff9mapkit.eb.edit.replace_function_body`), so EVERY jump
12
+ and switch in the function relocates past the inserted bytes (the keystone round-trips all 676 fields +
13
+ 3155/3155 switch functions byte-exact, and relocates them under a length change).
14
+
15
+ Kinds:
16
+ * ``set_flag`` -- write a GLOB story flag. IDEMPOTENT, so prepended UNGATED into any routine.
17
+ * ``give_item`` / ``give_gil`` -- CUMULATIVE (each call adds more), so wrapped in the FF9 chest once-guard
18
+ ``if(!guard){guard=1; body}`` (the guard flag auto-allocated from the safe band) -- UNLESS it's a tag-3
19
+ talk handler with ``repeat = true`` (a deliberately repeatable per-interaction effect).
20
+ * ``show_line`` -- open a dialogue window showing an authored line. Its only effect is the message, so
21
+ it's the way to ANNOUNCE a silent ``give_item``/``give_gil`` (otherwise they hand over the reward with
22
+ no "Received X!" box). Any other kind may also carry an optional ``message = "..."`` to announce its
23
+ effect in the SAME once-guard (give THEN show, atomically).
24
+ * ``add_case`` -- ADD a new arm to an existing jump table (0x06/0x0B/0x0D) -- a NEW dispatch arm (a scenario
25
+ value, an ATE/dialogue menu row) that runs one of the effects above (named by ``effect``) then rejoins the
26
+ switch's DEFAULT arm (``then = "merge"``). 0x0B/0x0D are contiguous (``case = "auto"`` = base+ncases, the
27
+ only legal extension); 0x06 takes an explicit unused ``case`` value. Length-changing: the operand table
28
+ grows + the branch body is appended at the function end, and the keystone re-anchors every reloff.
29
+ ``add_case`` alone makes an EXISTING-but-default selector value live -- it adds a dispatch arm but no
30
+ SELECTABLE, LABELLED menu row.
31
+ * ``menu_row`` -- the full coordinated MENU ROW: add a NEW selectable + labelled option to an existing
32
+ dialogue-choice menu. Orchestrates three legs over a CANONICAL menu (a base-0 contiguous GetChoose switch
33
+ + a text-gated ``[CHOO]`` row list): (A) the dispatch arm (an ``add_case`` at the next contiguous row
34
+ index, running ``effect``), (B) a best-effort widen of the ``EnableDialogChoices`` (0x7C) availability
35
+ mask, and (C) a verified splice of the new ``\n[MOVE=18,0]<label>`` row into the menu's single ``.mes``
36
+ entry (the row-label leg ships in :func:`menu_row_text_plan` / :func:`apply_menu_row_text`, applied by the
37
+ build). v1 targets TEXT-gated menus (no pre-tag / ``[PCHC]``); a ``[PCHM]`` mask-gated menu (incl. the ATE
38
+ avail-word menus) fails closed -- the runtime mask leg is a follow-up. One ``menu_row`` per switch.
39
+
40
+ A ``show_line`` (or any ``message =``) line is APPENDED to the donor ``.mes`` above its txids -- the same
41
+ append-and-resolve channel ``[[on_entry]]`` narration uses (:func:`build._verbatim_on_entry_messages`) -- so
42
+ the inserted ``WindowSync`` resolves into real text. A message ALWAYS implies a once-guard (a window in a
43
+ tread zone would re-open every frame), even on an otherwise-idempotent ``set_flag``.
44
+
45
+ The effect bytes reuse the proven :mod:`ff9mapkit.content.region` / :mod:`ff9mapkit.content.event` encoders
46
+ verbatim (zero new bytecode). The composed ``.eb`` is re-validated by :func:`ff9mapkit.eblint.lint_eb` before
47
+ the build ships it -- a bad add fails the BUILD (a clean :class:`LogicAddError`), never a silent mis-splice.
48
+ """
49
+ from __future__ import annotations
50
+
51
+ import re
52
+
53
+ from . import flags as _flags
54
+ from .content import event as _event
55
+ from .content import region as _region
56
+ from .eb import edit as _edit
57
+ from .eb.model import EbScript
58
+
59
+ GLOB_BOOL = _region.GLOB_BOOL # 0xC4 -- save-persistent story-flag class
60
+ _ADD_KINDS = ("set_flag", "give_item", "give_gil", "show_line")
61
+ _CUMULATIVE = ("give_item", "give_gil") # need a once-guard (set_flag is idempotent)
62
+ TALK_TAG = 3 # the only tag where an UNGATED cumulative effect is sane
63
+ _GIL_CAP = 9_999_999
64
+ _SWITCH_OPS = (0x06, 0x0B, 0x0D) # JMP_SWITCHEX (explicit) / JMP_SWITCH (contiguous) / 2-byte
65
+ _DISPATCH_KINDS = ("add_case", "menu_row") # kinds whose EFFECT is named by `effect` (not `kind`)
66
+ ENABLE_DIALOG_CHOICES = 0x7C # CHOOSEPARAM: [argflags][availMask:u16][default:u8]
67
+ CHOICE_INDENT = "[MOVE=18,0]" # = content.text.CHOICE_INDENT (per-row menu indent)
68
+
69
+
70
+ def _effective_effect_add(add):
71
+ """The add whose ``kind`` names the EFFECT to encode. For a normal effect add that's ``add`` itself; for a
72
+ DISPATCH add (``add_case``/``menu_row`` -- ``kind`` stays the dispatch kind, the payload is named by
73
+ ``effect``) it's a synthesized ``{**add, "kind": <effect>}`` -- so the effect/message/guard machinery treats
74
+ all of them uniformly. ``None`` for a dispatch add with no ``effect`` (a stub arm)."""
75
+ if add.get("kind") not in _DISPATCH_KINDS:
76
+ return add
77
+ eff = add.get("effect")
78
+ return {**add, "kind": eff} if eff is not None else None
79
+
80
+
81
+ class LogicAddError(ValueError):
82
+ """A ``[[logic_add]]`` that can't be applied safely (bad routine, out-of-band flag, overflow, no guards
83
+ left, an unsafe ungated effect) -- it fails the BUILD, never silently mis-adds."""
84
+
85
+
86
+ def _int(add, key, *, default=None, optional=False):
87
+ if key not in add:
88
+ if optional:
89
+ return default
90
+ raise LogicAddError(f"logic_add ({add.get('kind', '?')}) missing required key '{key}'")
91
+ v = add[key]
92
+ if isinstance(v, bool) or not isinstance(v, int):
93
+ raise LogicAddError(f"logic_add ({add.get('kind', '?')}) key '{key}' must be an integer, "
94
+ f"got {type(v).__name__} ({v!r})")
95
+ return v
96
+
97
+
98
+ def _add_message(add) -> "str | None":
99
+ """The authored line this add SHOWS, or None. ``show_line`` requires one; any other kind may carry an
100
+ optional ``message = "..."`` to announce its effect. Raises on a present-but-malformed message."""
101
+ add = _effective_effect_add(add)
102
+ if add is None: # an add_case stub (no effect) -> no message
103
+ return None
104
+ msg = add.get("message")
105
+ if add.get("kind") == "show_line":
106
+ if not isinstance(msg, str) or not msg:
107
+ raise LogicAddError("logic_add show_line needs a non-empty `message` string (the line to show)")
108
+ return msg
109
+ if msg is None:
110
+ return None
111
+ if not isinstance(msg, str) or not msg:
112
+ raise LogicAddError(f"logic_add ({add.get('kind', '?')}) message must be a non-empty string")
113
+ return msg
114
+
115
+
116
+ def _needs_guard(add) -> bool:
117
+ """A once-guard is needed for a CUMULATIVE effect (give_item/give_gil pile up) OR any add that shows a
118
+ MESSAGE (a window in a tread zone would re-open every frame). ``set_flag`` alone is idempotent -> ungated."""
119
+ return add.get("kind") in _CUMULATIVE or _add_message(add) is not None
120
+
121
+
122
+ def _effect_body(add, *, message_txid=None) -> bytes:
123
+ """The raw effect bytes for one add (no guard) -- reuses the content encoders. When the add carries a
124
+ message (or IS a ``show_line``), a ``WindowSync(message_txid)`` is APPENDED after the effect (give THEN
125
+ announce); ``message_txid`` is the build-allocated id of the appended ``.mes`` line."""
126
+ kind = add.get("kind")
127
+ msg = _add_message(add)
128
+ if msg is not None and message_txid is None: # the build allocates message txids; a
129
+ raise LogicAddError(f"logic_add ({kind}) has a message but no text id was allocated (internal: "
130
+ "apply_logic_adds needs message_txids -- the build/Check plan provides it)")
131
+ tail = _event.message(int(message_txid)) if msg is not None else b""
132
+ if kind == "show_line":
133
+ return tail # the message IS the whole effect
134
+ if kind == "set_flag":
135
+ idx, val = _int(add, "flag"), _int(add, "value", default=1, optional=True)
136
+ if not _flags.is_safe_custom(idx):
137
+ raise LogicAddError(f"set_flag index {idx} is outside the safe custom band "
138
+ f"[{_flags.FIRST_SAFE_FLAG}, {_flags.CHOICE_SCRATCH_FLOOR}) (or reserved)")
139
+ return _region.set_var(GLOB_BOOL, idx, val) + tail
140
+ if kind == "give_item":
141
+ count = _int(add, "count", default=1, optional=True)
142
+ if not (1 <= count <= 255):
143
+ raise LogicAddError(f"give_item count {count} out of range (1-255; AddItem count is one byte)")
144
+ try:
145
+ return _event.give_item(add.get("item"), count) + tail # name or id, resolved by items
146
+ except (ValueError, KeyError) as ex:
147
+ raise LogicAddError(f"give_item: {ex}")
148
+ if kind == "give_gil":
149
+ amount = _int(add, "amount")
150
+ if not (0 < amount <= _GIL_CAP):
151
+ raise LogicAddError(f"give_gil amount {amount} out of range (1-{_GIL_CAP})")
152
+ return _event.give_gil(amount) + tail
153
+ raise LogicAddError(f"logic_add unknown kind '{kind}' (kinds: {_ADD_KINDS})")
154
+
155
+
156
+ def _guarded(body: bytes, guard: int) -> bytes:
157
+ """``if(!guard){ guard=1; body }`` -- the FF9 chest once-guard (dedup-flag FIRST so a window in ``body``
158
+ can't double-fire), via the proven region encoders."""
159
+ return _region.if_block(_region.cond_not(GLOB_BOOL, guard),
160
+ _region.set_var(GLOB_BOOL, guard, 1) + body)
161
+
162
+
163
+ class _GuardAlloc:
164
+ """Hands out disjoint once-guard flags. Avoids collisions with: the author's other ``set_flag`` indices,
165
+ the project's OTHER authored story flags (``reserved`` -- ``[startup]``/``[[flag]]``/``[[on_entry]]``/
166
+ gateway ``set_flags``; else a guard could alias a load-time flag and silently pre-fire), each other, and
167
+ (in a campaign) a SIBLING member's window. Auto-guards are drawn from ``[base, base+window)`` (a campaign
168
+ member's flag block) or ``[FIRST_SAFE_FLAG, CHOICE_SCRATCH_FLOOR)`` for a single field; an explicit
169
+ ``guard`` is band-checked + collision-checked but not window-confined (it may be a deliberate shared flag)."""
170
+
171
+ def __init__(self, base, window, adds, reserved):
172
+ self._base = base if base is not None else _flags.FIRST_SAFE_FLAG
173
+ self._next = self._base
174
+ self._hi = (self._base + window) if (base is not None and window) else _flags.CHOICE_SCRATCH_FLOOR
175
+ self._hi = min(self._hi, _flags.CHOICE_SCRATCH_FLOOR)
176
+ self._used = {f for f in (reserved or ()) if isinstance(f, int) and not isinstance(f, bool)}
177
+ for a in adds or []: # other set_flag targets (handed-out guards are recorded in take)
178
+ ea = _effective_effect_add(a) # incl. an add_case whose effect is set_flag
179
+ if ea and ea.get("kind") == "set_flag" and isinstance(ea.get("flag"), int) and not isinstance(ea.get("flag"), bool):
180
+ self._used.add(ea["flag"])
181
+
182
+ def take(self, add):
183
+ g = add.get("guard")
184
+ if g is not None:
185
+ if isinstance(g, bool) or not isinstance(g, int):
186
+ raise LogicAddError("logic_add guard must be an integer")
187
+ if not _flags.is_safe_custom(g):
188
+ raise LogicAddError(f"logic_add guard {g} is outside the safe custom band "
189
+ f"[{_flags.FIRST_SAFE_FLAG}, {_flags.CHOICE_SCRATCH_FLOOR}) (or reserved)")
190
+ if g in self._used:
191
+ raise LogicAddError(f"logic_add guard {g} collides with another guard or an authored flag "
192
+ f"-- pick a free index in [{self._base}, {self._hi})")
193
+ self._used.add(g)
194
+ return g
195
+ while self._next < self._hi:
196
+ g = self._next
197
+ self._next += 1
198
+ if g not in self._used and _flags.is_safe_custom(g):
199
+ self._used.add(g)
200
+ return g
201
+ raise LogicAddError(f"logic_add ran out of safe guard flags in [{self._base}, {self._hi}) -- "
202
+ "raise [campaign] flags_per_field or set an explicit guard = N")
203
+
204
+
205
+ def _core_bytes(add, alloc, warnings=None, *, message_txid=None) -> bytes:
206
+ """The bytes to prepend for one add: the effect, guarded unless it's idempotent (a message-less
207
+ set_flag) or a deliberately-repeatable tag-3 talk effect (``repeat = true``)."""
208
+ body = _effect_body(add, message_txid=message_txid)
209
+ if not _needs_guard(add):
210
+ return body # set_flag w/o a message -- idempotent, ungated
211
+ if add.get("repeat"):
212
+ if _int(add, "tag") != TALK_TAG:
213
+ raise LogicAddError(f"logic_add {add['kind']} repeat=true is only allowed on a tag-{TALK_TAG} "
214
+ f"talk handler (got tag {_int(add, 'tag')}); else it would fire every frame")
215
+ if warnings is not None: # tag-3 is talk for an NPC but action-press for a region
216
+ warnings.append(f"[[logic_add]] {add['kind']} repeat=true on entry {_int(add, 'entry')} tag {TALK_TAG} "
217
+ "re-fires on EVERY interaction (NPC talk / region action-button press), not once")
218
+ return body # an opt-in per-interaction repeat
219
+ return _guarded(body, alloc.take(add)) # default: once-guarded (safe in any routine)
220
+
221
+
222
+ def _insert_after(eb_bytes, eb, f, entry, tag, add, core, warnings) -> bytes:
223
+ """Phase-4b: splice ``core`` AFTER the ``after_nth``-th occurrence of ``after_op`` in the routine. Uses the
224
+ keystone -- disassemble the function to labeled source, insert the effect's (re-labeled) source right after
225
+ the anchor instruction's line, reassemble (so EVERY jump/switch in the function relocates past the inserted
226
+ bytes), and swap the rebuilt body in via :func:`eb.edit.replace_function_body`. The effect's own once-guard
227
+ skip lands on the function's continuation, exactly as a prepend's does."""
228
+ from .eb import cmdasm as _cmdasm
229
+ from .eb import disasm as _disasm
230
+ from .eb import exprasm as _exprasm
231
+ after_op = _int(add, "after_op")
232
+ after_nth = _int(add, "after_nth", default=0, optional=True)
233
+ hits = [i for i in eb.instrs(f) if i.op == after_op]
234
+ if not (0 <= after_nth < len(hits)):
235
+ raise LogicAddError(f"logic_add where='after': no {_disasm.op_name(after_op)} #{after_nth} in "
236
+ f"entry{_int(add, 'entry')}/tag{_int(add, 'tag')} (found {len(hits)})")
237
+ anchor = hits[after_nth]
238
+ if warnings is not None and (anchor.op in _disasm.TERMINATOR_OPS or anchor.op == 0x01):
239
+ kind = "terminator" if anchor.op in _disasm.TERMINATOR_OPS else "unconditional JMP"
240
+ warnings.append(f"logic_add where='after' anchors on a {kind} ({_disasm.op_name(anchor.op)}) in "
241
+ f"entry{_int(add, 'entry')}/tag{_int(add, 'tag')} -- the inserted effect is unreachable "
242
+ "(control never falls through to it). Anchor on an earlier instruction.")
243
+ anchor_rel = anchor.off - f.abs_start
244
+ try: # the rebuild can raise CmdAsmError (a sibling of
245
+ items = _cmdasm.disassemble_items(eb.data, f.abs_start, f.abs_end) # LogicAddError) -- normalize it so the
246
+ line_idx = next((k for k, (off, _t) in enumerate(items) if off == anchor_rel), None) # build/Check report
247
+ if line_idx is None: # the anchor is a decoded instr -> always found
248
+ raise LogicAddError("logic_add where='after': could not locate the anchor instruction (internal)")
249
+ effect_src = _cmdasm.relabel(_cmdasm.disassemble_block(core, 0, len(core)), "_e")
250
+ texts = [t for _o, t in items]
251
+ spliced = "\n".join(texts[:line_idx + 1] + effect_src.split("\n") + texts[line_idx + 1:])
252
+ new_body = _cmdasm.assemble_block(spliced)
253
+ except (_cmdasm.CmdAsmError, _exprasm.AssembleError) as ex: # ...is a clean failure, not a raw traceback
254
+ raise LogicAddError(f"logic_add where='after': could not rebuild entry{_int(add, 'entry')}/"
255
+ f"tag{_int(add, 'tag')}: {ex}")
256
+ return _edit.replace_function_body(eb_bytes, entry, tag, new_body)
257
+
258
+
259
+ # --- add_case: ADD a new arm to a jump table (length-changing: grow the operand table + a new branch body) ---
260
+ def _locate_switch(eb, entry, tag, nth):
261
+ """Find the ``nth`` switch (0x06/0x0B/0x0D) in entry/tag; return ``(f, ins, SwitchInfo)``. ``nth`` may be
262
+ None when the function has exactly one switch. Raises a clean :class:`LogicAddError` on any miss."""
263
+ from .eb import disasm as _disasm
264
+ if not (0 <= entry < eb.entry_count) or eb.entry(entry).empty:
265
+ raise LogicAddError(f"add_case entry {entry} is empty or out of range (0..{eb.entry_count - 1})")
266
+ f = eb.entry(entry).func_by_tag(tag)
267
+ if f is None:
268
+ raise LogicAddError(f"add_case entry {entry} has no function tag {tag}")
269
+ switches = [i for i in eb.instrs(f) if i.op in _SWITCH_OPS]
270
+ if not switches:
271
+ raise LogicAddError(f"add_case: no switch (0x06/0x0B/0x0D) in entry{entry}/tag{tag}")
272
+ if nth is None:
273
+ if len(switches) > 1:
274
+ raise LogicAddError(f"add_case: {len(switches)} switches in entry{entry}/tag{tag} -- "
275
+ f"add `nth` (0..{len(switches) - 1})")
276
+ nth = 0
277
+ if not (0 <= nth < len(switches)):
278
+ raise LogicAddError(f"add_case nth {nth} out of range (0..{len(switches) - 1})")
279
+ ins = switches[nth]
280
+ si = _disasm.decode_switch(ins)
281
+ if si is None:
282
+ raise LogicAddError(f"add_case: the switch in entry{entry}/tag{tag} has computed operands (can't add)")
283
+ return f, ins, si
284
+
285
+
286
+ def _resolve_add_case(add, si, ncases):
287
+ """Validate + return the new selector value. 0x0B/0x0D (contiguous): only extends at ``base + ncases``;
288
+ ``case = "auto"`` resolves to it, an explicit int must equal it. 0x06 (explicit): any unused value 0-65535."""
289
+ case = add.get("case", "auto")
290
+ if si.op in (0x0B, 0x0D):
291
+ nxt = si.base + ncases
292
+ if case == "auto":
293
+ return nxt
294
+ v = _int(add, "case")
295
+ if v != nxt:
296
+ raise LogicAddError(f"add_case: a contiguous SWITCH only extends at the NEXT selector {nxt} "
297
+ f"(base {si.base} + {ncases} cases) -- got {v}; use case=\"auto\"")
298
+ return nxt
299
+ if case == "auto": # 0x06 SWITCHEX -- needs an explicit value
300
+ raise LogicAddError("add_case: a 0x06 SWITCHEX needs an explicit `case` value (no contiguous 'auto')")
301
+ v = _int(add, "case")
302
+ if v in {e.value for e in si.edges if not e.is_default}:
303
+ raise LogicAddError(f"add_case: case value {v} already exists in this SWITCHEX (a duplicate arm is dead)")
304
+ if not (0 <= v <= 0xFFFF):
305
+ raise LogicAddError(f"add_case: case value {v} out of range (0-65535)")
306
+ return v
307
+
308
+
309
+ def _insert_case(eb_bytes, eb, f, entry, tag, ins, si, case_value, core):
310
+ """Append a new case arm to the switch: grow its operand list (``L<new>`` for 0x0B/0x0D, ``value, L<new>``
311
+ for 0x06) and append the new branch (``NEWCASE:`` + the effect's re-labeled source + a ``JMP`` to the
312
+ switch's DEFAULT arm) at the function end, then reassemble (cmdasm re-anchors every reloff) + swap the body
313
+ in. The new arm's reloff is FORWARD (the branch is after the switch); the merge JMP is a plain backward
314
+ 0x01 to the default. The count byte is recomputed by cmdasm from the operand-list length (no manual bump)."""
315
+ from .eb import cmdasm as _cmdasm
316
+ from .eb import exprasm as _exprasm
317
+ sw_rel = ins.off - f.abs_start
318
+ default_rel = next(e.target for e in si.edges if e.is_default) - f.abs_start
319
+ if not (0 <= default_rel < (f.abs_end - f.abs_start)): # a default arm AT the function end (a malformed
320
+ raise LogicAddError(f"add_case: the switch's default arm in entry{entry}/tag{tag} is not an in-function "
321
+ "instruction boundary -- no safe merge target (the donor is itself broken)")
322
+ try:
323
+ items = _cmdasm.disassemble_items(eb.data, f.abs_start, f.abs_end)
324
+ line_idx = next((k for k, (off, _t) in enumerate(items) if off == sw_rel), None)
325
+ if line_idx is None: # the located switch is decoded -> always present
326
+ raise LogicAddError("add_case: could not locate the switch (internal)")
327
+ texts = [t for _o, t in items]
328
+ line = texts[line_idx]
329
+ mnem = line[:line.index("(")]
330
+ ops = line[line.index("(") + 1:line.rindex(")")].split(", ")
331
+ ops += ([str(case_value), "NEWCASE"] if si.op == 0x06 else ["NEWCASE"]) # 0x0B/0x0D: positional (base+n)
332
+ texts[line_idx] = mnem + "(" + ", ".join(ops) + ")"
333
+ branch = ["NEWCASE:"]
334
+ if core: # the effect body (re-labeled so its L<n> can't collide)
335
+ branch += _cmdasm.relabel(_cmdasm.disassemble_block(core, 0, len(core)), "_c").split("\n")
336
+ branch.append(f"JMP(L{default_rel})") # then="merge": rejoin the switch's default arm
337
+ new_body = _cmdasm.assemble_block("\n".join(texts + branch))
338
+ except (_cmdasm.CmdAsmError, _exprasm.AssembleError) as ex:
339
+ raise LogicAddError(f"add_case: could not rebuild entry{entry}/tag{tag}: {ex}")
340
+ return _edit.replace_function_body(eb_bytes, entry, tag, new_body)
341
+
342
+
343
+ def _apply_add_case(eb_bytes, add, alloc, warnings=None, *, message_txid=None) -> bytes:
344
+ """Add ONE new case arm to an existing switch, running a reused effect (set_flag/give_item/give_gil/
345
+ show_line) then rejoining the switch's default arm. The keystone rebuild relocates everything."""
346
+ if add.get("repeat"):
347
+ raise LogicAddError("add_case does not support `repeat` (a dispatch arm is not a tag-3 talk poll)")
348
+ then = add.get("then", "merge")
349
+ if then != "merge":
350
+ raise LogicAddError(f"add_case then='{then}' is not supported (v1 ships then=\"merge\" = rejoin the "
351
+ "switch's own default arm)")
352
+ eff = add.get("effect")
353
+ if eff is None:
354
+ raise LogicAddError("add_case needs an `effect` (set_flag/give_item/give_gil/show_line) -- a stub arm "
355
+ "that only rejoins the default is a no-op")
356
+ if eff not in _ADD_KINDS:
357
+ raise LogicAddError(f"add_case effect '{eff}' must be one of {_ADD_KINDS}")
358
+ entry, tag = _int(add, "entry"), _int(add, "tag")
359
+ nth = _int(add, "nth", default=None, optional=True)
360
+ eb = EbScript.from_bytes(eb_bytes)
361
+ f, ins, si = _locate_switch(eb, entry, tag, nth)
362
+ ncases = len([e for e in si.edges if not e.is_default])
363
+ cc = _int(add, "case_count", default=None, optional=True) # optional shape guard (donor drift -> fail)
364
+ if cc is not None and cc != ncases:
365
+ raise LogicAddError(f"add_case case_count guard: the switch has {ncases} cases, not {cc} (donor drift)")
366
+ cap = 65535 if si.op == 0x0D else 255 # the count byte width (0x06/0x0B = 1 byte)
367
+ if ncases >= cap:
368
+ raise LogicAddError(f"add_case: this switch is full ({ncases}/{cap} cases)")
369
+ case_value = _resolve_add_case(add, si, ncases)
370
+ core = _core_bytes(_effective_effect_add(add), alloc, warnings, message_txid=message_txid)
371
+ return _insert_case(eb_bytes, eb, f, entry, tag, ins, si, case_value, core)
372
+
373
+
374
+ # --- menu_row: the full coordinated ADD of a selectable+labelled choice-menu row -----------------------
375
+ def _menu_row_switch(eb, add):
376
+ """Locate + VALIDATE a ``menu_row``'s dispatch switch: it must be a base-0 CONTIGUOUS GetChoose switch
377
+ (0x0B/0x0D, base 0) so the row index IS the case value (1:1). Returns ``(f, ins, si, new_row)`` where
378
+ ``new_row`` = the existing case count = the next contiguous row index. Raises a clean LogicAddError on a
379
+ non-canonical menu (explicit 0x06 / non-zero base) -- author those with a manual add_case + logic_edit text."""
380
+ from .eb import disasm as _disasm
381
+ entry, tag = _int(add, "entry"), _int(add, "tag")
382
+ nth = _int(add, "nth", default=None, optional=True)
383
+ f, ins, si = _locate_switch(eb, entry, tag, nth)
384
+ if si.op not in (0x0B, 0x0D):
385
+ raise LogicAddError(f"menu_row needs a CONTIGUOUS GetChoose switch (0x0B/0x0D); entry{entry}/tag{tag}'s "
386
+ f"switch is {_disasm.op_name(si.op)} (explicit 0x06) -- author this with a manual "
387
+ "add_case (explicit case) + a [[logic_edit]] text row instead")
388
+ if si.base != 0:
389
+ raise LogicAddError(f"menu_row needs a base-0 GetChoose switch (the picked row index IS the case value); "
390
+ f"entry{entry}/tag{tag}'s switch has base {si.base} -- not a 1:1 menu")
391
+ new_row = len([e for e in si.edges if not e.is_default])
392
+ return f, ins, si, new_row
393
+
394
+
395
+ def _widen_dialog_mask(eb_bytes, eb, f, sw_ins, new_row, warnings=None) -> bytes:
396
+ """BEST-EFFORT leg B: OR the new row's bit into the ``EnableDialogChoices`` (0x7C) availability mask that
397
+ sets up this menu (the last 0x7C before the switch), an in-place LENGTH-PRESERVING operand edit. A no-op
398
+ (with a warning) when the mask is absent (text-gated menu) or computed at runtime (an expression operand
399
+ -- ATE avail-word copy / dynamic flag-gated). For a TEXT-gated menu (no pre-tag / ``[PCHC]``) the mask is
400
+ not consulted, so leaving it unwidened is harmless; this keeps a literal all-on mask looking consistent."""
401
+ masks = [i for i in eb.instrs(f) if i.op == ENABLE_DIALOG_CHOICES and i.off < sw_ins.off]
402
+ if not masks:
403
+ if warnings is not None:
404
+ warnings.append("menu_row: no EnableDialogChoices (0x7C) precedes the switch -- the menu enables its "
405
+ "rows from the text ([CHOO] rows / a [PCHC] count), so the new row relies on the .mes "
406
+ "row (+ count); no mask to widen.")
407
+ return eb_bytes
408
+ m = masks[-1]
409
+ if m.arg_is_expr[0]: # a RUNTIME-computed mask -- no literal to patch
410
+ if warnings is not None:
411
+ warnings.append("menu_row: the EnableDialogChoices mask is computed at runtime (expression operand), "
412
+ "not widened. v1 targets text-gated menus ([PCHC]/no pre-tag); if this menu hides "
413
+ "rows via a [PCHM] mask, the new row may not be selectable.")
414
+ return eb_bytes
415
+ if new_row >= 16: # the availMask is a u16 -- bit 16+ is unrepresentable
416
+ if warnings is not None:
417
+ warnings.append(f"menu_row: row index {new_row} exceeds the 16-bit EnableDialogChoices mask -- the "
418
+ "availability bit can't be set. (A [PCHM] mask-gated menu with >16 rows is unsupported; "
419
+ "a text-gated menu doesn't consult the mask, so this is harmless there.)")
420
+ return eb_bytes
421
+ old = m.imm(0)
422
+ new = (old | (1 << new_row)) & 0xFFFF
423
+ if new == old:
424
+ return eb_bytes # bit already set
425
+ ba = bytearray(eb_bytes)
426
+ ba[m.off + 2:m.off + 4] = new.to_bytes(2, "little") # op(1) + argflags(1) -> mask u16 LE at off+2
427
+ return bytes(ba)
428
+
429
+
430
+ def _apply_menu_row(eb_bytes, add, alloc, warnings=None, *, message_txid=None) -> bytes:
431
+ """The ``.eb`` side of a ``menu_row``: widen the availability mask (leg B), then ADD the dispatch arm at
432
+ the next contiguous row index (leg A = ``add_case`` with ``case="auto"``, running ``effect``). The ``.mes``
433
+ row-label leg (C) is applied by the build via :func:`menu_row_text_plan` / :func:`apply_menu_row_text`."""
434
+ label = add.get("label")
435
+ if not isinstance(label, str) or not label:
436
+ raise LogicAddError("menu_row needs a non-empty `label` (the new row's menu text)")
437
+ _int(add, "menu_txid") # required: the .mes entry holding the choice rows
438
+ if add.get("case", "auto") != "auto":
439
+ raise LogicAddError('menu_row uses case="auto" (the new row is always the next contiguous choice index)')
440
+ eb = EbScript.from_bytes(eb_bytes)
441
+ f, ins, si, new_row = _menu_row_switch(eb, add) # validates base-0 contiguous + computes new_row
442
+ eb_bytes = _widen_dialog_mask(eb_bytes, eb, f, ins, new_row, warnings)
443
+ return _apply_add_case(eb_bytes, add, alloc, warnings, message_txid=message_txid) # leg A (the dispatch arm)
444
+
445
+
446
+ def _apply_one(eb_bytes, add, alloc, warnings=None, *, message_txid=None) -> bytes:
447
+ kind = add.get("kind")
448
+ if kind == "add_case": # ADD a switch arm (length-changing table grow)
449
+ return _apply_add_case(eb_bytes, add, alloc, warnings, message_txid=message_txid)
450
+ if kind == "menu_row": # ADD a selectable+labelled choice-menu row
451
+ return _apply_menu_row(eb_bytes, add, alloc, warnings, message_txid=message_txid)
452
+ if kind not in _ADD_KINDS:
453
+ raise LogicAddError(f"logic_add unknown kind '{kind}' (kinds: {_ADD_KINDS} + 'add_case'/'menu_row')")
454
+ where = add.get("where", "prepend")
455
+ if where not in ("prepend", "after"):
456
+ raise LogicAddError(f"logic_add where='{where}' is not supported (use 'prepend' or 'after'; "
457
+ "'after' takes an after_op anchor)")
458
+ entry, tag = _int(add, "entry"), _int(add, "tag")
459
+ eb = EbScript.from_bytes(eb_bytes)
460
+ if not (0 <= entry < eb.entry_count) or eb.entry(entry).empty:
461
+ raise LogicAddError(f"logic_add entry {entry} is empty or out of range (0..{eb.entry_count - 1})")
462
+ f = eb.entry(entry).func_by_tag(tag)
463
+ if f is None:
464
+ raise LogicAddError(f"logic_add entry {entry} has no function tag {tag}")
465
+ core = _core_bytes(add, alloc, warnings, message_txid=message_txid)
466
+ if where == "prepend":
467
+ return _edit.insert_in_function(eb_bytes, entry, tag, 0, core) # the always-safe rel_off=0 prepend
468
+ return _insert_after(eb_bytes, eb, f, entry, tag, add, core, warnings) # mid-function (keystone rebuild)
469
+
470
+
471
+ def _normalize_adds(adds):
472
+ """The canonical filtered add list -- shared by :func:`apply_logic_adds` and :func:`plan_messages` so a
473
+ message txid keyed by index lines up with the add the build actually applies. Raises on a non-list or a
474
+ non-table element (the same clean errors the build/Check surface)."""
475
+ if adds is None:
476
+ return []
477
+ if not isinstance(adds, (list, tuple)): # a single [logic_add] table (or junk) -> clean error
478
+ raise LogicAddError("logic_add must be an array of tables ([[logic_add]]), not "
479
+ f"{type(adds).__name__} (you likely wrote [logic_add] instead of [[logic_add]])")
480
+ adds = [a for a in adds if a]
481
+ for a in adds:
482
+ if not isinstance(a, dict):
483
+ raise LogicAddError(f"each logic_add must be a table, got {type(a).__name__}")
484
+ return adds
485
+
486
+
487
+ def plan_messages(adds):
488
+ """The appended ``.mes`` lines a ``[[logic_add]]`` list needs, in apply order: ``[(idx, message,
489
+ speaker, tail)]`` for every add that SHOWS a line (a ``show_line`` or any kind with ``message =``).
490
+ ``idx`` is the index into the normalized (filtered) add list -- the SAME index :func:`apply_logic_adds`
491
+ enumerates -- so the build can allocate one txid per entry and hand them back as ``message_txids``."""
492
+ out = []
493
+ for idx, add in enumerate(_normalize_adds(adds)):
494
+ msg = _add_message(add)
495
+ if msg is not None:
496
+ out.append((idx, msg, add.get("speaker"), add.get("tail")))
497
+ return out
498
+
499
+
500
+ def apply_logic_adds(eb_bytes, adds, *, guard_base=None, guard_window=None, reserved_flags=None,
501
+ message_txids=None, warnings=None) -> bytes:
502
+ """Apply every ``[[logic_add]]`` to ``eb_bytes`` (a guarded PREPEND, or a mid-function insert for
503
+ ``where="after"``) and return the new bytes. Empty -> byte-identical. Raises :class:`LogicAddError` on
504
+ any unsafe add. ``guard_base``/``guard_window`` confine auto once-guards to a campaign member's flag
505
+ block; ``reserved_flags`` is the project's OTHER authored safe-band flags (so a guard never aliases one).
506
+ ``message_txids`` maps a normalized-add index (see :func:`plan_messages`) to the txid of its appended
507
+ ``.mes`` line -- required for any add that shows a message. ``warnings`` (a list) collects advisories."""
508
+ adds = _normalize_adds(adds)
509
+ if not adds:
510
+ return bytes(eb_bytes)
511
+ message_txids = message_txids or {}
512
+ alloc = _GuardAlloc(guard_base, guard_window, adds, reserved_flags)
513
+ if any(a.get("kind") == "menu_row" for a in adds): # leg-A/leg-C alignment guard (see below)
514
+ _assert_menu_row_switches(EbScript.from_bytes(eb_bytes), adds)
515
+ out = bytes(eb_bytes)
516
+ for idx, add in enumerate(adds):
517
+ out = _apply_one(out, add, alloc, warnings, message_txid=message_txids.get(idx))
518
+ return out
519
+
520
+
521
+ # --- menu_row leg C: the .mes row-label splice (applied by the build, NOT part of the .eb byte stream) ---
522
+ _PCHC_RE = re.compile(r"\[PCHC=(\d+),(\d+)((?:,\d+)*)\]") # pre-choose config: count, cancel-row, +extra fields
523
+ _TRAILING_TAGS_RE = re.compile(r"(?:\[[A-Z][A-Z0-9]*(?:=[^\]]*)?\])+$") # a run of [TAG]/[TAG=..] at the very end
524
+
525
+
526
+ def _switch_off(eb, add):
527
+ """The absolute byte offset of the switch an ``add_case``/``menu_row`` targets on ``eb``'s bytes, or None for
528
+ a non-dispatch add. Used to detect two dispatch adds hitting the SAME switch regardless of how ``nth`` is
529
+ spelled (``None`` vs an explicit ``0`` on a single-switch function resolve to the same instruction)."""
530
+ if add.get("kind") not in _DISPATCH_KINDS:
531
+ return None
532
+ _f, ins, _si = _locate_switch(eb, _int(add, "entry"), _int(add, "tag"),
533
+ _int(add, "nth", default=None, optional=True))
534
+ return ins.off
535
+
536
+
537
+ def _assert_menu_row_switches(eb, adds):
538
+ """Reject a ``menu_row`` that shares its switch with ANY other dispatch add (``add_case``/``menu_row``).
539
+ The ``menu_row`` row index (leg C) is planned from the PRE-add switch, while the dispatch case (leg A) is
540
+ computed from the LIVE bytes as adds apply sequentially -- so a second dispatch add growing the same switch
541
+ first would land leg A on a different case than the planned row, mis-aligning the menu (no error otherwise).
542
+ Keys by switch OFFSET, so it also catches ``nth=None`` vs ``nth=0`` duplicates + two menu_rows on one switch.
543
+ ``eb`` = the pre-add bytes; a non-locatable other add is skipped (its own miss surfaces at apply)."""
544
+ dispatch = [(i, a) for i, a in enumerate(adds) if a.get("kind") in _DISPATCH_KINDS]
545
+ offs = {}
546
+ for i, a in dispatch:
547
+ try:
548
+ offs[i] = _switch_off(eb, a)
549
+ except LogicAddError:
550
+ offs[i] = None # the other add's own error surfaces at apply
551
+ for i, a in dispatch:
552
+ if a.get("kind") != "menu_row" or offs[i] is None:
553
+ continue
554
+ for j, _b in dispatch:
555
+ if j != i and offs[j] == offs[i]:
556
+ raise LogicAddError(f"menu_row at index {i} shares its switch (entry{_int(a, 'entry')}/"
557
+ f"tag{_int(a, 'tag')}) with another dispatch add (add_case/menu_row) at index "
558
+ f"{j} -- they would mis-align the row index. Use ONE menu_row per switch, or "
559
+ "split across fields.")
560
+
561
+
562
+ def menu_row_text_plan(eb_bytes, adds):
563
+ """For each ``menu_row`` add (in normalized order): ``(idx, menu_txid, label, new_row)`` -- the data the
564
+ build needs to splice the row LABEL into the donor ``.mes``. ``new_row`` is read from the ORIGINAL switch
565
+ (``eb_bytes`` = the pre-``logic_add`` bytes), so it matches the dispatch arm :func:`apply_logic_adds` adds.
566
+ Empty -> no ``.mes`` work. :func:`_assert_menu_row_switches` enforces ONE dispatch add per switch (a second
567
+ would mis-align the row index). Raises a clean :class:`LogicAddError` on a non-canonical menu."""
568
+ adds = _normalize_adds(adds)
569
+ if not any(a.get("kind") == "menu_row" for a in adds):
570
+ return []
571
+ eb = EbScript.from_bytes(eb_bytes)
572
+ _assert_menu_row_switches(eb, adds)
573
+ out = []
574
+ for idx, add in enumerate(adds):
575
+ if add.get("kind") != "menu_row":
576
+ continue
577
+ label = add.get("label")
578
+ if not isinstance(label, str) or not label:
579
+ raise LogicAddError("menu_row needs a non-empty `label` (the new row's menu text)")
580
+ if "\n" in label:
581
+ raise LogicAddError(f"menu_row label may not contain a newline (it would inject a phantom row): {label!r}")
582
+ _f, _ins, _si, new_row = _menu_row_switch(eb, add)
583
+ out.append((idx, _int(add, "menu_txid"), label, new_row))
584
+ return out
585
+
586
+
587
+ def apply_menu_row_text(body, plan, lang, warnings=None):
588
+ r"""Leg C: splice each planned ``menu_row``'s ``\n[MOVE=18,0]<label>`` row into the menu's single ``.mes``
589
+ entry (the verbatim donor body), bumping a ``[PCHC]`` row count. A VERIFIED splice (every other entry stays
590
+ byte-identical, via :func:`logic_edit.verified_mes_splice`). Fails CLOSED on a ``[PCHM]`` mask-gated menu, a
591
+ missing/non-choice entry, an unsupported trailing window tag (only a final ``[IMME]`` is handled), or a row
592
+ count that doesn't match the dispatch (so the row index and case stay 1:1). ``plan`` =
593
+ :func:`menu_row_text_plan`; empty plan/body -> unchanged."""
594
+ if not plan or not body:
595
+ return body
596
+ from . import logic_edit as _le
597
+ from .dialogue import parse_mes
598
+ for (_idx, menu_txid, label, new_row) in plan:
599
+ ent = parse_mes(body).get(menu_txid)
600
+ if ent is None:
601
+ raise LogicAddError(f"menu_row: menu_txid {menu_txid} not found in the {lang} .mes")
602
+ text = ent.text
603
+ choo = text.find("[CHOO]")
604
+ if choo < 0:
605
+ raise LogicAddError(f"menu_row: menu_txid {menu_txid} is not a choice menu (no [CHOO] tag) in the "
606
+ f"{lang} .mes")
607
+ if "[PCHM=" in text: # mask-gated (any arity) -- v1 fails closed
608
+ raise LogicAddError(f"menu_row: menu_txid {menu_txid} is a [PCHM] mask-gated menu -- v1 targets "
609
+ "text-gated menus ([PCHC] / no pre-tag); a mask-gated row is a follow-up")
610
+ after = text[choo + len("[CHOO]"):] # the row list (then maybe a trailing tag run)
611
+ tail = _TRAILING_TAGS_RE.search(after)
612
+ tail_str = tail.group(0) if tail else ""
613
+ if tail_str and tail_str != "[IMME]": # an unknown trailing window tag -> don't corrupt it
614
+ raise LogicAddError(f"menu_row: menu_txid {menu_txid} ends with an unsupported trailing tag "
615
+ f"{tail_str!r} ({lang} .mes) -- v1 handles plain rows (+ a final [IMME])")
616
+ rows = after[:len(after) - len(tail_str)].split("\n") # the row segments (excluding the trailing tag run)
617
+ if len(rows) != new_row:
618
+ raise LogicAddError(f"menu_row: menu_txid {menu_txid} has {len(rows)} row(s) but the dispatch expects "
619
+ f"the new row at index {new_row} ({lang} .mes) -- the menu's rows and its "
620
+ "GetChoose switch aren't 1:1 (donor drift / not a single-line canonical menu)")
621
+ seg = "\n" + CHOICE_INDENT + label
622
+ new_text = (text[:len(text) - len(tail_str)] + seg + tail_str) if tail_str else (text + seg)
623
+ cm = _PCHC_RE.search(text)
624
+ if cm and cm.start() < choo: # a [PCHC] BEFORE the rows: bump count, keep the rest
625
+ new_text = (new_text[:cm.start()] + f"[PCHC={int(cm.group(1)) + 1},{cm.group(2)}{cm.group(3)}]"
626
+ + new_text[cm.end():])
627
+ elif warnings is not None: # no pre-tag: CANCEL (B) = the (new) last row
628
+ warnings.append(f"menu_row: menu_txid {menu_txid} has no pre-tag, so CANCEL (B) returns the LAST row "
629
+ "-- the new row is now last, so B will trigger it. Add a [PCHC] cancel row if that's "
630
+ "unwanted.")
631
+ body = _le.verified_mes_splice(body, menu_txid, new_text, lang=lang, err=LogicAddError)
632
+ return body