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/disasm.py ADDED
@@ -0,0 +1,332 @@
1
+ """FF9 field event-script (.eb) disassembler — decodes the bytecode stream.
2
+
3
+ This is the read side of the ``.eb`` library. It decodes one instruction at a time using the
4
+ baked opcode tables (``_optables``), so it needs no Memoria source at runtime. ``read_code``
5
+ returns a structured :class:`Instr` (offset, opcode, decoded immediate args, byte length) that
6
+ the model and the content injectors use to locate features symbolically — e.g. "find the
7
+ ``Wait(2)`` in Main_Init" — instead of relying on hardcoded byte offsets.
8
+
9
+ The decoding mirrors Memoria's ``EventEngine`` byte reader exactly:
10
+ * a leading ``0xFF`` byte selects the extended (2-byte) opcode page,
11
+ * opcodes >= 0x10 with operands carry a 1-byte ``argFlag`` bitmask; a set bit means that
12
+ operand is an *expression* (RPN-ish token stream) rather than a fixed-width immediate,
13
+ * a few opcodes have a variable operand count read from the stream (0x06 switch, 0x0B, 0x0D).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+
20
+ from ._optables import OP_ARG_COUNT, OP_ARG_SIZE, OP_NAMES
21
+
22
+ SWITCH_OPS = (0x06, 0x0B, 0x0D) # JMP_SWITCHEX (explicit value/offset pairs) / JMP_SWITCH (contiguous range) /
23
+ # JMP_SWITCH with a 2-byte case count. See decode_switch.
24
+
25
+
26
+ def op_name(op: int) -> str:
27
+ return OP_NAMES.get(op, f"op_{op:02X}")
28
+
29
+
30
+ def argsize(op: int, i: int) -> int:
31
+ """Byte width of operand *i* of *op* (immediate form)."""
32
+ if op == 0x29:
33
+ return 4
34
+ if op in (0x06, 0x0B, 0x0D):
35
+ return 2
36
+ a = OP_ARG_SIZE[op] if op < len(OP_ARG_SIZE) else None
37
+ return a[i] if (a and i < len(a)) else 0
38
+
39
+
40
+ @dataclass
41
+ class Instr:
42
+ """One decoded instruction.
43
+
44
+ off : absolute byte offset where the instruction begins
45
+ op : opcode (0x100 | x for extended/0xFF-prefixed opcodes)
46
+ args : decoded operands — ints for immediates, str tokens for expression operands
47
+ arg_is_expr: parallel bool list; True where the operand was an expression
48
+ length : total bytes consumed (off .. off+length)
49
+ """
50
+
51
+ off: int
52
+ op: int
53
+ args: list = field(default_factory=list)
54
+ arg_is_expr: list = field(default_factory=list)
55
+ length: int = 0
56
+
57
+ @property
58
+ def name(self) -> str:
59
+ return op_name(self.op)
60
+
61
+ @property
62
+ def end(self) -> int:
63
+ return self.off + self.length
64
+
65
+ def imm(self, i: int):
66
+ """Immediate operand *i* as int, or None if it was an expression."""
67
+ return self.args[i] if (i < len(self.args) and not self.arg_is_expr[i]) else None
68
+
69
+ @property
70
+ def is_switch(self) -> bool:
71
+ return self.op in SWITCH_OPS
72
+
73
+ def switch(self) -> "SwitchInfo | None":
74
+ """Structured (case value -> absolute target) decode if this is a switch; else None."""
75
+ return decode_switch(self)
76
+
77
+ def __str__(self) -> str:
78
+ parts = []
79
+ for v, is_expr in zip(self.args, self.arg_is_expr):
80
+ parts.append(v if is_expr else str(v))
81
+ return f"[{self.off}] {self.name}({', '.join(parts)})"
82
+
83
+
84
+ @dataclass
85
+ class SwitchEdge:
86
+ """One arm of a switch: a selector ``value`` (None = the default arm) -> an absolute byte ``target``."""
87
+ value: int | None
88
+ target: int
89
+ is_default: bool = False
90
+
91
+
92
+ @dataclass
93
+ class SwitchInfo:
94
+ """A decoded switch dispatch table. ``base`` = the lowest selector value of the contiguous-range form
95
+ (0x0B/0x0D), or None for the explicit value/offset form (0x06). ``edges`` = the cases then the default.
96
+ Targets are ABSOLUTE byte offsets (same space as ``Instr.off`` / ``Func.abs_start``), valid only within
97
+ the owning function. The selector itself is popped from the expression stack at runtime (pushed by the
98
+ preceding ``0x05``), so it is not part of this inline-table decode."""
99
+ op: int
100
+ base: int | None
101
+ edges: list
102
+
103
+
104
+ def _sx_hi(w: int) -> int:
105
+ """Sign-extend only the HIGH byte of a 16-bit word -- the engine reads the contiguous-form base as
106
+ ``offsetL | ((SByte)offsetH << 8)`` (EBin.cs JMP_SWITCH), so a base 0xFFFE means selector -2, not 65534."""
107
+ return (w & 0xFF) | ((((w >> 8) & 0xFF) ^ 0x80) - 0x80) * 256
108
+
109
+
110
+ def decode_switch(instr: Instr) -> "SwitchInfo | None":
111
+ """Decode a switch instruction (0x06 / 0x0B / 0x0D) into a :class:`SwitchInfo` of absolute case+default
112
+ targets, or None if *instr* isn't a switch (or its operands aren't plain immediates). Derived from the
113
+ Memoria engine (EBin.cs JMP_SWITCH / JMP_SWITCHEX) and validated 100% boundary-aligned across all 5563
114
+ switches in the 676 shipping fields.
115
+
116
+ Layout (O = ``instr.off``; ``a`` = the flat 2-byte operands :attr:`Instr.args`):
117
+ * 0x06 (explicit): ``a[0]`` = default reloffset, then n pairs ``(value=a[1+2k], reloffset=a[2+2k])``;
118
+ anchor = O+4; target = anchor + reloffset.
119
+ * 0x0B (contiguous): ``base = sx_hi(a[0])``, ``a[1]`` = default reloffset, ``a[2..]`` = n contiguous
120
+ case reloffsets for selectors base..base+n-1; anchor = O+1.
121
+ * 0x0D (contiguous, 2-byte count): identical to 0x0B with anchor = O+2 (none ship; by-construction).
122
+ All reloffsets are unsigned u16 (the engine only jumps forward)."""
123
+ op = instr.op
124
+ if op not in SWITCH_OPS:
125
+ return None
126
+ a = instr.args
127
+ if any(not isinstance(x, int) for x in a): # a switch never has expression operands; bail if malformed
128
+ return None
129
+ O = instr.off
130
+ if op == 0x06:
131
+ if not a:
132
+ return None
133
+ anchor = O + 4
134
+ n = (len(a) - 1) // 2
135
+ edges = [SwitchEdge(a[1 + 2 * k], anchor + a[2 + 2 * k]) for k in range(n)]
136
+ edges.append(SwitchEdge(None, anchor + a[0], True))
137
+ return SwitchInfo(op, None, edges)
138
+ if len(a) < 2:
139
+ return None
140
+ anchor = O + (2 if op == 0x0D else 1)
141
+ base = _sx_hi(a[0])
142
+ n = len(a) - 2
143
+ edges = [SwitchEdge(base + i, anchor + a[2 + i]) for i in range(n)]
144
+ edges.append(SwitchEdge(None, anchor + a[1], True))
145
+ return SwitchInfo(op, base, edges)
146
+
147
+
148
+ # --- control-flow facts (engine ABI) ----------------------------------------------------------------
149
+ # The flow-TERMINATOR opcodes: a path reaching one ENDS (the engine's per-function dispatch stops via
150
+ # adFin(); the IP never advances into adjacent bytecode). RET 0x04 / TerminateEntry 0x1C + the high ops
151
+ # whose DoEventCode return code routes through adFin(): Battle 0x2A / Field 0x2B / STOP 0x4F /
152
+ # TetraMaster 0xAE / WorldMap 0xB6 / GameOver 0xF5 (verified vs EBin.cs). NOTE: battle/ailint.py keeps a
153
+ # parallel private copy (it predates this) -- both are guarded by their whole-corpus soundness sweeps, which
154
+ # would fail if either drifted from the engine.
155
+ TERMINATOR_OPS = frozenset({0x04, 0x1C, 0x2A, 0x2B, 0x4F, 0xAE, 0xB6, 0xF5})
156
+ JUMP_OPS = frozenset({0x01, 0x02, 0x03}) # JMP / JMP_IFNOT / JMP_IF -- a 2-byte relative offset operand
157
+
158
+
159
+ def jump_target(ins: Instr) -> "int | None":
160
+ """The absolute byte target of a relative jump (0x01/0x02/0x03), or None if its offset is an expression.
161
+ Signedness MATCHES the engine: JMP (0x01) / JMP_IF (0x03) read a SIGNED int16; JMP_IFNOT (0x02) reads its
162
+ skip UNSIGNED -- so a backward JMP_IFNOT becomes a huge forward target a bounds check catches."""
163
+ if not ins.arg_is_expr or ins.arg_is_expr[0]:
164
+ return None
165
+ raw = ins.imm(0)
166
+ if raw is None:
167
+ return None
168
+ if ins.op == 0x02: # JMP_IFNOT -- engine reads this UNSIGNED
169
+ return ins.end + raw
170
+ return ins.end + (raw - 0x10000 if raw >= 0x8000 else raw) # JMP / JMP_IF -- signed int16
171
+
172
+
173
+ def read_expr(raw: bytes, pos: int) -> tuple[str, int]:
174
+ """Decode an expression token stream; returns (text, new_pos). Mirrors the engine."""
175
+ ops = []
176
+ while True:
177
+ o = raw[pos]; pos += 1
178
+ isconst = o in (0x7D, 0x7E)
179
+ isvar = o >= 0xC0 or o in (0x29, 0x5F, 0x78, 0x79, 0x7A)
180
+ if not isconst and not isvar:
181
+ ops.append(f"op{o:02X}")
182
+ if o == 0x7F:
183
+ break
184
+ continue
185
+ if o == 0x7E:
186
+ a = [raw[pos], raw[pos + 1], raw[pos + 2], raw[pos + 3]]; pos += 4
187
+ elif o >= 0xE0 or o in (0x78, 0x7D):
188
+ a = [raw[pos], raw[pos + 1]]; pos += 2
189
+ else:
190
+ a = [raw[pos]]; pos += 1
191
+ ops.append(f"op{o:02X}({','.join(str(x) for x in a)})")
192
+ return "{" + " ".join(ops) + "}", pos
193
+
194
+
195
+ def pretty_expr(raw: bytes, pos: int) -> tuple[str, int]:
196
+ """Decode an expression token stream to a HUMAN-READABLE form; returns (text, new_pos). Same byte-walk as
197
+ :func:`read_expr` but names each operator via the ``op_binary`` table and decodes a variable token into its
198
+ ``Source.Type[index]`` form (so a story-flag read shows as ``Global.Bit[8512]``, an enemy-HP read as
199
+ ``B_CURHP``). The read side of the battle-AI inspector; field scripts read the same way."""
200
+ from ._exprtable import expr_op_name, decode_var
201
+ out = []
202
+ while True:
203
+ o = raw[pos]; pos += 1
204
+ isconst = o in (0x7D, 0x7E)
205
+ isvar = o >= 0xC0 or o in (0x29, 0x5F, 0x78, 0x79, 0x7A)
206
+ if not isconst and not isvar: # a pure operator (no inline operand bytes)
207
+ out.append(expr_op_name(o))
208
+ if o == 0x7F: # B_EXPR_END
209
+ break
210
+ continue
211
+ if o == 0x7E: # B_CONST4 -- a 4-byte literal (distinct token so an
212
+ v = raw[pos] | (raw[pos + 1] << 8) | (raw[pos + 2] << 16) | (raw[pos + 3] << 24); pos += 4
213
+ out.append(f"const4({v})") # assemble() can round-trip it back to B_CONST4)
214
+ elif o == 0x7D: # B_CONST -- a 2-byte literal
215
+ v = raw[pos] | (raw[pos + 1] << 8); pos += 2
216
+ out.append(f"const({v})")
217
+ elif o == 0x78: # B_OBJSPECA -- obj-var read: uid (hi) + field (lo)
218
+ out.append(f"obj(uid={raw[pos]}).f[{raw[pos + 1]}]"); pos += 2
219
+ elif o in (0x79, 0x7A): # B_SYSLIST / B_SYSVAR -- 1-byte index
220
+ out.append(f"{expr_op_name(o)}[{raw[pos]}]"); pos += 1
221
+ elif o in (0x29, 0x5F): # B_MEMBER / B_PTR -- 1-byte operand
222
+ out.append(f"{expr_op_name(o)}({raw[pos]})"); pos += 1
223
+ elif o >= 0xE0: # a long-index variable (2-byte index)
224
+ out.append(decode_var(o, raw[pos] | (raw[pos + 1] << 8))); pos += 2
225
+ else: # a short-index variable (0xC0..0xDF, 1-byte index)
226
+ out.append(decode_var(o, raw[pos])); pos += 1
227
+ return "{" + " ".join(out) + "}", pos
228
+
229
+
230
+ def read_code(raw: bytes, pos: int) -> tuple[Instr, int]:
231
+ """Decode one instruction at *pos*; returns (Instr, new_pos)."""
232
+ start = pos
233
+ op = raw[pos]; pos += 1
234
+ if op == 0xFF:
235
+ op = 0x100 | raw[pos]; pos += 1
236
+ ac = OP_ARG_COUNT[op] if op < len(OP_ARG_COUNT) else 0
237
+ arg_flag = 0
238
+ if op >= 0x10 and ac != 0:
239
+ arg_flag = raw[pos]; pos += 1
240
+ if op == 0x05:
241
+ arg_flag = 1
242
+ if ac < 0:
243
+ ac = raw[pos]; pos += 1
244
+ if op == 0x0D:
245
+ ac |= raw[pos] << 8; pos += 1
246
+ if op == 0x06:
247
+ ac = 1 + 2 * ac
248
+ elif op in (0x0B, 0x0D):
249
+ ac = 2 + ac
250
+ args: list = []
251
+ is_expr: list[bool] = []
252
+ for i in range(ac):
253
+ if arg_flag & (1 << i):
254
+ s, pos = read_expr(raw, pos)
255
+ args.append(s); is_expr.append(True)
256
+ else:
257
+ sz = argsize(op, i)
258
+ v = 0
259
+ for k in range(sz):
260
+ v |= raw[pos + k] << (8 * k)
261
+ pos += sz
262
+ args.append(v); is_expr.append(False)
263
+ return Instr(start, op, args, is_expr, pos - start), pos
264
+
265
+
266
+ def iter_code(raw: bytes, start: int, end: int):
267
+ """Yield Instr objects decoded from raw[start:end]. Stops cleanly at *end*."""
268
+ pos = start
269
+ guard = 0
270
+ while pos < end and guard < 100000:
271
+ instr, pos = read_code(raw, pos)
272
+ yield instr
273
+ guard += 1
274
+
275
+
276
+ def _expr_uid_offsets(raw: bytes, pos: int) -> tuple[int, list]:
277
+ """Walk one expression token stream (mirrors :func:`read_expr`); return (new_pos, uid_offsets) where
278
+ each uid_offset is the absolute byte offset of a ``0x78`` (B_OBJSPECA) token's UID operand byte (the
279
+ first of its two data bytes -- ``78 <uid> <field>``, uid first)."""
280
+ offs = []
281
+ while True:
282
+ o = raw[pos]; pos += 1
283
+ isconst = o in (0x7D, 0x7E)
284
+ isvar = o >= 0xC0 or o in (0x29, 0x5F, 0x78, 0x79, 0x7A)
285
+ if not isconst and not isvar:
286
+ if o == 0x7F:
287
+ break
288
+ continue
289
+ if o == 0x7E:
290
+ pos += 4
291
+ elif o >= 0xE0 or o in (0x78, 0x7D):
292
+ if o == 0x78:
293
+ offs.append(pos) # the UID byte (first data byte of the obj-var token)
294
+ pos += 2
295
+ else:
296
+ pos += 1
297
+ return pos, offs
298
+
299
+
300
+ def expr_obj_uid_offsets(raw: bytes, start: int, end: int) -> list:
301
+ """Absolute byte offsets of every ``0x78`` (B_OBJSPECA, obj-var read) token's UID operand byte in
302
+ ``raw[start:end]``. Decodes instruction-by-instruction exactly like :func:`read_code` and walks each
303
+ EXPRESSION operand's token stream -- NOT a raw-byte ``0x78`` scan (which false-positives on const data,
304
+ per docs/OBJECT_CARRY.md S3 invariant 2). The object graft uses this to remap a sibling uid read inside
305
+ an expression operand (a same-length 1-byte patch). Mirrors ``read_code``'s operand decode."""
306
+ out = []
307
+ pos = start
308
+ while pos < end:
309
+ op = raw[pos]; pos += 1
310
+ if op == 0xFF:
311
+ op = 0x100 | raw[pos]; pos += 1
312
+ ac = OP_ARG_COUNT[op] if op < len(OP_ARG_COUNT) else 0
313
+ arg_flag = 0
314
+ if op >= 0x10 and ac != 0:
315
+ arg_flag = raw[pos]; pos += 1
316
+ if op == 0x05:
317
+ arg_flag = 1
318
+ if ac < 0:
319
+ ac = raw[pos]; pos += 1
320
+ if op == 0x0D:
321
+ ac |= raw[pos] << 8; pos += 1
322
+ if op == 0x06:
323
+ ac = 1 + 2 * ac
324
+ elif op in (0x0B, 0x0D):
325
+ ac = 2 + ac
326
+ for i in range(ac):
327
+ if arg_flag & (1 << i):
328
+ pos, uoffs = _expr_uid_offsets(raw, pos)
329
+ out.extend(uoffs)
330
+ else:
331
+ pos += argsize(op, i)
332
+ return out