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,728 @@
1
+ """Phase-2: in-place, length-preserving, GUARDED value edits on a verbatim fork's ``.eb`` (+ ``.mes`` text).
2
+
3
+ The write-side sibling of :mod:`ff9mapkit.logic_map` (read) and :mod:`ff9mapkit.eblint` (validate): a verbatim
4
+ fork ships the donor's whole compiled ``.eb``, and this lets you EDIT it IN PLACE -- change an item reward, a gil
5
+ amount, a ``Field()`` warp destination, a story-flag index, a dialogue txid, or rewrite a dialogue STRING -- WITHOUT
6
+ regenerating or splicing the script (that's Phase 4). Every ``.eb`` edit is strictly LENGTH-PRESERVING (a same-width
7
+ operand overwrite), so the entry table / fpos never move; the composed ``.eb`` is re-validated by the Phase-3 linter
8
+ (:func:`ff9mapkit.eblint.lint_eb`) before the build ships it. Each edit is **old-guarded**: it locates its site by
9
+ ``entry``/``tag``/``op`` AND the current value, and REFUSES (a clean ``LogicEditError`` -> build failure, never a
10
+ silent mis-patch) if the donor bytes drifted. Authored declaratively as ``[[logic_edit]]`` in the member field.toml;
11
+ applied in build's verbatim pass (CLAUDE.md / docs/FORK_FIDELITY.md). Empty list -> byte-identical no-op.
12
+
13
+ v1 kinds: ``field`` (0x2B dest) · ``item`` (0x48 id/count) · ``gil`` (0xCE amount) · ``txid`` (a Window op's text id)
14
+ · ``flag_index`` (the GLOB ``C4``/``E4`` index inside an 0x05 expression -- a same-width remap is an in-place
15
+ operand swap; a remap that CROSSES the 0xFF C4/E4 token boundary is length-changing and rebuilt via the Phase-4b
16
+ keystone) · ``switch_case`` (REDIRECT one case/default arm of a jump table 0x06/0x0B/0x0D to a different
17
+ in-function target -- re-wire which branch a dialogue-menu row / ATE / scenario value triggers; keystone rebuild,
18
+ length-neutral) · ``text`` (a ``.mes`` dialogue-string rewrite, targets the per-language ``.mes``, not the
19
+ ``.eb``). Deferred: ADDING a switch case (a new menu row / dispatch arm -- length-changing, a logic_add follow-up).
20
+
21
+ The ``.eb`` bytecode is language-identical (only the 84-byte name differs), so one edit set patches all 7 langs.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ import struct
27
+ from dataclasses import dataclass, field as _dc_field
28
+
29
+ from .eb import disasm
30
+ from .eb.model import EbScript
31
+ from .content.object import _arg_byte_offset
32
+
33
+ ITEM_OP = 0x48 # AddItem(item_id:u16, count:u8)
34
+ GIL_OP = 0xCE # AddGil(amount:u24)
35
+ FIELD_OP = 0x2B # Field(dest:u16)
36
+ EXPR_OP = 0x05 # an expression statement (a GLOB flag read/write rides here)
37
+ SETTEXTVAR_OP = 0x66 # SetTextVariable(slot:u8, value:u16) -- feeds the "Received <item>!" DISPLAY id
38
+ WINDOW_OPS = {0x1F: 2, 0x20: 2, 0x95: 3, 0x96: 3} # Window op -> its txid operand index (dialogue.WINDOW_OPS)
39
+ _ITEM_OPERAND = {"id": 0, "count": 1}
40
+ _EB_KINDS = ("field", "item", "gil", "txid", "flag_index", "operand", "item_display", "item_count", "switch_case")
41
+ _SWITCH_OPS = (0x06, 0x0B, 0x0D) # JMP_SWITCHEX (explicit) / JMP_SWITCH (contiguous) / 2-byte-count variant
42
+ _TAIL_RE = re.compile(r"\[TAIL=([^\]]*)\]")
43
+
44
+
45
+ class LogicEditError(ValueError):
46
+ """A logic-edit that can't be applied safely (bad address, drifted donor, overflow, unsupported) -- it
47
+ fails the BUILD, never silently mis-patches the shipped script."""
48
+
49
+
50
+ def _req(ed, key):
51
+ if key not in ed:
52
+ raise LogicEditError(f"logic_edit ({ed.get('kind', '?')}) missing required key '{key}'")
53
+ return ed[key]
54
+
55
+
56
+ def _int(ed, key, *, optional=False):
57
+ """Require ``key`` be a plain int (TOML floats/strings/bools are author mistakes -> a clean LogicEditError,
58
+ not a raw TypeError). ``optional`` returns None when the key is absent."""
59
+ if optional and key not in ed:
60
+ return None
61
+ v = _req(ed, key)
62
+ if isinstance(v, bool) or not isinstance(v, int):
63
+ raise LogicEditError(f"logic_edit ({ed.get('kind', '?')}) key '{key}' must be an integer, "
64
+ f"got {type(v).__name__} ({v!r})")
65
+ return v
66
+
67
+
68
+ def _func(eb, ed):
69
+ entry, tag = _int(ed, "entry"), _int(ed, "tag")
70
+ if not (0 <= entry < eb.entry_count):
71
+ raise LogicEditError(f"logic_edit entry {entry} out of range (0..{eb.entry_count - 1})")
72
+ e = eb.entry(entry)
73
+ if e.empty:
74
+ raise LogicEditError(f"logic_edit entry {entry} is an empty slot")
75
+ f = e.func_by_tag(tag)
76
+ if f is None:
77
+ raise LogicEditError(f"logic_edit entry {entry} has no function tag {tag}")
78
+ return f
79
+
80
+
81
+ def _pick(hits, ed, what):
82
+ """Choose among instrs already filtered to match the old value: exactly one, or ``nth`` to disambiguate."""
83
+ if not hits:
84
+ raise LogicEditError(f"logic_edit found no {what} (the donor drifted or the address is wrong)")
85
+ if len(hits) == 1:
86
+ return hits[0]
87
+ nth = _int(ed, "nth", optional=True)
88
+ if nth is None:
89
+ raise LogicEditError(f"logic_edit is ambiguous: {len(hits)} {what} -- add `nth` (0..{len(hits) - 1})")
90
+ if not (0 <= nth < len(hits)):
91
+ raise LogicEditError(f"logic_edit nth={nth} out of range (0..{len(hits) - 1}) for {what}")
92
+ return hits[nth]
93
+
94
+
95
+ def _guarded_write(buf, abs_off, w, old, new):
96
+ """Overwrite ``w`` bytes at ``abs_off`` IN PLACE, asserting the current bytes encode ``old`` first (a real
97
+ guard that also catches an offset miscalculation). ``new`` must fit ``w`` bytes."""
98
+ if not (0 <= new < (1 << (8 * w))):
99
+ raise LogicEditError(f"logic_edit new value {new} doesn't fit a {w}-byte operand")
100
+ expect = old.to_bytes(w, "little")
101
+ cur = bytes(buf[abs_off:abs_off + w])
102
+ if cur != expect:
103
+ raise LogicEditError(f"logic_edit guard @{abs_off}: expected {expect.hex()} got {cur.hex()} "
104
+ "(donor drift or a bad address)")
105
+ buf[abs_off:abs_off + w] = new.to_bytes(w, "little")
106
+
107
+
108
+ def _operand_edit(eb, buf, ed, op, operand_index, *, extra=None, what=None):
109
+ """Locate an instr (op, current operand == old, + an optional ``extra(ins)`` guard) in entry/tag and
110
+ overwrite that operand same-width. ``extra`` lets a kind pin a SECOND operand (e.g. the item-display
111
+ text slot) so the value-filtered nth matches discovery -- without it, an unrelated same-value instr
112
+ could be mis-targeted."""
113
+ f = _func(eb, ed)
114
+ old, new = _int(ed, "old"), _int(ed, "new")
115
+ hits = [i for i in eb.instrs(f) if i.op == op and i.imm(operand_index) == old and (extra is None or extra(i))]
116
+ ins = _pick(hits, ed, what or (f"{disasm.op_name(op)} with operand[{operand_index}]=={old} in "
117
+ f"entry{_req(ed, 'entry')}/tag{_req(ed, 'tag')}"))
118
+ bo = _arg_byte_offset(ins, operand_index)
119
+ if bo is None:
120
+ raise LogicEditError(f"logic_edit cannot address {disasm.op_name(op)} operand {operand_index} "
121
+ "(a preceding operand is an expression)")
122
+ _guarded_write(buf, ins.off + bo, disasm.argsize(op, operand_index), old, new)
123
+
124
+
125
+ def _flag_width(idx: int) -> int:
126
+ """The GLOB var token's index width in BYTES: 1 for ``idx <= 0xFF`` (the C4 short token), 2 for the E4 long
127
+ token. The engine reads the token byte to know the width, so a remap crossing 0xFF changes BOTH the token
128
+ byte and its length -- a length-changing edit (the keystone rebuild), not an in-place same-width swap."""
129
+ return 1 if idx <= 0xFF else 2
130
+
131
+
132
+ def _flag_locate(eb, ed):
133
+ """Find the 0x05 expression instruction that reads/writes GLOB flag ``ed['flag']`` (disambiguated by
134
+ ``nth``); return ``(ins, idx, tok_len, old, new)``. Shared by the in-place and re-width flag paths so they
135
+ locate IDENTICALLY. Raises on an out-of-range target or a flag that isn't found (donor drift / bad addr)."""
136
+ from .eventscan import _glob_var_token
137
+ f = _func(eb, ed)
138
+ old, new = _int(ed, "flag"), _int(ed, "new_flag")
139
+ if not (0 <= new <= 0xFFFF):
140
+ raise LogicEditError(f"logic_edit flag remap target {new} out of range (0-65535)")
141
+ hits = []
142
+ for i in eb.instrs(f):
143
+ if i.op != EXPR_OP:
144
+ continue
145
+ tok = _glob_var_token(eb.data, i.off + 1) # the var token sits right after the 0x05
146
+ if tok is not None and tok[0] == old:
147
+ hits.append((i, tok))
148
+ ins, (idx, tok_len) = _pick(hits, ed, f"GLOB flag {old} read/write in "
149
+ f"entry{_req(ed, 'entry')}/tag{_req(ed, 'tag')}")
150
+ return ins, idx, tok_len, old, new
151
+
152
+
153
+ def _flag_edit(eb, buf, ed):
154
+ """In-place (length-preserving) GLOB story-flag index remap inside an 0x05 expression -- ONLY when the new
155
+ index stays in the SAME C4/E4 width class. A cross-0xFF remap is length-changing and routed to
156
+ :func:`_flag_rewidth` by :func:`apply_logic_edits`; this raises if one somehow reaches the in-place path."""
157
+ ins, idx, tok_len, old, new = _flag_locate(eb, ed)
158
+ if _flag_width(new) != (tok_len - 1): # C4 -> 1-byte index, E4 -> 2-byte index
159
+ raise LogicEditError(f"internal: cross-0xFF flag remap {old}->{new} must use the re-width pass")
160
+ _guarded_write(buf, ins.off + 2, tok_len - 1, old, new) # 0x05 at off, token byte at off+1, index at off+2
161
+
162
+
163
+ def _flag_rewidth(eb_bytes, ed, anchor_rel, old, new) -> bytes:
164
+ """A cross-0xFF GLOB flag remap (length-CHANGING: the C4 short token <-> the E4 long token), applied at the
165
+ EXACT instruction located during the split (``anchor_rel`` is its function-relative offset, captured before
166
+ the in-place pass and adjusted for any prior same-function rebuild). Rewrite that 0x05 expression's
167
+ ``Global.Bit[old]`` token to ``Global.Bit[new]`` in the disassembled function SOURCE and reassemble via the
168
+ keystone (``exprasm`` picks the new index's natural width; ``cmdasm`` relocates every jump/switch past the
169
+ length change), then swap the rebuilt body in. Old-guarded: it asserts the byte at ``anchor_rel`` is still a
170
+ 0x05 reading ``old`` (so conflicting edits that drifted the site fail cleanly, never silently mis-patch)."""
171
+ from .eb import cmdasm as _cmdasm
172
+ from .eb import edit as _edit
173
+ from .eb import exprasm as _exprasm
174
+ from .eb._exprtable import decode_var
175
+ from .eventscan import _glob_var_token, GLOB_BOOL_SHORT, GLOB_BOOL_LONG
176
+ eb = EbScript.from_bytes(eb_bytes)
177
+ f = _func(eb, ed)
178
+ abs_off = f.abs_start + anchor_rel
179
+ tok = _glob_var_token(eb.data, abs_off + 1) if (0 <= abs_off < len(eb.data) and eb.data[abs_off] == EXPR_OP) else None
180
+ if tok is None or tok[0] != old: # the captured site no longer holds the old flag
181
+ raise LogicEditError(f"logic_edit flag remap {old}->{new}: the target 0x05 expression in "
182
+ f"entry{_int(ed, 'entry')}/tag{_int(ed, 'tag')} drifted (conflicting edits?)")
183
+ old_tok = decode_var(GLOB_BOOL_SHORT if old <= 0xFF else GLOB_BOOL_LONG, old) # "Global.Bit[old]"
184
+ new_tok = decode_var(GLOB_BOOL_SHORT if new <= 0xFF else GLOB_BOOL_LONG, new) # "Global.Bit[new]"
185
+ try:
186
+ items = _cmdasm.disassemble_items(eb.data, f.abs_start, f.abs_end)
187
+ line_idx = next((k for k, (off, _t) in enumerate(items) if off == anchor_rel), None)
188
+ if line_idx is None: # the guarded instr is decoded -> always present
189
+ raise LogicEditError("logic_edit flag remap: could not locate the expression instruction (internal)")
190
+ _off, text = items[line_idx]
191
+ if old_tok not in text: # second guard: the decoded expr must show the old flag
192
+ raise LogicEditError(f"logic_edit flag remap: {old_tok} not in the decoded expression ({text})")
193
+ texts = [t for _o, t in items]
194
+ texts[line_idx] = text.replace(old_tok, new_tok)
195
+ new_body = _cmdasm.assemble_block("\n".join(texts))
196
+ except (_cmdasm.CmdAsmError, _exprasm.AssembleError) as ex: # normalize the rebuild failure (clean build error)
197
+ raise LogicEditError(f"logic_edit flag remap {old}->{new}: could not rebuild "
198
+ f"entry{_int(ed, 'entry')}/tag{_int(ed, 'tag')}: {ex}")
199
+ return _edit.replace_function_body(eb_bytes, _int(ed, "entry"), _int(ed, "tag"), new_body)
200
+
201
+
202
+ # --- switch_case: REDIRECT a jump-table arm (0x06/0x0B/0x0D) to a different in-function target -------------
203
+ def _switch_case_key(ed):
204
+ """The selector an edit targets: an int case VALUE, or the string ``"default"``."""
205
+ case = ed.get("case")
206
+ if case == "default":
207
+ return "default"
208
+ if isinstance(case, bool) or not isinstance(case, int):
209
+ raise LogicEditError('logic_edit switch_case needs `case` = an integer selector value or "default"')
210
+ return case
211
+
212
+
213
+ def _switch_locate(eb, ed):
214
+ """Find the switch instruction + the edge for ``case`` in entry/tag; return ``(ins, anchor_rel, case,
215
+ old_target)``. Disambiguate among multiple switches with ``nth``. Guard: the selected edge currently
216
+ resolves to ``ed['old_target']`` (a function-relative offset) -- a wrong old_target / drifted donor fails."""
217
+ f = _func(eb, ed)
218
+ case = _switch_case_key(ed)
219
+ old_target = _int(ed, "old_target")
220
+ switches = [i for i in eb.instrs(f) if i.op in _SWITCH_OPS]
221
+ if not switches:
222
+ raise LogicEditError(f"logic_edit switch_case: no switch (0x06/0x0B/0x0D) in "
223
+ f"entry{_req(ed, 'entry')}/tag{_req(ed, 'tag')}")
224
+ ins = _pick(switches, ed, f"switch instruction in entry{_req(ed, 'entry')}/tag{_req(ed, 'tag')}")
225
+ si = disasm.decode_switch(ins)
226
+ if si is None: # a switch whose operands aren't plain immediates
227
+ raise LogicEditError(f"logic_edit switch_case: could not decode the switch in "
228
+ f"entry{_req(ed, 'entry')}/tag{_req(ed, 'tag')} (computed operands?)")
229
+ if case == "default":
230
+ edge = next((e for e in si.edges if e.is_default), None)
231
+ else:
232
+ edge = next((e for e in si.edges if not e.is_default and e.value == case), None)
233
+ if edge is None:
234
+ raise LogicEditError(f"logic_edit switch_case: no case {case} in the switch "
235
+ f"(values: {[e.value for e in si.edges if not e.is_default]})")
236
+ cur = edge.target - f.abs_start
237
+ if cur != old_target:
238
+ raise LogicEditError(f"logic_edit switch_case guard: case {case} currently targets {cur}, not "
239
+ f"old_target {old_target} (donor drift or wrong old_target)")
240
+ return ins, ins.off - f.abs_start, case, old_target
241
+
242
+
243
+ def _switch_operand_index(op, ops, case):
244
+ """The index in the cmdasm SWITCH/SWITCHEX operand list of the LABEL operand for ``case``.
245
+ 0x0B/0x0D: ``[base, default, case0, case1, ...]`` (case i is selector base+i). 0x06: ``[default, val0,
246
+ lbl0, val1, lbl1, ...]`` (explicit values)."""
247
+ if op in (0x0B, 0x0D): # SWITCH(base, default, case0, case1, ...)
248
+ if case == "default":
249
+ return 1
250
+ try:
251
+ base = disasm._sx_hi(int(ops[0]) & 0xFFFF) # cmdasm re-emits the base as RAW u16: a negative
252
+ except (ValueError, IndexError): # base (-1) shows as "65535" -> sign-decode it
253
+ raise LogicEditError("logic_edit switch_case: malformed SWITCH operands (internal)")
254
+ i = case - base
255
+ if not (0 <= i < len(ops) - 2):
256
+ raise LogicEditError(f"logic_edit switch_case: selector {case} is outside the contiguous range "
257
+ f"{base}..{base + len(ops) - 3} of this SWITCH (only those cases or "
258
+ '"default" are redirectable; an arbitrary value needs a 0x06 SWITCHEX)')
259
+ return 2 + i
260
+ if case == "default": # 0x06 SWITCHEX(default, val0, lbl0, ...)
261
+ return 0
262
+ for k in range(1, len(ops) - 1, 2):
263
+ try:
264
+ if int(ops[k]) == case:
265
+ return k + 1
266
+ except ValueError:
267
+ continue
268
+ raise LogicEditError(f"logic_edit switch_case: no explicit case value {case} in this SWITCHEX")
269
+
270
+
271
+ def _switch_redirect(eb_bytes, ed, anchor_rel, case, old_target, new_target) -> bytes:
272
+ """Redirect a switch ``case``/default arm to a different in-function instruction boundary, applied at the
273
+ EXACT switch located during the split. Swap that one arm's ``L<old_target>`` label for ``L<new_target>`` in
274
+ the disassembled source (injecting ``L<new_target>:`` if the boundary isn't already a branch target -- a
275
+ label is zero bytes, so this stays length-NEUTRAL) and reassemble via the keystone (``cmdasm`` re-anchors
276
+ every reloff; it RAISES on a backward / >u16 reloff -> normalized here). Old-guarded: the byte at
277
+ ``anchor_rel`` is still a switch of the same op AND the arm's operand is still ``L<old_target>``."""
278
+ from .eb import cmdasm as _cmdasm
279
+ from .eb import edit as _edit
280
+ from .eb import exprasm as _exprasm
281
+ eb = EbScript.from_bytes(eb_bytes)
282
+ f = _func(eb, ed)
283
+ abs_off = f.abs_start + anchor_rel
284
+ op = eb.data[abs_off] if (0 <= abs_off < len(eb.data)) else None
285
+ if op not in _SWITCH_OPS: # the captured site no longer holds a switch
286
+ raise LogicEditError(f"logic_edit switch_case: the switch in entry{_int(ed, 'entry')}/"
287
+ f"tag{_int(ed, 'tag')} drifted (conflicting edits?)")
288
+ old_label, new_label = f"L{old_target}", f"L{new_target}"
289
+ try:
290
+ items = _cmdasm.disassemble_items(eb.data, f.abs_start, f.abs_end)
291
+ line_idx = next((k for k, (off, _t) in enumerate(items) if off == anchor_rel), None)
292
+ if line_idx is None: # the guarded switch is decoded -> always present
293
+ raise LogicEditError("logic_edit switch_case: could not locate the switch (internal)")
294
+ texts = [t for _o, t in items]
295
+ line = texts[line_idx]
296
+ mnem = line[:line.index("(")]
297
+ ops = line[line.index("(") + 1:line.rindex(")")].split(", ")
298
+ op_idx = _switch_operand_index(op, ops, case)
299
+ if ops[op_idx] != old_label: # second guard: the arm still points at old_target
300
+ raise LogicEditError(f"logic_edit switch_case guard: the case {case} arm is {ops[op_idx]}, not "
301
+ f"{old_label} (donor drift)")
302
+ if not any(o is None and t.strip() == new_label + ":" for o, t in items):
303
+ tgt_idx = next((k for k, (off, _t) in enumerate(items) if off == new_target), None)
304
+ if tgt_idx is None: # new_target must be a real instruction boundary
305
+ raise LogicEditError(f"logic_edit switch_case: new_target {new_target} is not an instruction "
306
+ f"boundary in entry{_int(ed, 'entry')}/tag{_int(ed, 'tag')}")
307
+ texts.insert(tgt_idx, new_label + ":") # label a bare boundary (zero bytes, length-neutral)
308
+ if tgt_idx <= line_idx:
309
+ line_idx += 1
310
+ ops[op_idx] = new_label
311
+ texts[line_idx] = mnem + "(" + ", ".join(ops) + ")"
312
+ new_body = _cmdasm.assemble_block("\n".join(texts))
313
+ except (_cmdasm.CmdAsmError, _exprasm.AssembleError) as ex:
314
+ raise LogicEditError(f"logic_edit switch_case {old_target}->{new_target}: could not rebuild "
315
+ f"entry{_int(ed, 'entry')}/tag{_int(ed, 'tag')}: {ex}")
316
+ return _edit.replace_function_body(eb_bytes, _int(ed, "entry"), _int(ed, "tag"), new_body)
317
+
318
+
319
+ def _apply_eb_edit(eb, buf, ed):
320
+ kind = _req(ed, "kind")
321
+ if kind == "field":
322
+ _operand_edit(eb, buf, ed, FIELD_OP, 0)
323
+ elif kind == "item":
324
+ _operand_edit(eb, buf, ed, ITEM_OP, _ITEM_OPERAND.get(ed.get("operand", "id"), 0))
325
+ elif kind == "gil":
326
+ _operand_edit(eb, buf, ed, GIL_OP, 0)
327
+ elif kind == "txid":
328
+ op = _int(ed, "op")
329
+ if op not in WINDOW_OPS:
330
+ raise LogicEditError(f"logic_edit txid op {op:#x} is not a Window op {sorted(WINDOW_OPS)}")
331
+ _operand_edit(eb, buf, ed, op, WINDOW_OPS[op])
332
+ elif kind == "flag_index":
333
+ _flag_edit(eb, buf, ed)
334
+ elif kind == "operand": # generic escape hatch: patch literal operand
335
+ _operand_edit(eb, buf, ed, _int(ed, "op"), _int(ed, "operand")) # `operand` of any op (caller owns
336
+ # the choice of op/operand/nth -- e.g. a hand-authored display patch; no slot guard)
337
+ elif kind == "item_display": # the "Received <item>!" DISPLAY half of a reward:
338
+ slot = _int(ed, "slot", optional=True) # SetTextVariable(slot, item_id). FF9's item-get
339
+ slot = 0 if slot is None else slot # display is slot 0; pin it so a same-value
340
+ old = _int(ed, "old") # SetTextVariable in another slot isn't corrupted.
341
+ _operand_edit(eb, buf, ed, SETTEXTVAR_OP, 1, extra=lambda i: i.imm(0) == slot,
342
+ what=f"SetTextVariable(slot={slot}, id={old}) display in "
343
+ f"entry{_req(ed, 'entry')}/tag{_req(ed, 'tag')}")
344
+ elif kind == "item_count": # the QUANTITY operand of a specific AddItem(id):
345
+ iid = _int(ed, "item_id") # pin the item id so a same-count AddItem of a
346
+ old = _int(ed, "old") # DIFFERENT item isn't retargeted.
347
+ _operand_edit(eb, buf, ed, ITEM_OP, _ITEM_OPERAND["count"], extra=lambda i: i.imm(0) == iid,
348
+ what=f"AddItem(id={iid}) count=={old} in "
349
+ f"entry{_req(ed, 'entry')}/tag{_req(ed, 'tag')}")
350
+ else:
351
+ raise LogicEditError(f"logic_edit unknown .eb kind '{kind}' (kinds: {_EB_KINDS} + 'text')")
352
+
353
+
354
+ def _edit_list(edits):
355
+ """Validate the ``[[logic_edit]]`` container is an array of tables (so ``[logic_edit]`` -- a single TOML
356
+ table -- or junk is a clean :class:`LogicEditError`, not a raw ``AttributeError`` from ``.get`` on a key)
357
+ and return its non-empty entries."""
358
+ if edits is None:
359
+ return []
360
+ if not isinstance(edits, (list, tuple)):
361
+ raise LogicEditError("logic_edit must be an array of tables ([[logic_edit]]), not "
362
+ f"{type(edits).__name__} (you likely wrote [logic_edit] instead of [[logic_edit]])")
363
+ out = [e for e in edits if e]
364
+ for e in out:
365
+ if not isinstance(e, dict):
366
+ raise LogicEditError(f"each logic_edit must be a table, got {type(e).__name__}")
367
+ return out
368
+
369
+
370
+ def apply_logic_edits(eb_bytes, edits) -> bytes:
371
+ """Apply every NON-text ``[[logic_edit]]`` to ``eb_bytes`` and return the patched bytes. Empty / text-only ->
372
+ byte-identical. Most edits are length-preserving in-place operand swaps (old-guarded); two kinds need the
373
+ keystone REBUILD instead -- a ``flag_index`` remap that CROSSES the 0xFF C4/E4 boundary (length-changing) and
374
+ a ``switch_case`` redirect (length-neutral but the reloff is computed, not a literal). The rebuilds run in a
375
+ SECOND pass AFTER the in-place edits (so the in-place edits' donor-based offsets aren't shifted first), each
376
+ located by the EXACT function-relative offset captured here, delta-adjusted for prior same-function rebuilds.
377
+ Raises :class:`LogicEditError` on any unsafe edit."""
378
+ eb_edits = [e for e in _edit_list(edits) if e.get("kind") != "text"]
379
+ if not eb_edits:
380
+ return bytes(eb_bytes)
381
+ eb = EbScript.from_bytes(eb_bytes)
382
+ inplace, rebuilds = [], [] # in-place operand swaps vs keystone rebuilds
383
+ for ed in eb_edits:
384
+ kind = ed.get("kind")
385
+ if kind == "flag_index":
386
+ ins, _idx, tok_len, old, new = _flag_locate(eb, ed)
387
+ if _flag_width(new) != (tok_len - 1): # crosses 0xFF -> keystone rebuild (length-changing)
388
+ rebuilds.append(("flag", ed, (_int(ed, "entry"), _int(ed, "tag")),
389
+ ins.off - _func(eb, ed).abs_start, _flag_width(new) - _flag_width(old),
390
+ (old, new)))
391
+ continue
392
+ elif kind == "switch_case": # redirect a switch arm (keystone, length-neutral)
393
+ ins, anchor_rel, case, old_target = _switch_locate(eb, ed)
394
+ rebuilds.append(("switch", ed, (_int(ed, "entry"), _int(ed, "tag")), anchor_rel, 0,
395
+ (case, old_target, _int(ed, "new_target"))))
396
+ continue
397
+ inplace.append(ed)
398
+ buf = bytearray(eb_bytes)
399
+ for ed in inplace: # length-preserving: instruction offsets stay put
400
+ _apply_eb_edit(eb, buf, ed)
401
+ out = bytes(buf)
402
+ # keystone rebuilds: locate each by its captured function-relative offset (stable through the in-place pass),
403
+ # adjusted for the byte delta of prior same-function rebuilds (ascending offset) so multiple rebuilds compose.
404
+ # Ties each rebuild to the EXACT instruction the split saw -- it can't drift onto a different same-flag instr.
405
+ deltas: dict = {}
406
+ for rkind, ed, key, anchor_rel, byte_delta, payload in sorted(rebuilds, key=lambda r: (r[2], r[3])):
407
+ dk = deltas.get(key, 0) # cumulative byte shift from prior same-function rebuilds
408
+ eff = anchor_rel + dk
409
+ if rkind == "flag":
410
+ out = _flag_rewidth(out, ed, eff, *payload)
411
+ else: # a switch's case targets are all FORWARD of it, so
412
+ case, old_t, new_t = payload # they shifted by dk too -- relocate them, not just
413
+ out = _switch_redirect(out, ed, eff, case, old_t + dk, new_t + dk) # the anchor (the composition fix)
414
+ deltas[key] = dk + byte_delta
415
+ return out
416
+
417
+
418
+ # --- .mes dialogue-string rewrite (kind="text") -- a verified in-place splice, per language ----------
419
+ def _splice_block(part: str, new_text: str) -> str:
420
+ """Replace the text payload of one ``[STRT=...]...[ENDN]`` block (a ``[STRT=``-split segment), preserving
421
+ its STRT geometry, optional [TAIL], the [ENDN], and any trailing bytes before the next entry."""
422
+ b = part.find("]")
423
+ if b < 0:
424
+ raise LogicEditError("malformed .mes entry (no STRT close)")
425
+ rest = part[b + 1:]
426
+ mt = _TAIL_RE.match(rest)
427
+ tail_str = mt.group(0) if mt else ""
428
+ endn = part.find("[ENDN]", b + 1 + len(tail_str))
429
+ if endn < 0:
430
+ raise LogicEditError("malformed .mes entry (no [ENDN])")
431
+ return part[:b + 1] + tail_str + new_text + part[endn:]
432
+
433
+
434
+ def verified_mes_splice(body: str, txid: int, new_text: str, *, lang: str, err=None) -> str:
435
+ """Replace ONLY ``txid``'s text payload with ``new_text`` in an index-implicit ``.mes`` body, then re-parse
436
+ and assert every OTHER entry is byte-identical -- a botched splice fails the build, not the player. Shared by
437
+ the dialogue-string rewrite (``kind="text"``) and the ``logic_add`` ``menu_row`` row-label splice. ``err`` is
438
+ the exception class to raise (defaults to :class:`LogicEditError`, so a ``menu_row`` caller can pass its own
439
+ ``LogicAddError``). v1 supports the verbatim donor body (no ``[TXID=]`` re-index markers)."""
440
+ from .dialogue import parse_mes
441
+ err = err or LogicEditError
442
+ if "[TXID=" in body:
443
+ raise err("a .mes splice on a [TXID=]-reindexed body is not supported (Phase 2b)")
444
+ before = parse_mes(body)
445
+ if txid not in before:
446
+ raise err(f"txid {txid} not found in the {lang} .mes")
447
+ parts = body.split("[STRT=")
448
+ if not (0 <= txid + 1 < len(parts)): # index-implicit: txid == position == part index-1
449
+ raise err(f"txid {txid} out of range in the {lang} .mes")
450
+ try:
451
+ parts[txid + 1] = _splice_block(parts[txid + 1], new_text)
452
+ except LogicEditError as ex: # _splice_block speaks LogicEditError -> normalize to err
453
+ raise err(str(ex))
454
+ spliced = "[STRT=".join(parts)
455
+ after = parse_mes(spliced) # VERIFY: only the target entry changed
456
+ if len(after) != len(before):
457
+ raise err(f"the .mes splice changed the entry count ({lang})")
458
+ for t, e in before.items():
459
+ got = after.get(t)
460
+ if got is None:
461
+ raise err(f"the .mes splice dropped txid {t} ({lang})")
462
+ if t == txid:
463
+ if got.text != new_text:
464
+ raise err(f"the .mes splice didn't take for txid {txid} ({lang})")
465
+ elif (got.text, got.strt, got.tail) != (e.text, e.strt, e.tail):
466
+ raise err(f"the .mes splice corrupted txid {t} ({lang})")
467
+ return spliced
468
+
469
+
470
+ def apply_logic_text_edits(body: str, edits, lang: str) -> str:
471
+ """Apply every ``kind="text"`` edit whose ``lang`` is unset or == ``lang`` to a ``.mes`` body, returning the
472
+ rewritten body. A VERIFIED in-place splice (:func:`verified_mes_splice`): it replaces one entry's text
473
+ payload, then re-parses and asserts every OTHER entry is byte-identical (so a botched splice fails the build,
474
+ not the player). v1 supports the index-implicit verbatim donor body (no ``[TXID=]`` re-index markers)."""
475
+ text_edits = [e for e in _edit_list(edits) if e.get("kind") == "text" and e.get("lang") in (None, lang)]
476
+ if not text_edits or not body:
477
+ return body
478
+ from .dialogue import parse_mes, strip_tags
479
+ if "[TXID=" in body:
480
+ raise LogicEditError("logic_edit text rewrite on a [TXID=]-reindexed .mes is not supported (Phase 2b)")
481
+ for ed in text_edits:
482
+ txid, old, new = _int(ed, "txid"), _req(ed, "old"), _req(ed, "new")
483
+ if not isinstance(old, str) or not isinstance(new, str):
484
+ raise LogicEditError(f"logic_edit text txid {txid}: 'old' and 'new' must be strings")
485
+ ent = parse_mes(body).get(txid)
486
+ if ent is None:
487
+ raise LogicEditError(f"logic_edit text: txid {txid} not found in the {lang} .mes")
488
+ if old not in (ent.text, strip_tags(ent.text)):
489
+ raise LogicEditError(f"logic_edit text txid {txid} ({lang}): current line != `old` (donor drifted)")
490
+ body = verified_mes_splice(body, txid, new, lang=lang)
491
+ return body
492
+
493
+
494
+ # --- discovery: the editable value-sites of one routine (the GUI authoring surface) -----------------
495
+ # The GUI (Workspace "Script (verbatim .eb)" subtree) can't ask the user to hand-write entry/tag/op/nth/old
496
+ # coordinates. So this walks ONE (entry, tag) routine the way the appliers' `_pick` filters do, and returns a
497
+ # legible EditSite per editable value, each carrying ready-to-fill [[logic_edit]] templates. Edit -> click ->
498
+ # pick a new value; :func:`synth_edits` splices it in; :func:`upsert_edits` merges into the field.toml list.
499
+ @dataclass
500
+ class EditSite:
501
+ """One editable value in a routine -- a row + 'Edit…' affordance in the GUI. ``templates`` are the
502
+ [[logic_edit]] dicts MINUS the new-value key (filled by :func:`synth_edits`). For an item reward the
503
+ AddItem give and the matched ``SetTextVariable`` 'Received <item>!' display are paired in
504
+ ``display_templates`` so ONE edit retargets both -- the give-vs-display decoupling: if only the give
505
+ changes, the message lies (the chest-says-Potion-gives-Elixir bug)."""
506
+ group: str # item | gil | field | flag | text (what's being edited)
507
+ value_kind: str # item | int | flag | string -> how the dialog renders/validates NEW
508
+ label: str # the row label shown in the panel
509
+ old: object # the donor's current value (int, or the us string for text)
510
+ new_key: str = "new" # the template key the NEW value goes under ("new", or "new_flag" for flag)
511
+ templates: list = _dc_field(default_factory=list) # the primary edits (the give / the value)
512
+ display_templates: list = _dc_field(default_factory=list) # item: the paired display edits
513
+ count_templates: list = _dc_field(default_factory=list) # item: the quantity (AddItem count) edits
514
+ count_old: object = None # item: the current quantity (None if it varies across give-paths -> not editable)
515
+ note: str = "" # an advisory (e.g. no display site found / count not shown)
516
+ key: str = "" # a stable id for this site within the routine (GUI row <-> its edits)
517
+
518
+
519
+ def _op_tmpl(kind, entry, tag, old, nth, total, *, op=None, operand=None):
520
+ """One length-preserving operand template (the new value spliced in later). ``nth`` is included only
521
+ when the value is ambiguous (``total`` > 1) -- mirroring what the appliers' ``_pick`` requires."""
522
+ t = {"kind": kind, "entry": int(entry), "tag": int(tag), "old": old}
523
+ if op is not None:
524
+ t["op"] = op
525
+ if operand is not None:
526
+ t["operand"] = operand
527
+ if total > 1:
528
+ t["nth"] = nth
529
+ return t
530
+
531
+
532
+ def _value_groups(instrs, op, operand_index):
533
+ """``{value: [nth, ...]}`` for every immediate ``operand_index`` of ``op`` -- the value-filtered nth
534
+ each occurrence gets (the exact index :func:`_pick` resolves), so an edit can target all or one."""
535
+ groups: dict = {}
536
+ for ins in instrs:
537
+ if ins.op != op:
538
+ continue
539
+ v = ins.imm(operand_index)
540
+ if v is None:
541
+ continue
542
+ groups.setdefault(v, []).append(len(groups.get(v, [])))
543
+ return groups
544
+
545
+
546
+ def _line_old(entries, txid):
547
+ """The donor's current line (tag-stripped) for ``txid``, or None (no ``.mes`` / not found)."""
548
+ if not entries:
549
+ return None
550
+ from .dialogue import strip_tags
551
+ ent = entries.get(int(txid))
552
+ return strip_tags(ent.text) if ent is not None else None
553
+
554
+
555
+ def _short(s, width=44):
556
+ s = " ".join(str(s).split())
557
+ return (s[:width] + "…") if len(s) > width else s
558
+
559
+
560
+ def _text_templates(txid, lang_bodies, fallback_old):
561
+ """A per-language ``text`` template (each guarded by THAT language's own current string) so a single
562
+ new string is written to every localized copy consistently. Skips a ``[TXID=]``-reindexed body (Phase
563
+ 4) and a language missing the txid. Falls back to one lang-agnostic template when no bodies are given."""
564
+ if not lang_bodies:
565
+ return [{"kind": "text", "txid": int(txid), "old": fallback_old}] if fallback_old is not None else []
566
+ from .dialogue import parse_mes, strip_tags
567
+ out = []
568
+ for lang, body in lang_bodies.items():
569
+ if not body or "[TXID=" in body:
570
+ continue
571
+ ent = parse_mes(body).get(int(txid))
572
+ if ent is None:
573
+ continue
574
+ out.append({"kind": "text", "lang": lang, "txid": int(txid), "old": strip_tags(ent.text)})
575
+ return out
576
+
577
+
578
+ def editable_effects(eb_bytes, entry, tag, *, entries=None, lang_bodies=None):
579
+ """Discover the editable value-sites of one ``(entry, tag)`` routine of a verbatim fork's ``.eb`` --
580
+ item rewards (give + paired display), gil grants, ``Field()`` warps, GLOB story-flag indices, and
581
+ dialogue lines -- each as an :class:`EditSite` the GUI authors a ``[[logic_edit]]`` from. Pure; never
582
+ mutates. ``entries`` = parsed us ``.mes`` (``{txid: MesEntry}``) for line text; ``lang_bodies`` =
583
+ ``{lang: raw .mes body}`` for per-language text-edit guards."""
584
+ eb = EbScript.from_bytes(eb_bytes)
585
+ if not (0 <= entry < eb.entry_count):
586
+ return []
587
+ e = eb.entry(entry)
588
+ if e.empty:
589
+ return []
590
+ f = e.func_by_tag(tag)
591
+ if f is None:
592
+ return []
593
+ from . import forkreport as FR
594
+ instrs = list(eb.instrs(f))
595
+ sites: list = []
596
+
597
+ # items: group AddItem by id (skipping the engine no-op grants the read map also hides); pair each with
598
+ # the same-id SetTextVariable in TEXT SLOT 0 (FF9's item-get display, build.set_text_variable(0, id)) so
599
+ # the reward + its "Received <item>!" message change together. A same-value SetTextVariable in another
600
+ # slot (e.g. a preview row) is NOT the item display and is left alone.
601
+ disp_groups: dict = {}
602
+ for ins in instrs:
603
+ if ins.op == SETTEXTVAR_OP and ins.imm(0) == 0:
604
+ v = ins.imm(1)
605
+ if v is not None:
606
+ disp_groups.setdefault(v, []).append(len(disp_groups.get(v, [])))
607
+ item_groups: dict = {}
608
+ for ins in instrs:
609
+ if ins.op != ITEM_OP:
610
+ continue
611
+ iid = ins.imm(0)
612
+ if iid is None or iid == FR.NO_ITEM or FR.item_inert(iid):
613
+ continue
614
+ item_groups.setdefault(iid, []).append(len(item_groups.get(iid, [])))
615
+ for iid, nths in item_groups.items():
616
+ give = [_op_tmpl("item", entry, tag, iid, n, len(nths), op=ITEM_OP, operand="id") for n in nths]
617
+ dn = disp_groups.get(iid, [])
618
+ disp = [{**_op_tmpl("item_display", entry, tag, iid, n, len(dn), op=SETTEXTVAR_OP, operand=1), "slot": 0}
619
+ for n in dn]
620
+ # quantity: editable only when every give-path of this item grants the SAME count (the usual case);
621
+ # if the counts vary, expose no count edit (the user can hand-author per-path) and note it.
622
+ counts = [ins.imm(1) for ins in instrs if ins.op == ITEM_OP and ins.imm(0) == iid]
623
+ uniform = bool(counts) and counts[0] is not None and len(set(counts)) == 1
624
+ count_old = counts[0] if uniform else None
625
+ cnt = ([{**_op_tmpl("item_count", entry, tag, count_old, n, len(nths), op=ITEM_OP, operand=1),
626
+ "item_id": int(iid)} for n in nths] if uniform else [])
627
+ qty = f" ×{count_old}" if count_old is not None else ""
628
+ paths = f" ({len(nths)} give-paths)" if len(nths) > 1 else ""
629
+ label = f"gives {FR.item_label(iid)}{qty}{paths}"
630
+ note = "" if disp else "no 'Received <item>!' display message paired — only the give changes"
631
+ if not uniform and len(counts) > 1:
632
+ note = (note + " " if note else "") + "quantity varies across give-paths — not editable here"
633
+ sites.append(EditSite("item", "item", label, int(iid), "new", give, disp, cnt, count_old,
634
+ note, f"item:{iid}"))
635
+
636
+ # gil grants (skip the > party-cap sentinel amounts -- a scripted/computed AddGil, not a treasure reward)
637
+ for amt, nths in _value_groups(instrs, GIL_OP, 0).items():
638
+ if amt > FR.GIL_CAP:
639
+ continue
640
+ tmpls = [_op_tmpl("gil", entry, tag, amt, n, len(nths), op=GIL_OP) for n in nths]
641
+ sites.append(EditSite("gil", "int", f"gives {amt} gil", int(amt), "new", tmpls, key=f"gil:{amt}"))
642
+
643
+ # Field() warps (an alternative to the [verbatim_eb] retarget table -- per-site, not by global dest)
644
+ for dest, nths in _value_groups(instrs, FIELD_OP, 0).items():
645
+ tmpls = [_op_tmpl("field", entry, tag, dest, n, len(nths), op=FIELD_OP) for n in nths]
646
+ sites.append(EditSite("field", "field", f"warps to field {dest}", int(dest), "new", tmpls,
647
+ key=f"field:{dest}"))
648
+
649
+ # GLOB story-flag indices (read + write of one index remap together so they stay in sync)
650
+ from .eventscan import _glob_var_token
651
+ flag_groups: dict = {}
652
+ for ins in instrs:
653
+ if ins.op != EXPR_OP:
654
+ continue
655
+ tok = _glob_var_token(eb.data, ins.off + 1)
656
+ if tok is not None:
657
+ flag_groups.setdefault(tok[0], []).append(len(flag_groups.get(tok[0], [])))
658
+ for idx, nths in flag_groups.items():
659
+ tmpls = [{"kind": "flag_index", "entry": int(entry), "tag": int(tag), "flag": int(idx),
660
+ **({"nth": n} if len(nths) > 1 else {})} for n in nths]
661
+ n = len(nths)
662
+ sites.append(EditSite("flag", "flag", f"story flag {idx}" + (f" (×{n})" if n > 1 else ""),
663
+ int(idx), "new_flag", tmpls, key=f"flag:{idx}"))
664
+
665
+ # dialogue lines (one site per distinct Window-op txid that resolves to a line)
666
+ seen_txid: set = set()
667
+ for ins in instrs:
668
+ if ins.op not in WINDOW_OPS:
669
+ continue
670
+ txid = ins.imm(WINDOW_OPS[ins.op])
671
+ if txid is None or txid in seen_txid:
672
+ continue
673
+ seen_txid.add(txid)
674
+ us_old = _line_old(entries, txid)
675
+ tmpls = _text_templates(txid, lang_bodies, us_old)
676
+ if not tmpls:
677
+ continue # no editable .mes for this line
678
+ label = (f'line {txid}: "{_short(us_old)}"' if us_old else f"line {txid}")
679
+ langs = [t["lang"] for t in tmpls if t.get("lang")]
680
+ note = ("rewrites " + ", ".join(langs)) if langs else ""
681
+ sites.append(EditSite("text", "string", label, us_old if us_old is not None else "",
682
+ "new", tmpls, note=note, key=f"text:{txid}"))
683
+ return sites
684
+
685
+
686
+ # --- edit synthesis + merge (the GUI writes these into the field.toml's logic_edit list) -------------
687
+ _COORD_KEYS = ("kind", "entry", "tag", "op", "operand", "slot", "item_id", "nth", "lang", "txid", "flag", "old")
688
+
689
+
690
+ def synth_edits(site: EditSite, new) -> list:
691
+ """The ``[[logic_edit]]`` dicts that realize editing ``site`` to ``new`` -- the value edits PLUS, for an
692
+ item, the paired display edits (so the 'Received <item>!' message always tracks the give). For an item the
693
+ quantity is unchanged (use :func:`synth_item_edits` to also set the count)."""
694
+ out = [{**t, site.new_key: new} for t in site.templates]
695
+ out += [{**t, "new": new} for t in site.display_templates]
696
+ return out
697
+
698
+
699
+ def synth_item_edits(site: EditSite, new_id, new_count=None) -> list:
700
+ """The edits to retarget an item reward to ``new_id`` (give + paired display) AND, when ``new_count`` is
701
+ given and differs, its quantity. A component whose value is unchanged emits NO edit (so a count-only change
702
+ doesn't author a redundant give edit, and vice-versa)."""
703
+ out = []
704
+ if new_id != site.old:
705
+ out += [{**t, "new": new_id} for t in site.templates]
706
+ out += [{**t, "new": new_id} for t in site.display_templates]
707
+ if new_count is not None and site.count_old is not None and new_count != site.count_old:
708
+ out += [{**t, "new": new_count} for t in site.count_templates]
709
+ return out
710
+
711
+
712
+ def edit_coord(ed: dict) -> tuple:
713
+ """The identifying coordinates of a logic_edit (everything but the NEW value) -- for dedup/replace."""
714
+ return tuple((k, ed.get(k)) for k in _COORD_KEYS)
715
+
716
+
717
+ def site_footprint(site: EditSite) -> set:
718
+ """The coords of EVERY edit ``site`` can author (value + display + quantity) -- so re-editing or clearing a
719
+ site removes all of its prior edits before adding new ones (handles a changed display/count set cleanly)."""
720
+ return {edit_coord(t) for t in (site.templates + site.display_templates + site.count_templates)}
721
+
722
+
723
+ def upsert_edits(existing, new_edits, *, drop=None) -> list:
724
+ """Return ``existing`` with edits whose coords are in ``drop`` (default = the new edits' own coords)
725
+ removed, then ``new_edits`` appended. Pure -- re-editing a site replaces its edits, never stacks them."""
726
+ drop = set(drop) if drop is not None else {edit_coord(e) for e in new_edits}
727
+ kept = [e for e in (existing or []) if edit_coord(e) not in drop]
728
+ return kept + list(new_edits)