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/eb/edit.py ADDED
@@ -0,0 +1,439 @@
1
+ """Structural edits on a FF9 field event script (``.eb``).
2
+
3
+ Every function here splices the *existing* bytes; none re-serialize from a parse. The two
4
+ load-bearing primitives:
5
+
6
+ * :func:`insert_bytes` — insert bytes at an absolute offset and keep the entry table
7
+ consistent (grow the containing entry, shift every later entry's offset). This is the
8
+ relayout that was copy-pasted into ~5 of the original tools; it lives here once.
9
+ * :func:`append_entry` — append a whole new entry body at end-of-file and register it in a
10
+ free slot. Because it appends at the end, it never shifts existing bytecode.
11
+
12
+ Injecting behaviour into an existing function is done **shift-free** wherever possible by
13
+ overwriting a ``Wait(n)`` filler with an equal-length opcode (``InitObject`` / ``InitRegion``
14
+ / ``InitCode`` are all 3 bytes, same as ``Wait`` ``22 00 nn``). :func:`find_wait` locates such
15
+ fillers. When a genuine insert is unavoidable, :func:`jumps_crossing` flags any relative jump
16
+ that would straddle the insert point (the one thing that makes an insert unsafe).
17
+
18
+ All functions accept either raw ``bytes`` or an :class:`~ff9mapkit.eb.model.EbScript` and
19
+ return raw ``bytes``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import struct
25
+
26
+ from ..binutils import set_u16, u16
27
+ from .model import ENTRY_SLOT_SIZE, ENTRY_TABLE_OFF, EbScript
28
+
29
+
30
+ def _as_bytes(data) -> bytes:
31
+ return data.to_bytes() if isinstance(data, EbScript) else bytes(data)
32
+
33
+
34
+ # --------------------------------------------------------------------------- core relayout
35
+
36
+ def insert_bytes(data, abs_off: int, ins: bytes) -> bytes:
37
+ """Insert ``ins`` at absolute offset ``abs_off``; keep the entry table consistent.
38
+
39
+ Grows the entry that contains ``abs_off`` (so its declared size still covers its code) and
40
+ bumps the table offset of every entry that starts after it. Entry-count aware. Internal
41
+ func ``fpos`` values are relative to their entry, so they need no fixup.
42
+ """
43
+ b = bytearray(_as_bytes(data))
44
+ n = b[3]
45
+ target = None
46
+ for i in range(n):
47
+ so = ENTRY_TABLE_OFF + i * ENTRY_SLOT_SIZE
48
+ off, sz = u16(b, so), u16(b, so + 2)
49
+ if sz > 0 and ENTRY_TABLE_OFF + off <= abs_off < ENTRY_TABLE_OFF + off + sz:
50
+ target = (i, off, sz)
51
+ break
52
+ if target is None:
53
+ raise ValueError(f"no entry contains absolute offset {abs_off}")
54
+ ti, toff, tsz = target
55
+ set_u16(b, ENTRY_TABLE_OFF + ti * ENTRY_SLOT_SIZE + 2, tsz + len(ins))
56
+ for j in range(n):
57
+ if j == ti:
58
+ continue
59
+ so = ENTRY_TABLE_OFF + j * ENTRY_SLOT_SIZE
60
+ off = u16(b, so)
61
+ if off > toff:
62
+ set_u16(b, so, off + len(ins))
63
+ return bytes(b[:abs_off]) + bytes(ins) + bytes(b[abs_off:])
64
+
65
+
66
+ ENTRY_GROW_CHUNK = 8 # slots added per growth (amortise the body reshuffle; real fields ~30)
67
+ ENTRY_TABLE_MAX = 255 # entry_count is a single header byte
68
+
69
+
70
+ def grow_entry_table(data, new_count: int) -> bytes:
71
+ """Enlarge the entry table to ``new_count`` slots (a no-op if already that big).
72
+
73
+ The table lives at :data:`ENTRY_TABLE_OFF` (128) as ``entry_count`` 8-byte slots, immediately
74
+ followed by the entry bodies (whose slot ``off`` is relative to 128). Adding slots inserts
75
+ ``(new-old)*8`` zero bytes right after the existing table -- pushing every body later -- so each
76
+ EXISTING body's ``off`` is bumped by that amount; the new slots read as empty (off=size=0). The
77
+ 44-byte header (byte 3 = count) + 84-byte name precede the table and need no fixup beyond the
78
+ count. ``InitRegion``/``InitObject`` reference a SLOT INDEX (not a byte offset) and func ``fpos``
79
+ is entry-relative, so activations + internal jumps survive untouched."""
80
+ b = bytearray(_as_bytes(data))
81
+ old = b[3]
82
+ if new_count <= old:
83
+ return bytes(b)
84
+ if new_count > ENTRY_TABLE_MAX:
85
+ raise ValueError(f"entry table can hold at most {ENTRY_TABLE_MAX} slots (asked {new_count})")
86
+ k = (new_count - old) * ENTRY_SLOT_SIZE
87
+ for i in range(old): # bump every NON-empty body offset by the insert
88
+ so = ENTRY_TABLE_OFF + i * ENTRY_SLOT_SIZE
89
+ if u16(b, so + 2) > 0: # empty slots keep off=0
90
+ set_u16(b, so, u16(b, so) + k)
91
+ b[3] = new_count
92
+ ins_at = ENTRY_TABLE_OFF + old * ENTRY_SLOT_SIZE # right after the old table, before the bodies
93
+ return bytes(b[:ins_at]) + bytes(k) + bytes(b[ins_at:])
94
+
95
+
96
+ def append_entry(data, slot: int, entry_bytes: bytes) -> bytes:
97
+ """Append ``entry_bytes`` at end-of-file and register it in entry-table ``slot``.
98
+
99
+ The slot must currently be empty. If ``slot`` is beyond the current table (``slot >= entry_count``
100
+ -- what :meth:`EbScript.first_free_slot` returns when the table is full), the table is grown
101
+ on-demand to accommodate it first. Returns new bytes. Does not shift existing bytecode.
102
+ """
103
+ b = bytearray(_as_bytes(data))
104
+ if slot >= b[3]: # table full -> grow (chunked) to fit this slot
105
+ b = bytearray(grow_entry_table(b, max(slot + 1, b[3] + ENTRY_GROW_CHUNK)))
106
+ so = ENTRY_TABLE_OFF + slot * ENTRY_SLOT_SIZE
107
+ if u16(b, so + 2) != 0:
108
+ raise ValueError(f"entry slot {slot} is not empty (size={u16(b, so + 2)})")
109
+ new_off = len(b) - ENTRY_TABLE_OFF
110
+ b += entry_bytes
111
+ set_u16(b, so, new_off)
112
+ set_u16(b, so + 2, len(entry_bytes))
113
+ b[so + 4] = 0 # loc
114
+ b[so + 5] = 0 # flags
115
+ b[so + 6] = 0 # pad
116
+ b[so + 7] = 0
117
+ return bytes(b)
118
+
119
+
120
+ def insert_entry_at(data, at_index: int, entry_bytes: bytes) -> bytes:
121
+ """Insert a NEW entry as slot ``at_index``, shifting the slot records at ``>= at_index`` up one index.
122
+
123
+ Unlike :func:`append_entry` (fill an existing empty slot / grow the table at the END), this makes room
124
+ for a new slot in the MIDDLE of the table: one 8-byte slot record is inserted before all entry bodies,
125
+ so every existing body shifts down 8 bytes (its ``off`` += 8) and the records at ``>= at_index`` take a
126
+ new, one-higher slot INDEX; the entry count (header byte 3) is bumped and the new body is appended at
127
+ end-of-file. Returns new bytes; the inserted entry's slot index is ``at_index``.
128
+
129
+ Because the existing entries at/after ``at_index`` are RENUMBERED, any bytecode that references them by
130
+ raw slot/uid index is now stale -- the caller must remap those FIRST (see
131
+ :func:`ff9mapkit.content.object.insert_entry_before_band`, which uses this to seat a new NPC just below
132
+ the engine's reserved party-character band).
133
+ """
134
+ b = bytearray(_as_bytes(data))
135
+ n = b[3]
136
+ if not 0 <= at_index <= n:
137
+ raise ValueError(f"at_index {at_index} out of range 0..{n}")
138
+ if n + 1 > ENTRY_TABLE_MAX:
139
+ raise ValueError(f"entry table already at the {ENTRY_TABLE_MAX}-slot max")
140
+ for i in range(n): # one slot record inserted before the bodies ->
141
+ so = ENTRY_TABLE_OFF + i * ENTRY_SLOT_SIZE # every existing body moves down 8 bytes
142
+ if u16(b, so + 2) > 0:
143
+ set_u16(b, so, u16(b, so) + ENTRY_SLOT_SIZE)
144
+ ins_pos = ENTRY_TABLE_OFF + at_index * ENTRY_SLOT_SIZE
145
+ b[ins_pos:ins_pos] = bytes(ENTRY_SLOT_SIZE) # the new (empty) record; pushes later records up one index
146
+ b[3] = n + 1
147
+ new_off = len(b) - ENTRY_TABLE_OFF # the new body goes at EOF (after the just-grown table)
148
+ b += entry_bytes
149
+ set_u16(b, ins_pos, new_off)
150
+ set_u16(b, ins_pos + 2, len(entry_bytes))
151
+ b[ins_pos + 4] = 0 # loc
152
+ b[ins_pos + 5] = 0 # flags
153
+ b[ins_pos + 6] = 0 # pad
154
+ b[ins_pos + 7] = 0
155
+ return bytes(b)
156
+
157
+
158
+ def add_function(data, entry_index: int, tag: int, body: bytes) -> bytes:
159
+ """Add a function ``(tag, body)`` to an EXISTING entry.
160
+
161
+ Grows the entry's function table by one 4-byte slot (existing funcs' ``fpos += 4``), appends the
162
+ body after the entry's code, and relocates every later entry's table offset by the growth. (The
163
+ re-layout :mod:`ff9mapkit.content.reinit` does for the after-battle handler, generalized -- used by
164
+ the ladder primitive to add the player's climb function.) Raises if ``tag`` already exists.
165
+ """
166
+ b = bytearray(_as_bytes(data))
167
+ slot = ENTRY_TABLE_OFF + entry_index * ENTRY_SLOT_SIZE
168
+ off, sz = u16(b, slot), u16(b, slot + 2)
169
+ if sz == 0:
170
+ raise ValueError(f"entry {entry_index} is empty")
171
+ es = ENTRY_TABLE_OFF + off
172
+ etype, fc = b[es], b[es + 1]
173
+ fbase = es + 2
174
+ funcs = [(u16(b, fbase + i * 4), u16(b, fbase + i * 4 + 2)) for i in range(fc)]
175
+ if any(t == tag for t, _ in funcs):
176
+ raise ValueError(f"entry {entry_index} already has a function with tag {tag}")
177
+ code = bytes(b[fbase + fc * 4: es + sz])
178
+ new_funcs = [(t, fp + 4) for t, fp in funcs] + [(tag, (fc + 1) * 4 + len(code))]
179
+ new_entry = bytearray([etype, fc + 1])
180
+ for t, fp in new_funcs:
181
+ new_entry += struct.pack("<HH", t, fp)
182
+ new_entry += code + body
183
+ growth = len(new_entry) - sz
184
+ out = bytearray(bytes(b[:es]) + bytes(new_entry) + bytes(b[es + sz:]))
185
+ set_u16(out, slot + 2, len(new_entry))
186
+ for i in range(b[3]):
187
+ if i == entry_index:
188
+ continue
189
+ s2 = ENTRY_TABLE_OFF + i * ENTRY_SLOT_SIZE
190
+ if u16(out, s2 + 2) > 0 and u16(out, s2) > off:
191
+ set_u16(out, s2, u16(out, s2) + growth)
192
+ return bytes(out)
193
+
194
+
195
+ def replace_function_body(data, entry_index: int, func_tag: int, new_body: bytes) -> bytes:
196
+ """Replace function ``func_tag``'s body in ``entry_index`` with ``new_body`` (any length).
197
+
198
+ Fixes the intra-entry ``fpos`` of every LATER function (shifted by the size delta), the entry's
199
+ declared size, and every later entry's table offset. A full-body replace needs no jump analysis
200
+ (the old body -- and any jumps inside it -- is discarded, and functions never jump into each other).
201
+ Used to re-author a battle eb's ``Main_Init`` (entry 0, tag 0) to ``InitObject`` one enemy-AI object
202
+ per spawned slot, so the eb's AI binding matches an edited spawn composition.
203
+ """
204
+ b = bytearray(_as_bytes(data))
205
+ slot = ENTRY_TABLE_OFF + entry_index * ENTRY_SLOT_SIZE
206
+ off, sz = u16(b, slot), u16(b, slot + 2)
207
+ if sz == 0:
208
+ raise ValueError(f"entry {entry_index} is empty")
209
+ es = ENTRY_TABLE_OFF + off
210
+ fc = b[es + 1]
211
+ fbase = es + 2
212
+ funcs = [(u16(b, fbase + i * 4), u16(b, fbase + i * 4 + 2)) for i in range(fc)] # (tag, fpos)
213
+ idx = next((i for i, (t, _) in enumerate(funcs) if t == func_tag), None)
214
+ if idx is None:
215
+ raise ValueError(f"entry {entry_index} has no function tag {func_tag}")
216
+ body_start = fbase + funcs[idx][1]
217
+ body_end = (fbase + funcs[idx + 1][1]) if idx + 1 < fc else (es + sz)
218
+ delta = len(new_body) - (body_end - body_start)
219
+ out = bytearray(bytes(b[:body_start]) + bytes(new_body) + bytes(b[body_end:]))
220
+ for i in range(idx + 1, fc): # later funcs' bodies shifted by delta
221
+ set_u16(out, fbase + i * 4 + 2, funcs[i][1] + delta)
222
+ set_u16(out, slot + 2, sz + delta) # entry's declared size
223
+ for i in range(b[3]): # later entries' table offsets
224
+ if i == entry_index:
225
+ continue
226
+ s2 = ENTRY_TABLE_OFF + i * ENTRY_SLOT_SIZE
227
+ if u16(out, s2 + 2) > 0 and u16(out, s2) > off:
228
+ set_u16(out, s2, u16(out, s2) + delta)
229
+ return bytes(out)
230
+
231
+
232
+ def insert_in_function(data, entry_index: int, func_tag: int, rel_off: int, ins: bytes) -> bytes:
233
+ """Insert ``ins`` into function ``func_tag``'s body at body offset ``rel_off`` (0 = prepend).
234
+
235
+ Unlike :func:`insert_bytes` (which only fixes the entry table), this ALSO fixes the intra-entry
236
+ function-table ``fpos`` of every *other* function whose body starts at/after the insert point --
237
+ the gap that makes a raw insert into a non-last function corrupt the later funcs. So that the
238
+ function's own relative jumps stay valid, a MID-function insert point must not be straddled by any
239
+ of ``func_tag``'s jumps (raised otherwise; a 0x06 jump table can't be analysed, so a mid-function
240
+ insert into one is refused). Inserting right after a setup opcode and before the function's tail
241
+ (e.g. after the player's ``DefinePlayerCharacter``, before its ``EnableMove`` block) is safe: every
242
+ tail jump and its target shift together. Used to place the ladder re-entry spawn inside the player
243
+ Init, exactly as field 706 does (no warm-up, no base-position flash).
244
+
245
+ A **prepend** (``rel_off == 0``) is ALWAYS safe -- even on a function with a 0x06/0x0B scenario
246
+ jump table -- because the engine is uniformly IP-relative: moving the whole body wholesale keeps
247
+ every relative offset valid (both endpoints shift together), and nothing can sit between the new
248
+ start and a later target. This is the ``[startup]``/``activate`` path, and it now works on the ~11%
249
+ of fields whose Main_Init switches on the ScenarioCounter (e.g. the interactive-ATE hub field 206)."""
250
+ eb = EbScript.from_bytes(data)
251
+ f = eb.entry(entry_index).func_by_tag(func_tag)
252
+ if f is None:
253
+ raise ValueError(f"entry {entry_index} has no function tag {func_tag}")
254
+ abs_ins = f.abs_start + rel_off
255
+ if not (f.abs_start <= abs_ins < f.abs_end):
256
+ raise ValueError(f"insert offset {rel_off} is outside func {func_tag} body")
257
+ # A prepend at the function's very start (abs_ins == f.abs_start, i.e. rel_off == 0) can NEVER
258
+ # be straddled by the function's own control flow: the engine is uniformly IP-relative
259
+ # (``s1.ip += offset`` -- bra/beq/bne AND the 0x06/0x0B switch tables, EBin.cs), so moving the
260
+ # whole body wholesale preserves every relative offset (both endpoints shift together). Only a
261
+ # genuine MID-function insert can split a jump from its target, so the (best-effort) jump-safety
262
+ # analysis is needed only then. This is what lets [startup]/activate prepend onto the ~11% of
263
+ # fields whose Main_Init switches on the ScenarioCounter via a 0x06 jump table (e.g. field 206,
264
+ # the interactive-ATE hub) -- exactly the case inject_startup's docstring already promises is safe.
265
+ if abs_ins > f.abs_start:
266
+ for j in eb.instrs(f): # the function's own relative jumps
267
+ if j.op in (0x01, 0x02, 0x03) and not j.arg_is_expr[0]:
268
+ raw = j.imm(0)
269
+ tgt = j.end + (raw - 0x10000 if raw >= 0x8000 else raw)
270
+ # The insert keeps a relative jump valid only if BOTH its endpoints shift by the same amount, i.e.
271
+ # the jump and its target are on the same side of abs_ins (a boundary-aware test -- the old
272
+ # `min<abs_ins<max` was blind to an endpoint landing EXACTLY on abs_ins, silently corrupting a jump
273
+ # whose end or target coincides with the insert). The one exception is tgt == abs_ins: the jump then
274
+ # lands on the FIRST inserted instruction, which (the fragment having no terminator) flows on into
275
+ # the original target -- the intended "insert before instruction X" semantics for every path
276
+ # reaching X, so it is allowed.
277
+ if (j.off >= abs_ins) != (tgt >= abs_ins) and tgt != abs_ins:
278
+ raise ValueError(f"insert at {abs_ins} straddles jump {j.off}->{tgt} in func {func_tag}")
279
+ elif j.op == 0x06:
280
+ raise ValueError(f"func {func_tag} has a jump table (0x06); mid-function insert "
281
+ f"unsupported (a prepend at the function start IS supported)")
282
+ out = bytearray(insert_bytes(data, abs_ins, bytes(ins))) # grows entry + later entries; fpos NOT fixed
283
+ so = ENTRY_TABLE_OFF + entry_index * ENTRY_SLOT_SIZE
284
+ es = ENTRY_TABLE_OFF + u16(out, so)
285
+ fc = out[es + 1]
286
+ fbase = es + 2
287
+ for i in range(fc):
288
+ t = u16(out, fbase + i * 4)
289
+ fp = u16(out, fbase + i * 4 + 2)
290
+ if t != func_tag and fbase + fp >= abs_ins: # other funcs whose body shifted
291
+ set_u16(out, fbase + i * 4 + 2, fp + len(ins))
292
+ return bytes(out)
293
+
294
+
295
+ def nop_range(data, abs_off: int, length: int) -> bytes:
296
+ """Overwrite ``length`` bytes at ``abs_off`` with NOP (0x00). Length-preserving."""
297
+ b = bytearray(_as_bytes(data))
298
+ b[abs_off:abs_off + length] = bytes(length)
299
+ return bytes(b)
300
+
301
+
302
+ def patch_bytes(data, abs_off: int, new: bytes, expect: bytes | None = None) -> bytes:
303
+ """Overwrite ``len(new)`` bytes at ``abs_off``. If ``expect`` given, assert it matches first."""
304
+ b = bytearray(_as_bytes(data))
305
+ if expect is not None and bytes(b[abs_off:abs_off + len(expect)]) != expect:
306
+ got = bytes(b[abs_off:abs_off + len(expect)])
307
+ raise ValueError(f"patch @ {abs_off}: expected {expect.hex()} got {got.hex()}")
308
+ b[abs_off:abs_off + len(new)] = new
309
+ return bytes(b)
310
+
311
+
312
+ # --------------------------------------------------------------------------- locators
313
+
314
+ WAIT_OP = 0x22 # Wait(n) encodes as 22 00 nn (op, argFlag=0, 1-byte count)
315
+
316
+
317
+ def find_entry_containing(eb: EbScript, abs_off: int):
318
+ for e in eb.entries:
319
+ if not e.empty and e.abs_start <= abs_off < e.abs_end:
320
+ return e
321
+ return None
322
+
323
+
324
+ def find_instrs(eb: EbScript, op: int, *, entry_index: int = 0, func_tag: int | None = None):
325
+ """All instructions with opcode ``op`` in the given entry (optionally a single func)."""
326
+ entry = eb.entry(entry_index)
327
+ funcs = entry.funcs if func_tag is None else [f for f in entry.funcs if f.tag == func_tag]
328
+ out = []
329
+ for f in funcs:
330
+ for ins in eb.instrs(f):
331
+ if ins.op == op:
332
+ out.append(ins)
333
+ return out
334
+
335
+
336
+ CINEMATIC_OP = 0x28 # Cinematic(...) -- FMV / opening-movie playback
337
+ FIELD_OP = 0x2B # Field(dest) -- a field warp
338
+
339
+
340
+ def nop_cinematics(data, *, entry_index: int = 0, func_tag: int = 0, before_op: int = FIELD_OP):
341
+ """NOP every ``Cinematic`` (``0x28``, FMV playback) instruction in a function, up to the first
342
+ ``before_op`` (default the first ``Field()`` warp, ``0x2B``). Length-preserving: each op is overwritten
343
+ in place with ``0x00`` NOPs (engine-confirmed "do nothing" -- ``DoEventCode`` case ``NOP``), so no offsets
344
+ shift and no jumps need fixing. Returns ``(new_data, n_nopped)``.
345
+
346
+ Used to strip the opening movie from an opening-field override (e.g. field 70 ``EVT_ALEX1_TS_OPENING``,
347
+ which plays 2 cinematics before warping to a custom field) so a New Game lands in the target field
348
+ *instantly* -- a pure-mod, engine-independent change. See memory ``project-ff9-new-game-entry``."""
349
+ src = _as_bytes(data)
350
+ eb = EbScript.from_bytes(src)
351
+ entry = eb.entry(entry_index)
352
+ func = entry.func_by_tag(func_tag) if entry is not None else None
353
+ if func is None:
354
+ return src, 0
355
+ ins = list(eb.instrs(func))
356
+ stop = next((x.off for x in ins if x.op == before_op), None)
357
+ out, n = src, 0
358
+ for i, x in enumerate(ins):
359
+ if x.op == CINEMATIC_OP and (stop is None or x.off < stop):
360
+ end = ins[i + 1].off if i + 1 < len(ins) else func.abs_end
361
+ out = nop_range(out, x.off, end - x.off)
362
+ n += 1
363
+ return out, n
364
+
365
+
366
+ def find_wait(eb: EbScript, *, n: int | None = None, entry_index: int = 0,
367
+ func_tag: int | None = 0, occurrence: int = 0) -> int:
368
+ """Absolute offset of a ``Wait(n)`` filler (default: in Main_Init, entry 0 func tag 0).
369
+
370
+ ``n`` filters by the wait count; ``occurrence`` selects among multiple matches. This is the
371
+ canonical shift-free injection site: overwrite the 3-byte ``Wait`` with an equal-length
372
+ ``InitObject``/``InitRegion``/``InitCode``. Raises if no matching filler exists.
373
+ """
374
+ matches = [ins for ins in find_instrs(eb, WAIT_OP, entry_index=entry_index, func_tag=func_tag)
375
+ if n is None or ins.imm(0) == n]
376
+ if occurrence >= len(matches):
377
+ raise ValueError(f"no Wait({n}) filler #{occurrence} in entry {entry_index} func {func_tag} "
378
+ f"(found {len(matches)})")
379
+ return matches[occurrence].off
380
+
381
+
382
+ def activate(data, init_bytes: bytes, *, spawn_wait_n: int = 2, spawn_wait_occurrence: int = 0) -> bytes:
383
+ """Activate an appended entry from Main_Init with a 3-byte ``Init*`` call (``InitObject`` /
384
+ ``InitRegion`` / ``InitCode``).
385
+
386
+ Overwrites a Main_Init ``Wait(n)`` filler (shift-free) when one is free; otherwise INSERTS the
387
+ call at the start of Main_Init. The blank field has only 2 Wait fillers, so a content-rich field
388
+ (NPCs + gateways + events) overflows them -- the insert path lets any amount of content activate.
389
+ The insert goes through :func:`insert_in_function` (the fpos-fixing insert), so it stays correct
390
+ even when entry-0 has a REAL second function (a borrowed field's tag-1) and even across MANY
391
+ sequential inserts -- the bug that previously left a 3rd+ region silently un-armed (raw
392
+ ``insert_bytes`` left other funcs' ``fpos`` stale, corrupting the 2nd+ insertion). Within-budget
393
+ fields hit the Wait path and stay byte-identical to before."""
394
+ eb = EbScript.from_bytes(data)
395
+ try:
396
+ off = find_wait(eb, n=spawn_wait_n, occurrence=spawn_wait_occurrence)
397
+ except ValueError:
398
+ if eb.entry(0).func_by_tag(0) is None:
399
+ raise ValueError("entry 0 has no Main_Init to activate from")
400
+ return insert_in_function(data, 0, 0, 0, bytes(init_bytes))
401
+ return patch_bytes(data, off, bytes(init_bytes), expect=bytes([WAIT_OP, 0x00, spawn_wait_n & 0xFF]))
402
+
403
+
404
+ # --------------------------------------------------------------------------- jump safety (best effort)
405
+
406
+ JMP_OP = 0x03 # unconditional relative jump: operand is signed int16, target = instr.end + offset
407
+
408
+
409
+ def relative_jumps(eb: EbScript):
410
+ """All unconditional relative jumps (op 0x03) as (src_off, src_end, target) tuples.
411
+
412
+ Best effort: covers the unconditional JMP. The recommended injection path (overwrite a
413
+ Wait filler, or append an entry) is shift-free and needs no jump analysis; this helper is
414
+ a safety net for the rarer case of inserting into a function with control flow.
415
+ """
416
+ out = []
417
+ for e in eb.entries:
418
+ if e.empty:
419
+ continue
420
+ for f in e.funcs:
421
+ for ins in eb.instrs(f):
422
+ if ins.op == JMP_OP and not ins.arg_is_expr[0]:
423
+ raw = ins.imm(0)
424
+ offset = raw - 0x10000 if raw >= 0x8000 else raw # signed int16
425
+ out.append((ins.off, ins.end, ins.end + offset))
426
+ return out
427
+
428
+
429
+ def jumps_crossing(eb: EbScript, abs_off: int):
430
+ """Relative jumps that would straddle an insert at ``abs_off`` (i.e. become invalid).
431
+
432
+ Empty list => inserting at ``abs_off`` is safe with respect to unconditional jumps.
433
+ """
434
+ crossing = []
435
+ for src_off, src_end, target in relative_jumps(eb):
436
+ lo, hi = sorted((src_end, target))
437
+ if lo < abs_off < hi:
438
+ crossing.append((src_off, target))
439
+ return crossing
@@ -0,0 +1,158 @@
1
+ """Phase-6c-i: the `.eb` EXPRESSION ASSEMBLER -- the exact inverse of :func:`disasm.pretty_expr`.
2
+
3
+ Authoring new enemy-AI logic (a phase-switch condition, a counter trigger) means writing the RPN expression
4
+ token stream the engine evaluates. This assembles that stream from the SAME readable form the disassembler
5
+ prints, so the round trip is the identity:
6
+
7
+ assemble(disasm.pretty_expr(bytes)[0]) == bytes (byte-exact, for canonical bytecode)
8
+ disasm.pretty_expr(assemble(text))[0] == text
9
+
10
+ Each whitespace-separated token in a ``{ ... }`` form maps to one encoded token (the inverse of every branch of
11
+ pretty_expr): a bare op mnemonic (``B_LT``, ``B_CURHP`` …) -> its op_binary byte; ``const(N)`` -> ``B_CONST``
12
+ (0x7D + 2 LE bytes); ``const4(N)`` -> ``B_CONST4`` (0x7E + 4 LE bytes); ``Source.Type[i]`` -> the ``0xC0`` var
13
+ token (source 0-3, type, + a 1- or 2-byte index, the engine's minimal encoding); ``B_SYSVAR[i]`` / ``B_SYSLIST[i]``
14
+ / ``obj(uid=U).f[F]`` / ``B_MEMBER(i)`` / ``B_PTR(i)`` -> their operand tokens; ``B_EXPR_END`` (0x7F) terminates.
15
+
16
+ Provenance: only the open-source op_binary / VariableSource / VariableType NAMES are used (via
17
+ :mod:`ff9mapkit.eb._exprtable`); no SE bytes. This is the keystone for Phase-6c new-branch authoring (the command
18
+ assembler + length-changing ``add_function`` insertion + a battle linter build on top).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import re
23
+
24
+ from ._exprtable import EXPR_OP_NAMES, VAR_SOURCE, VAR_TYPE
25
+
26
+ _OP_BY_NAME = {n: v for v, n in EXPR_OP_NAMES.items()}
27
+ _SRC_BY_NAME = {n: v for v, n in VAR_SOURCE.items()}
28
+ _TYPE_BY_NAME = {n: v for v, n in VAR_TYPE.items()}
29
+
30
+ _RE_CONST = re.compile(r"^const\((-?\d+)\)$")
31
+ _RE_CONST4 = re.compile(r"^const4\((-?\d+)\)$")
32
+ _RE_VAR = re.compile(r"^([A-Za-z]+)\.([A-Za-z0-9]+)\[(\d+)\]$")
33
+ _RE_SYS = re.compile(r"^(B_SYSVAR|B_SYSLIST)\[(\d+)\]$")
34
+ _RE_OBJ = re.compile(r"^obj\(uid=(\d+)\)\.f\[(\d+)\]$")
35
+ _RE_MEMPTR = re.compile(r"^(B_MEMBER|B_PTR)\(([\w.]+)\)$") # operand may be a number OR a member name (B_MEMBER)
36
+ _RE_OPHEX = re.compile(r"^op([0-9A-Fa-f]{2})$") # the disassembler's fallback for an UNNAMED operator byte
37
+
38
+ # the operand-bearing tokens -- they MUST be written in their operand form (pretty_expr always does), never bare:
39
+ # a bare "B_CONST" / "B_MEMBER" would emit the opcode alone and DROP the operand byte(s), desyncing the stream.
40
+ _OPERAND_OPS = {"B_CONST": "const(N)", "B_CONST4": "const4(N)", "B_SYSVAR": "B_SYSVAR[i]", "B_SYSLIST": "B_SYSLIST[i]",
41
+ "B_OBJSPECA": "obj(uid=U).f[F]", "B_MEMBER": "B_MEMBER(i)", "B_PTR": "B_PTR(i)"}
42
+
43
+
44
+ class AssembleError(ValueError):
45
+ pass
46
+
47
+
48
+ def _u16(v: int) -> bytes:
49
+ return bytes((v & 0xFF, (v >> 8) & 0xFF))
50
+
51
+
52
+ def _u32(v: int) -> bytes:
53
+ return bytes((v & 0xFF, (v >> 8) & 0xFF, (v >> 16) & 0xFF, (v >> 24) & 0xFF))
54
+
55
+
56
+ def assemble_token(tok: str) -> bytes:
57
+ """Encode ONE pretty_expr token -> its bytes. Raises AssembleError on an unknown token / out-of-range value."""
58
+ m = _RE_CONST.match(tok)
59
+ if m:
60
+ v = int(m.group(1)) # B_CONST -- a 2-byte literal (engine reads getShortIP,
61
+ if not -0x8000 <= v <= 0xFFFF: # a signed Int16; accept the signed-or-unsigned window)
62
+ raise AssembleError(f"{tok}: const out of 16-bit range (-32768..65535)")
63
+ return bytes((0x7D,)) + _u16(v & 0xFFFF)
64
+ m = _RE_CONST4.match(tok)
65
+ if m:
66
+ v = int(m.group(1)) # B_CONST4 -- a 4-byte literal (the engine masks the read
67
+ if not -0x80000000 <= v <= 0xFFFFFFFF: # to 26 bits, but the 4 bytes are byte-faithful here)
68
+ raise AssembleError(f"{tok}: const4 out of 32-bit range")
69
+ return bytes((0x7E,)) + _u32(v & 0xFFFFFFFF)
70
+ m = _RE_SYS.match(tok) # B_SYSVAR[i] / B_SYSLIST[i] -- 1-byte index
71
+ if m:
72
+ idx = int(m.group(2))
73
+ if not 0 <= idx <= 0xFF:
74
+ raise AssembleError(f"{tok}: index out of range (0-255)")
75
+ return bytes((0x7A if m.group(1) == "B_SYSVAR" else 0x79, idx))
76
+ m = _RE_OBJ.match(tok) # obj(uid=U).f[F] -- B_OBJSPECA, uid then field
77
+ if m:
78
+ uid, fld = int(m.group(1)), int(m.group(2))
79
+ if not (0 <= uid <= 0xFF and 0 <= fld <= 0xFF):
80
+ raise AssembleError(f"{tok}: uid/field out of range (0-255)")
81
+ return bytes((0x78, uid, fld))
82
+ m = _RE_MEMPTR.match(tok) # B_MEMBER(i) / B_PTR(i) -- 1-byte operand, a number OR
83
+ if m: # (for B_MEMBER) a field NAME -> the GetCharacterData id
84
+ op_name, raw = m.group(1), m.group(2)
85
+ if raw.lstrip("-").isdigit():
86
+ n = int(raw)
87
+ elif op_name == "B_MEMBER":
88
+ from ._membertable import member_selector
89
+ n = member_selector(raw)
90
+ if n is None:
91
+ raise AssembleError(f"{tok}: unknown member name {raw!r} (e.g. cur.hp, max.hp, cur.mp -- see "
92
+ f"_membertable.MEMBER_NAMES) -- or use the numeric selector")
93
+ else:
94
+ raise AssembleError(f"{tok}: B_PTR takes a numeric operand, not a name")
95
+ if not 0 <= n <= 0xFF:
96
+ raise AssembleError(f"{tok}: operand out of range (0-255)")
97
+ return bytes((0x29 if op_name == "B_MEMBER" else 0x5F, n))
98
+ m = _RE_VAR.match(tok) # Source.Type[index] -- the 0xC0 variable token
99
+ if m:
100
+ src_name, typ_name, idx = m.group(1), m.group(2), int(m.group(3))
101
+ src = _SRC_BY_NAME.get(src_name)
102
+ typ = _TYPE_BY_NAME.get(typ_name)
103
+ if src is None or typ is None:
104
+ raise AssembleError(f"{tok}: unknown variable Source.Type (got {src_name}.{typ_name})")
105
+ if not 0 <= src <= 3:
106
+ raise AssembleError(f"{tok}: only Global/Map/Instance/Null are 0xC0 vars (Object/System/Member use "
107
+ f"their own tokens obj(...)/B_SYSLIST/B_MEMBER)")
108
+ if not 0 <= idx <= 0xFFFF:
109
+ raise AssembleError(f"{tok}: index out of range (0-65535)")
110
+ token = 0xC0 | (typ << 2) | src
111
+ if idx > 0xFF: # long index -> the 0x20 bit + a 2-byte index
112
+ return bytes((token | 0x20,)) + _u16(idx)
113
+ return bytes((token, idx)) # short index -> 1 byte (the engine's minimal encoding)
114
+ if tok in _OPERAND_OPS: # caught a bare operand-op -> would drop its operand
115
+ raise AssembleError(f"{tok} takes an operand -- write it as {_OPERAND_OPS[tok]}, not bare")
116
+ if tok in _OP_BY_NAME: # a bare operator mnemonic (B_LT, B_CURHP, B_EXPR_END…)
117
+ return bytes((_OP_BY_NAME[tok],))
118
+ m = _RE_OPHEX.match(tok) # opXX -- the disasm fallback for an UNNAMED pure operator
119
+ if m:
120
+ val = int(m.group(1), 16)
121
+ if val in EXPR_OP_NAMES: # a NAMED op (incl. the operand-bearing const/var/sys/
122
+ raise AssembleError(f"{tok} is {EXPR_OP_NAMES[val]} -- write it by name, not as opXX") # member/ptr ops)
123
+ if val >= 0xC0: # a 0xC0 variable token -- emitting it bare drops its
124
+ raise AssembleError(f"{tok}: 0x{val:02X} is a variable token -- write it as Source.Type[i]") # index
125
+ return bytes((val,)) # only a genuinely-unnamed pure operator byte gets through
126
+ raise AssembleError(f"unknown expression token {tok!r}")
127
+
128
+
129
+ def assemble(text) -> bytes:
130
+ """Assemble a pretty_expr expression -> its byte stream. ``text`` is the ``{ tok tok ... }`` form (braces
131
+ optional) or a list of token strings. The stream MUST end with ``B_EXPR_END``. Round-trips with
132
+ :func:`disasm.pretty_expr` byte-exactly for canonical bytecode."""
133
+ if isinstance(text, str):
134
+ tokens = text.strip().strip("{}").split()
135
+ elif isinstance(text, (list, tuple)):
136
+ tokens = [str(t) for t in text]
137
+ else:
138
+ raise AssembleError("assemble() takes a '{ ... }' string or a list of token strings")
139
+ if not tokens:
140
+ raise AssembleError("empty expression")
141
+ if tokens[-1] != "B_EXPR_END":
142
+ raise AssembleError("an expression must end with B_EXPR_END")
143
+ out = bytearray()
144
+ for tok in tokens:
145
+ out += assemble_token(tok)
146
+ b = bytes(out)
147
+ # Self-verify the round trip at the library boundary: the assembled stream MUST re-parse to exactly itself --
148
+ # consume every byte, no mid-stream B_EXPR_END, no token desync. This makes the round-trip guarantee an
149
+ # INVARIANT of assemble() (a caller can never receive bytes that don't round-trip), so any future encoding
150
+ # hole surfaces here as a clean AssembleError instead of a downstream crash / a silently-corrupt eb.
151
+ from .disasm import pretty_expr as _pretty_expr
152
+ try:
153
+ _text, pos = _pretty_expr(b, 0)
154
+ except (IndexError, KeyError, ValueError) as ex: # a desynced stream runs the decoder off the end
155
+ raise AssembleError(f"assembled stream does not re-parse ({b.hex(' ')}): {type(ex).__name__}: {ex}")
156
+ if pos != len(b): # a mid-stream B_EXPR_END leaves trailing bytes unread
157
+ raise AssembleError(f"B_EXPR_END must be the LAST token -- {len(b) - pos} byte(s) follow the first one")
158
+ return b