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,76 @@
1
+ """Regenerate ``_optables.py`` from a local Memoria source checkout.
2
+
3
+ This is a *maintainer* tool, not part of the runtime. The opcode tables are baked into
4
+ ``_optables.py`` so the kit needs no Memoria source at runtime. Run this only when updating
5
+ to a newer Memoria that changed the opcode tables:
6
+
7
+ python -m ff9mapkit.eb._regen_optables --memoria "C:/path/to/Memoria"
8
+
9
+ It reads ``Assembly-CSharp/Global/Event/Engine/EventEngineUtils.cs`` (opArgCount, opArgSize)
10
+ and ``EventEngine.DoEventCode.cs`` (opcode names) and rewrites ``_optables.py`` in place.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import re
17
+ from pathlib import Path
18
+
19
+
20
+ def parse_tables(memoria_root: Path):
21
+ utils = memoria_root / "Assembly-CSharp" / "Global" / "Event" / "Engine" / "EventEngineUtils.cs"
22
+ doc = memoria_root / "Assembly-CSharp" / "Global" / "Event" / "Engine" / "EventEngine.DoEventCode.cs"
23
+ src = utils.read_text(encoding="utf-8", errors="replace")
24
+
25
+ m = re.search(r"opArgCount\s*=\s*new\s+SByte\[\]\s*\{(.*?)\}", src, re.S)
26
+ op_arg_count = [int(x) for x in re.findall(r"-?\d+", m.group(1))]
27
+
28
+ m = re.search(r"opArgSize\s*=\s*new\s+Byte\[\]\[\]\s*\{(.*?)\n\s*\};", src, re.S)
29
+ body = (m.group(1).replace("new Byte[]{", "[").replace("new Byte[] {", "[")
30
+ .replace("}", "]").replace("null", "None"))
31
+ op_arg_size = eval("[" + body + "]") # noqa: S307 - trusted, generated from source we control
32
+
33
+ names: dict[int, str] = {}
34
+ for line in doc.read_text(encoding="utf-8", errors="replace").splitlines():
35
+ mm = re.search(r'case EBin\.event_code_binary\.(\w+):\s*//\s*(0x[0-9A-Fa-f]+),\s*"([^"]+)"', line)
36
+ if mm:
37
+ names[int(mm.group(2), 16)] = mm.group(3)
38
+ return op_arg_count, op_arg_size, names
39
+
40
+
41
+ def render(op_arg_count, op_arg_size, names) -> str:
42
+ def fmt_count(lst, perline=20):
43
+ rows = []
44
+ for i in range(0, len(lst), perline):
45
+ rows.append(" " + ", ".join(repr(x) for x in lst[i:i + perline]) + ",")
46
+ return "\n".join(rows)
47
+
48
+ header = ('"""Auto-generated FF9 event-script opcode tables (snapshot of Memoria source).\n\n'
49
+ "DO NOT EDIT BY HAND. Regenerate with: python -m ff9mapkit.eb._regen_optables\n"
50
+ "Source: Memoria Assembly-CSharp/Global/Event/Engine/EventEngineUtils.cs (opArgCount, opArgSize)\n"
51
+ " and EventEngine.DoEventCode.cs (opcode names).\n\n"
52
+ " OP_ARG_COUNT[op] : number of operands. Negative => variable (count read from the stream).\n"
53
+ " OP_ARG_SIZE[op] : per-operand byte width (None where unused / variable).\n"
54
+ " OP_NAMES[op] : human-readable mnemonic (cosmetic; missing entries fall back to op_XX).\n"
55
+ '"""\n')
56
+ out = header + "\n"
57
+ out += "OP_ARG_COUNT = [\n" + fmt_count(op_arg_count) + "\n]\n\n"
58
+ out += "OP_ARG_SIZE = [\n" + "\n".join(f" {x!r}," for x in op_arg_size) + "\n]\n\n"
59
+ out += "OP_NAMES = {\n" + "\n".join(f" 0x{k:02X}: {v!r}," for k, v in sorted(names.items())) + "\n}\n"
60
+ return out
61
+
62
+
63
+ def main(argv=None) -> int:
64
+ ap = argparse.ArgumentParser(description="Regenerate eb/_optables.py from Memoria source.")
65
+ ap.add_argument("--memoria", required=True, help="path to a Memoria source checkout")
66
+ args = ap.parse_args(argv)
67
+ counts, sizes, names = parse_tables(Path(args.memoria))
68
+ text = render(counts, sizes, names)
69
+ target = Path(__file__).with_name("_optables.py")
70
+ target.write_text(text, encoding="utf-8", newline="\n")
71
+ print(f"wrote {target} (OP_ARG_COUNT={len(counts)}, OP_ARG_SIZE={len(sizes)}, OP_NAMES={len(names)})")
72
+ return 0
73
+
74
+
75
+ if __name__ == "__main__":
76
+ raise SystemExit(main())
ff9mapkit/eb/cmdasm.py ADDED
@@ -0,0 +1,323 @@
1
+ """Phase-6c-ii: the `.eb` COMMAND ASSEMBLER -- assemble whole instructions (the inverse of `disasm.read_code`).
2
+
3
+ Phase-6c-i (`exprasm`) assembles one EXPRESSION; this assembles a whole INSTRUCTION (opcode + arg-flag +
4
+ operands) and a BLOCK of them -- the body of a new enemy-AI branch that the existing length-changing primitives
5
+ (`eb.edit.add_function` / `replace_function_body`) splice into a forked battle `.eb`. It MIRRORS `read_code`'s
6
+ byte-walk step for step (the `0xFF` extended page, the `argFlag` byte for `op >= 0x10`, the forced-expr `SET`=0x05,
7
+ the stream-read operand count for the variable ops 0x06/0x0B/0x0D and the count-prefixed 0x29), so it reproduces
8
+ the exact bytes `read_code` decoded -- given operands in the PRETTY-decoded form (mnemonic via the disassembler's
9
+ `_cmd_name`, expression operands as `{ ... }` from `pretty_expr`, the form `battleai._decode_func_pretty` emits).
10
+ Expression operands go through `exprasm.assemble`; immediates are little-endian of the opcode's `argsize`.
11
+
12
+ `assemble_block` adds the authoring layer: `label:` lines + symbolic jump targets (`JMP done`,
13
+ `JMP_IF {expr} loop`) resolved to the signed relative offsets the engine expects, in a two-pass walk (instruction
14
+ sizes are independent of jump *values* -- a jump immediate is always 2 bytes -- so offsets are known before the
15
+ targets are). Provenance-clean: only the open-source opcode/operator NAMES are used.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from . import disasm as _disasm
20
+ from . import exprasm
21
+ from ._optables import OP_ARG_COUNT, OP_NAMES
22
+ from .disasm import argsize
23
+
24
+ # the control-op overlay (mirrors battleai._CTRL_NAMES / disasm naming precedence): the low ops EBin handles in
25
+ # jumpToCommand, which OP_NAMES leaves unnamed. The disassembler names a command CTRL-first, then OP_NAMES.
26
+ _CTRL_NAMES = {0x01: "JMP", 0x02: "JMP_IFNOT", 0x03: "JMP_IF", 0x04: "RET", 0x05: "SET", 0x06: "SWITCHEX",
27
+ 0x0B: "SWITCH", 0x0D: "SWITCH2"}
28
+
29
+ # name -> opcode, the inverse of disasm._cmd_name. Build OP_NAMES first, then OVERLAY the control names (so they
30
+ # win for 0x01-0x0D exactly as the decoder's precedence does).
31
+ _OP_BY_NAME: dict[str, int] = {}
32
+ for _op, _nm in OP_NAMES.items():
33
+ _OP_BY_NAME.setdefault(_nm, _op)
34
+ for _op, _nm in _CTRL_NAMES.items():
35
+ _OP_BY_NAME[_nm] = _op
36
+
37
+ # the jump ops whose single immediate is a SIGNED relative offset (target = instr_end + offset) -- the only ops
38
+ # whose operand may be a symbolic label in a block.
39
+ _JUMP_OPS = {0x01, 0x02, 0x03}
40
+ # the switch ops: their case/default targets are FORWARD reloffsets from an ANCHOR = instr_off + this delta
41
+ # (EBin.cs JMP_SWITCHEX/JMP_SWITCH; validated by disasm.decode_switch). Their reloff operands may be labels too.
42
+ _SWITCH_ANCHOR = {0x06: 4, 0x0B: 1, 0x0D: 2}
43
+
44
+
45
+ def _is_switch_reloff(op: int, i: int) -> bool:
46
+ """True if operand ``i`` of switch ``op`` is a case/default RELOFFSET (a label target), not a base/value.
47
+ 0x06 SWITCHEX: [defaultReloff, val0, reloff0, ...] -> reloffs at the EVEN indices. 0x0B/0x0D SWITCH:
48
+ [base, defaultReloff, reloff0, ...] -> reloffs at index >= 1 (index 0 is the base)."""
49
+ if op == 0x06:
50
+ return i % 2 == 0
51
+ if op in (0x0B, 0x0D):
52
+ return i >= 1
53
+ return False
54
+
55
+
56
+ class CmdAsmError(ValueError):
57
+ pass
58
+
59
+
60
+ def _resolve_op(name: str) -> int:
61
+ """Mnemonic -> opcode (the inverse of disasm naming). Accepts the `op_XX` fallback for an unnamed opcode."""
62
+ if name in _OP_BY_NAME:
63
+ return _OP_BY_NAME[name]
64
+ if name.startswith("op_"):
65
+ try:
66
+ return int(name[3:], 16)
67
+ except ValueError:
68
+ pass
69
+ raise CmdAsmError(f"unknown command mnemonic {name!r}")
70
+
71
+
72
+ def _emit_op(op: int) -> bytearray:
73
+ out = bytearray()
74
+ if op >= 0x100: # extended page -- a 0xFF prefix selects it
75
+ out += bytes((0xFF, op & 0xFF))
76
+ else:
77
+ out.append(op)
78
+ return out
79
+
80
+
81
+ def _imm_bytes(op: int, i: int, value: int) -> bytes:
82
+ sz = argsize(op, i)
83
+ if sz <= 0:
84
+ raise CmdAsmError(f"{OP_NAMES.get(op, hex(op))} operand {i} has no immediate width (expected an expression?)")
85
+ if not 0 <= value <= (1 << (8 * sz)) - 1:
86
+ raise CmdAsmError(f"operand {i} value {value} out of range for a {sz}-byte immediate")
87
+ return value.to_bytes(sz, "little")
88
+
89
+
90
+ def assemble_instruction(name: str, operands, *, label_offsets=None, instr_end: int | None = None,
91
+ instr_off: int | None = None) -> bytes:
92
+ """Assemble ONE instruction -> its bytes, the exact inverse of `read_code`. ``operands`` is the decoded
93
+ operand list (each an immediate int/str, a ``{ ... }`` expression string, or -- for a jump or a switch
94
+ case/default target -- a label name). ``label_offsets``/``instr_end`` resolve a symbolic JUMP target;
95
+ ``label_offsets``/``instr_off`` resolve a symbolic SWITCH target (a forward reloffset from the anchor)."""
96
+ operands = [str(o) for o in operands]
97
+ op = _resolve_op(name)
98
+ out = _emit_op(op)
99
+ ac0 = OP_ARG_COUNT[op] if op < len(OP_ARG_COUNT) else 0
100
+ is_expr = [o.startswith("{") for o in operands]
101
+
102
+ if op >= 0x10 and ac0 != 0: # an argFlag byte: bit i set == operand i is an expr
103
+ flag = 0
104
+ for i, e in enumerate(is_expr):
105
+ if e:
106
+ flag |= 1 << i
107
+ out.append(flag)
108
+ # op 0x05 (SET) forces argFlag=1 with NO byte on the wire -- its single operand is always an expression.
109
+ if op == 0x05 and (len(operands) != 1 or not is_expr[0]):
110
+ raise CmdAsmError("SET (0x05) takes exactly one { ... } expression operand")
111
+
112
+ if ac0 < 0: # a variable operand count, read from the stream
113
+ c = len(operands)
114
+ if op == 0x06: # SWITCHEX: count = 1 + 2n -> n
115
+ if c < 1 or (c - 1) % 2:
116
+ raise CmdAsmError(f"SWITCHEX needs an odd operand count (1+2n), got {c}")
117
+ out.append((c - 1) // 2)
118
+ elif op == 0x0B: # SWITCH: count = 2 + n
119
+ if c < 2:
120
+ raise CmdAsmError(f"SWITCH needs >=2 operands, got {c}")
121
+ out.append(c - 2)
122
+ elif op == 0x0D: # SWITCH2: count = 2 + n, n is 2 bytes
123
+ if c < 2:
124
+ raise CmdAsmError(f"SWITCH2 needs >=2 operands, got {c}")
125
+ out += (c - 2).to_bytes(2, "little")
126
+ else: # 0x29 etc.: a plain 1-byte count == operand count
127
+ out.append(c)
128
+ elif op != 0x05 and len(operands) != ac0:
129
+ raise CmdAsmError(f"{name} takes {ac0} operand(s), got {len(operands)}")
130
+
131
+ for i, o in enumerate(operands):
132
+ if is_expr[i]:
133
+ out += exprasm.assemble(o)
134
+ elif op in _JUMP_OPS and not o.lstrip("-").isdigit(): # a symbolic JUMP label target (signed, rel to end)
135
+ if label_offsets is None or instr_end is None:
136
+ raise CmdAsmError(f"jump to label {o!r} outside assemble_block (no label table)")
137
+ if o not in label_offsets:
138
+ raise CmdAsmError(f"undefined label {o!r}")
139
+ rel = label_offsets[o] - instr_end
140
+ if op == 0x02: # JMP_IFNOT (beq) reads its offset UNSIGNED in-engine
141
+ if rel < 0:
142
+ raise CmdAsmError(f"JMP_IFNOT cannot branch BACKWARD to {o!r}: the engine reads its skip offset "
143
+ f"unsigned, so a negative target executes as a ~64KB forward jump (crash). Use "
144
+ f"JMP_IF over a forward JMP, or invert the condition. (JMP/JMP_IF are signed.)")
145
+ if rel > 0xFFFF:
146
+ raise CmdAsmError(f"JMP_IFNOT to {o!r} is {rel} bytes forward, past the unsigned 16-bit reach "
147
+ f"(max 65535); the function is too large for this branch (split it).")
148
+ elif not -0x8000 <= rel <= 0x7FFF: # JMP/JMP_IF read a SIGNED int16 -- a span that masks
149
+ raise CmdAsmError(f"jump to {o!r} is {rel} bytes away, out of signed int16 range [-32768, 32767]; "
150
+ f"the function is too large for this branch (split it or shorten the span).")
151
+ out += (rel & 0xFFFF).to_bytes(2, "little")
152
+ elif op in _SWITCH_ANCHOR and _is_switch_reloff(op, i) and not o.lstrip("-").isdigit():
153
+ if label_offsets is None or instr_off is None: # a symbolic SWITCH target (forward reloff from anchor)
154
+ raise CmdAsmError(f"switch label {o!r} outside assemble_block (no label table)")
155
+ if o not in label_offsets:
156
+ raise CmdAsmError(f"undefined label {o!r}")
157
+ rel = label_offsets[o] - (instr_off + _SWITCH_ANCHOR[op])
158
+ if rel < 0: # the engine reads switch reloffsets unsigned -> forward only
159
+ raise CmdAsmError(f"switch case to {o!r} is BACKWARD (reloff {rel}); the engine reads a switch "
160
+ "reloffset unsigned, so only forward targets are valid")
161
+ if rel > 0xFFFF: # a switch reloff is an unsigned u16 (max 65535)
162
+ raise CmdAsmError(f"switch case to {o!r} is {rel} bytes past the anchor, out of unsigned u16 range "
163
+ f"[0, 65535]; the function body exceeds the reachable switch span (split it).")
164
+ out += rel.to_bytes(2, "little")
165
+ else:
166
+ out += _imm_bytes(op, i, int(o))
167
+ return bytes(out)
168
+
169
+
170
+ def _split_operands(inner: str) -> list:
171
+ """Split an instruction's operand list on top-level commas (respecting ``{}`` / ``()`` / ``[]`` nesting)."""
172
+ out, depth, cur = [], 0, ""
173
+ for ch in inner:
174
+ if ch in "{([":
175
+ depth += 1
176
+ elif ch in "})]":
177
+ depth -= 1
178
+ if depth < 0:
179
+ raise CmdAsmError(f"unbalanced bracket in operands: {inner!r}")
180
+ if ch == "," and depth == 0:
181
+ out.append(cur.strip())
182
+ cur = ""
183
+ else:
184
+ cur += ch
185
+ if depth != 0:
186
+ raise CmdAsmError(f"unbalanced bracket in operands: {inner!r}")
187
+ if cur.strip():
188
+ out.append(cur.strip())
189
+ return out
190
+
191
+
192
+ def _parse_line(line: str):
193
+ """A source line -> ('label', name) | ('instr', name, [operands]) | None (blank/comment)."""
194
+ line = line.split("#", 1)[0].strip()
195
+ if not line:
196
+ return None
197
+ if line.endswith(":") and "(" not in line:
198
+ return ("label", line[:-1].strip())
199
+ if not line.endswith(")") or "(" not in line:
200
+ raise CmdAsmError(f"malformed instruction line {line!r} (want Mnemonic(op, op) or label:)")
201
+ name, inner = line[:line.index("(")].strip(), line[line.index("(") + 1:-1]
202
+ return ("instr", name, _split_operands(inner))
203
+
204
+
205
+ def _instr_len(name: str, operands) -> int:
206
+ """Byte length of an instruction. A jump's / switch-target's immediate is always 2 bytes regardless of its
207
+ (maybe still-unknown) target, so substitute a placeholder 0 for every symbolic-label operand and assemble it
208
+ to MEASURE the length (length is independent of any jump/switch reloffset VALUE)."""
209
+ probe = [0 if (not str(o).startswith("{") and not str(o).lstrip("-").isdigit()) else o for o in operands]
210
+ return len(assemble_instruction(name, probe))
211
+
212
+
213
+ def assemble_block(text: str) -> bytes:
214
+ """Assemble a BLOCK of instructions (one per line, ``label:`` lines allowed) -> the branch-body bytes.
215
+
216
+ Two passes: pass 1 lays out instruction offsets (sizes are known up front -- a jump immediate is always 2
217
+ bytes) and records each ``label:``'s offset; pass 2 emits, resolving every symbolic jump target to the signed
218
+ relative offset (``target - instr_end``) the engine reads. The output is the ``body`` for
219
+ `eb.edit.add_function` / `replace_function_body`."""
220
+ items = [p for p in (_parse_line(ln) for ln in text.splitlines()) if p is not None]
221
+ if not any(it[0] == "instr" for it in items):
222
+ raise CmdAsmError("empty block")
223
+
224
+ # pass 1 -- offsets + label table
225
+ label_offsets: dict[str, int] = {}
226
+ pos = 0
227
+ for it in items:
228
+ if it[0] == "label":
229
+ if it[1] in label_offsets:
230
+ raise CmdAsmError(f"duplicate label {it[1]!r}")
231
+ label_offsets[it[1]] = pos
232
+ else:
233
+ pos += _instr_len(it[1], it[2])
234
+
235
+ # pass 2 -- emit with resolved jumps + switch targets (instr_off = this instruction's start; instr_end its end)
236
+ out = bytearray()
237
+ for it in items:
238
+ if it[0] == "label":
239
+ continue
240
+ instr_off = len(out)
241
+ instr_end = instr_off + _instr_len(it[1], it[2])
242
+ out += assemble_instruction(it[1], it[2], label_offsets=label_offsets, instr_end=instr_end,
243
+ instr_off=instr_off)
244
+ return bytes(out)
245
+
246
+
247
+ # --------------------------------------------------------------------------- the labeled DISASSEMBLER (4b keystone)
248
+
249
+ def _switch_labeled_ops(ins, start: int) -> list:
250
+ """The labeled operand list for a switch ``Instr``: case/default reloffsets become ``L<rel>`` labels
251
+ (function-relative), the base / case-VALUES stay immediates -- the form :func:`assemble_block` resolves
252
+ back via the anchor. Mirrors :func:`disasm.decode_switch`'s operand layout."""
253
+ sw = ins.switch()
254
+ cases = [e for e in sw.edges if not e.is_default]
255
+ default = next(e for e in sw.edges if e.is_default)
256
+ if ins.op == 0x06: # SWITCHEX: defaultLabel, val0, label0, val1, ...
257
+ ops = [f"L{default.target - start}"]
258
+ for e in cases:
259
+ ops += [str(e.value), f"L{e.target - start}"]
260
+ return ops
261
+ # 0x0B / 0x0D: base, defaultLabel, caseLabel0, ... -- the base is a SIGNED selector decoded via sx_hi, so
262
+ # re-emit its RAW u16 (base & 0xFFFF) for the 2-byte immediate (sx_hi(base & 0xFFFF) == base round-trips).
263
+ return [str(sw.base & 0xFFFF), f"L{default.target - start}"] + [f"L{e.target - start}" for e in cases]
264
+
265
+
266
+ def disassemble_items(raw: bytes, start: int, end: int) -> list:
267
+ """The inverse of :func:`assemble_block` as a structured list of ``(rel_off | None, text)`` -- one entry per
268
+ SOURCE line, where ``rel_off`` is the function-relative byte offset of an INSTRUCTION line and ``None`` marks
269
+ a ``L<n>:`` label line. Every JUMP and every SWITCH case/default target is rendered as a function-relative
270
+ ``L<n>`` label, so re-assembling (:func:`assemble_block`) reproduces the bytes byte-for-byte AND a length
271
+ change between a branch and its target RELOCATES automatically. The structured form lets a length-changing
272
+ rebuild splice new source at a precise instruction boundary (then reassemble + splice via
273
+ :func:`ff9mapkit.eb.edit.replace_function_body`). Computed (expression-operand) jumps/switches that can't be
274
+ resolved offline keep their raw decoded operands (the round-trip still holds; they just don't relocate)."""
275
+ from ..battle.battleai import _decode_func_pretty # the pretty operand renderer (general bytecode)
276
+ end = min(end, len(raw)) # a truncated/corrupt/forked .eb can claim a func past the buffer
277
+ try:
278
+ instrs = list(_disasm.iter_code(raw, start, end))
279
+ pretty = {off: (mn, ops) for off, mn, ops in _decode_func_pretty(raw, start, end)}
280
+ except IndexError as ex: # a malformed expr/operand stream runs off the buffer
281
+ raise CmdAsmError(f"truncated/malformed bytecode in raw[{start}:{end}]: {ex}") from ex
282
+ targets: set = set()
283
+ for ins in instrs:
284
+ if ins.op in _disasm.JUMP_OPS and _disasm.jump_target(ins) is not None:
285
+ targets.add(_disasm.jump_target(ins) - start)
286
+ elif ins.is_switch and ins.switch() is not None:
287
+ for e in ins.switch().edges:
288
+ targets.add(e.target - start)
289
+ items, seen = [], set() # (rel_off | None, text); None = a label line
290
+ for ins in instrs:
291
+ rel = ins.off - start
292
+ if rel in targets:
293
+ items.append((None, f"L{rel}:"))
294
+ seen.add(rel)
295
+ mn, ops = pretty.get(ins.off, (_cmd_fallback(ins.op), []))
296
+ if ins.op in _disasm.JUMP_OPS and _disasm.jump_target(ins) is not None:
297
+ ops = [f"L{_disasm.jump_target(ins) - start}"]
298
+ elif ins.is_switch and ins.switch() is not None:
299
+ ops = _switch_labeled_ops(ins, start)
300
+ items.append((rel, f"{mn}({', '.join(ops)})"))
301
+ end_rel = end - start
302
+ if end_rel in targets and end_rel not in seen: # a branch to end-of-function -> a trailing label
303
+ items.append((None, f"L{end_rel}:"))
304
+ return items
305
+
306
+
307
+ def disassemble_block(raw: bytes, start: int, end: int) -> str:
308
+ """``disassemble_items`` joined into one source string (see that function for the contract)."""
309
+ return "\n".join(text for _off, text in disassemble_items(raw, start, end))
310
+
311
+
312
+ def _cmd_fallback(op: int) -> str:
313
+ return OP_NAMES.get(op, f"op_{op:02X}")
314
+
315
+
316
+ _LABEL_RE = __import__("re").compile(r"\bL(\d+)\b")
317
+
318
+
319
+ def relabel(src: str, prefix: str) -> str:
320
+ """Rename every ``L<n>`` label (definition + reference) in ``src`` to ``<prefix>L<n>`` so a snippet's labels
321
+ can't collide with the labels of a function it is spliced into. Labels are always ``L<digits>`` (instruction
322
+ offsets); operator/mnemonic tokens never match that shape, so this is safe on assemble_block source."""
323
+ return _LABEL_RE.sub(lambda m: f"{prefix}L{m.group(1)}", src)