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,220 @@
1
+ """NET-NEW raw17 sequence authoring -- the LENGTH-CHANGING tier (read = :mod:`seqdis`, same-length = :mod:`seqpatch`).
2
+ The analog of ``battle/aiauthor`` for enemy AI: assemble a whole attack-sequence body from source (:mod:`seqasm`)
3
+ and SPLICE it in, driving the offset-fixup repack (:func:`seqcodec.serialize_repacked`) so every ``seqOffset`` +
4
+ ``camOffset`` is recomputed and the camera block re-appends intact.
5
+
6
+ ``[[scene.seq_replace]]`` (seq = sub_no, source = ...) replaces an existing attack's choreography wholesale. A
7
+ brand-NEW attack slot (growing ``seqCount`` + wiring a raw16 ``AA_DATA`` + the ``.eb`` AI to select it) is a further
8
+ step; replace is the keystone primitive it builds on. Authoring is lint-gated on the two per-file cross-references
9
+ the engine doesn't bounds-check: an out-of-range ``Anim`` code (``IndexOutOfRange`` on ``animList``) and a
10
+ ``SetCamera``/``RunCamera`` id past the file's camera count (a stuck/black native camera) -- both fail the build,
11
+ not the game.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from . import camera_codec as _cc
16
+ from . import seqasm as _seqasm
17
+ from . import seqcodec as _sc
18
+
19
+
20
+ class SeqAuthorError(ValueError):
21
+ pass
22
+
23
+
24
+ _CAMERA_OPS = (0x10, 0x12, 0x20) # SetCamera / RunCamera / RunCameraForced (cam id @ operand 0)
25
+
26
+
27
+ def lint_seq(raw17: bytes) -> list:
28
+ """Offline semantic problems of a raw17's sequences (empty => OK). The codec already guarantees decode-to-
29
+ terminator / opcode in range / no overrun / disjoint bodies; this adds the SEMANTIC cross-references the codec
30
+ can't see -- the two operands whose safe range is a function of THIS file's own contents:
31
+ * an ``Anim`` (0x05) code whose ``seqBaseAnim[sub] + code`` indexes past ``animList`` (the engine does no
32
+ bounds check -> IndexOutOfRange);
33
+ * a ``SetCamera``/``RunCamera`` ``cam`` id >= the raw17's own camera count (the closed native SFX plugin
34
+ selects a non-existent camera -> a stuck/black camera).
35
+ Both are checked for every sub_no pointing at each body (aliases can differ)."""
36
+ try:
37
+ model = _sc.parse(raw17)
38
+ except _sc.SeqCodecError as ex:
39
+ return [f"unparseable raw17: {ex}"]
40
+ cam_count = None # the camera block may be absent/garbage on a hand-built
41
+ try: # model -> skip the cam check rather than crash the lint
42
+ cam_count = len(_cc.parse_block(raw17)[1])
43
+ except Exception: # noqa: BLE001 -- CameraCodecError or a malformed block
44
+ cam_count = None
45
+ problems = []
46
+ n = model.anim_count
47
+ for sub in range(model.seq_count):
48
+ body = model.body_for(sub)
49
+ if body is None:
50
+ continue
51
+ base = model.seq_base_anim[sub]
52
+ for ins in body.instrs:
53
+ if ins.op == 0x05 and ins.operands[0] != 255: # Anim, non-idle
54
+ idx = base + ins.operands[0]
55
+ if idx >= n: # base+code of two unsigned bytes is always >= 0
56
+ problems.append(f"sub {sub}: Anim code {ins.operands[0]} -> animList[{base}+{ins.operands[0]}]"
57
+ f" = {idx} is out of range (animCount {n}) -- would crash the engine")
58
+ elif ins.op in _CAMERA_OPS and cam_count is not None and ins.operands[0] >= cam_count:
59
+ problems.append(f"sub {sub}: {ins.name} cam {ins.operands[0]} exceeds the {cam_count} camera(s) "
60
+ f"in this raw17 -- no camera entry to play (stuck/black camera)")
61
+ return problems
62
+
63
+
64
+ def replace_sequence(raw17: bytes, sub_no: int, source: str) -> tuple:
65
+ """Replace sub_no's attack-sequence body with one assembled from ``source`` (a :mod:`seqasm` block ending in a
66
+ terminator). Returns (new_raw17, warnings). The whole file is repacked (offsets recomputed, camera block kept).
67
+ Raises SeqAuthorError on a bad sub_no / unassemblable source / a body whose edit would crash (lint)."""
68
+ try:
69
+ model = _sc.parse(raw17)
70
+ except _sc.SeqCodecError as ex:
71
+ raise SeqAuthorError(f"malformed raw17: {ex}")
72
+ if not isinstance(sub_no, int) or isinstance(sub_no, bool) or not 0 <= sub_no < model.seq_count:
73
+ raise SeqAuthorError(f"seq = {sub_no!r} is not a valid sub_no (0..{model.seq_count - 1})")
74
+ if model.seq_offset[sub_no] == 0:
75
+ raise SeqAuthorError(f"seq {sub_no} has no sequence (seqOffset 0 sentinel) -- nothing to replace")
76
+ body = model.body_for(sub_no)
77
+ try:
78
+ instrs = _seqasm.assemble(source)
79
+ except _seqasm.SeqAsmError as ex:
80
+ raise SeqAuthorError(f"seq {sub_no} source: {ex}")
81
+ warnings = []
82
+ others = tuple(s for s in range(model.seq_count) if model.seq_offset[s] == body.offset and s != sub_no)
83
+ if others:
84
+ warnings.append(f"seq {sub_no} also shares its body with sub(s) {','.join(str(s) for s in others)} "
85
+ f"-- replacing it rewrites ALL of them")
86
+ body.instrs = [_sc.Instr(i.op, 0, list(i.operands)) for i in instrs] # offsets are recomputed by the repack
87
+ try:
88
+ out = _sc.serialize_repacked(model) # may raise on an i16-overflowing body
89
+ except _sc.SeqCodecError as ex:
90
+ raise SeqAuthorError(f"seq {sub_no}: {ex}")
91
+ problems = lint_seq(out) # the composed result must lint clean
92
+ if problems:
93
+ raise SeqAuthorError(f"seq {sub_no} would produce an invalid raw17: {'; '.join(problems)}")
94
+ return out, warnings
95
+
96
+
97
+ def _locate(body, locator, kind: str) -> int:
98
+ """Resolve a ``before``/``after`` locator -> an instruction INDEX in ``body.instrs``. The locator is an int
99
+ (instruction index) or a str (an opcode NAME -> its first occurrence)."""
100
+ if isinstance(locator, bool) or not isinstance(locator, (int, str)):
101
+ raise SeqAuthorError(f"{kind} must be an instruction index (int) or an opcode name (str)")
102
+ if isinstance(locator, int):
103
+ if not 0 <= locator < len(body.instrs):
104
+ raise SeqAuthorError(f"{kind} = {locator} out of range (0..{len(body.instrs) - 1})")
105
+ return locator
106
+ for idx, ins in enumerate(body.instrs):
107
+ if ins.name == locator:
108
+ return idx
109
+ raise SeqAuthorError(f"{kind} = {locator!r}: no such opcode in this sequence "
110
+ f"(has {[i.name for i in body.instrs]})")
111
+
112
+
113
+ def insert_sequence(raw17: bytes, sub_no: int, source: str, *, before=None, after=None) -> tuple:
114
+ """Splice an assembled FRAGMENT (no terminator) into sub_no's body at a ``before``/``after`` locator (an
115
+ instruction index or an opcode name). The body's terminator stays last. Returns (new_raw17, warnings)."""
116
+ try:
117
+ model = _sc.parse(raw17)
118
+ except _sc.SeqCodecError as ex:
119
+ raise SeqAuthorError(f"malformed raw17: {ex}")
120
+ if not isinstance(sub_no, int) or isinstance(sub_no, bool) or not 0 <= sub_no < model.seq_count:
121
+ raise SeqAuthorError(f"seq = {sub_no!r} is not a valid sub_no (0..{model.seq_count - 1})")
122
+ if model.seq_offset[sub_no] == 0:
123
+ raise SeqAuthorError(f"seq {sub_no} has no sequence (seqOffset 0 sentinel)")
124
+ if (before is None) == (after is None):
125
+ raise SeqAuthorError("give exactly one of before / after (an instruction index or opcode name)")
126
+ body = model.body_for(sub_no)
127
+ try:
128
+ frag = _seqasm.assemble_fragment(source)
129
+ except _seqasm.SeqAsmError as ex:
130
+ raise SeqAuthorError(f"seq {sub_no} fragment: {ex}")
131
+ pos = _locate(body, before, "before") if before is not None else _locate(body, after, "after") + 1
132
+ if pos >= len(body.instrs): # never splice at/after the terminator
133
+ raise SeqAuthorError(f"seq {sub_no}: insert position {pos} is at/after the terminator -- "
134
+ f"insert before the final {body.instrs[-1].name}")
135
+ warnings = []
136
+ others = tuple(s for s in range(model.seq_count) if model.seq_offset[s] == body.offset and s != sub_no)
137
+ if others:
138
+ warnings.append(f"seq {sub_no} also shares its body with sub(s) {','.join(str(s) for s in others)} "
139
+ f"-- the insert applies to ALL of them")
140
+ new = [_sc.Instr(i.op, 0, list(i.operands)) for i in body.instrs]
141
+ for k, ins in enumerate(frag):
142
+ new.insert(pos + k, _sc.Instr(ins.op, 0, list(ins.operands)))
143
+ body.instrs = new
144
+ try:
145
+ out = _sc.serialize_repacked(model)
146
+ except _sc.SeqCodecError as ex:
147
+ raise SeqAuthorError(f"seq {sub_no}: {ex}")
148
+ problems = lint_seq(out)
149
+ if problems:
150
+ raise SeqAuthorError(f"seq {sub_no} insert would produce an invalid raw17: {'; '.join(problems)}")
151
+ return out, warnings
152
+
153
+
154
+ def apply_seq_inserts(raw17: bytes, specs) -> tuple:
155
+ """Apply ``[{seq, source, before|after}, ...]`` fragment inserts in order. Returns (new_raw17, warnings)."""
156
+ if not isinstance(specs, list):
157
+ raise SeqAuthorError("[[scene.seq_insert]] must be a list of tables")
158
+ b = raw17
159
+ warnings: list = []
160
+ for n, spec in enumerate(specs):
161
+ if not isinstance(spec, dict):
162
+ raise SeqAuthorError(f"[[scene.seq_insert]] #{n} must be a table (got {type(spec).__name__})")
163
+ unknown = set(spec) - {"seq", "source", "before", "after"}
164
+ if unknown:
165
+ raise SeqAuthorError(f"[[scene.seq_insert]] #{n}: unknown key(s) {sorted(unknown)} "
166
+ f"(expected seq / source / before / after)")
167
+ seq, source = spec.get("seq"), spec.get("source")
168
+ if not isinstance(seq, int) or isinstance(seq, bool):
169
+ raise SeqAuthorError(f"[[scene.seq_insert]] #{n} needs an integer seq")
170
+ if not isinstance(source, str) or not source.strip():
171
+ raise SeqAuthorError(f"[[scene.seq_insert]] #{n} needs a non-empty source string")
172
+ try:
173
+ b, w = insert_sequence(b, seq, source, before=spec.get("before"), after=spec.get("after"))
174
+ except SeqAuthorError as ex:
175
+ raise SeqAuthorError(f"[[scene.seq_insert]] #{n}: {ex}")
176
+ warnings += w
177
+ return b, warnings
178
+
179
+
180
+ def apply_seq_replaces(raw17: bytes, specs) -> tuple:
181
+ """Apply ``[{seq, source}, ...]`` body replacements in order. Returns (new_raw17, warnings)."""
182
+ if not isinstance(specs, list):
183
+ raise SeqAuthorError("[[scene.seq_replace]] must be a list of tables")
184
+ b = raw17
185
+ warnings: list = []
186
+ for n, spec in enumerate(specs):
187
+ if not isinstance(spec, dict):
188
+ raise SeqAuthorError(f"[[scene.seq_replace]] #{n} must be a table (got {type(spec).__name__})")
189
+ unknown = set(spec) - {"seq", "source"}
190
+ if unknown:
191
+ raise SeqAuthorError(f"[[scene.seq_replace]] #{n}: unknown key(s) {sorted(unknown)} (expected seq / source)")
192
+ seq, source = spec.get("seq"), spec.get("source")
193
+ if not isinstance(seq, int) or isinstance(seq, bool):
194
+ raise SeqAuthorError(f"[[scene.seq_replace]] #{n} needs an integer seq (the sub_no to replace)")
195
+ if not isinstance(source, str) or not source.strip():
196
+ raise SeqAuthorError(f"[[scene.seq_replace]] #{n} needs a non-empty source string")
197
+ try:
198
+ b, w = replace_sequence(b, seq, source)
199
+ except SeqAuthorError as ex:
200
+ raise SeqAuthorError(f"[[scene.seq_replace]] #{n}: {ex}")
201
+ warnings += w
202
+ return b, warnings
203
+
204
+
205
+ def validate_replaces(raw17: bytes, specs) -> list:
206
+ """Offline problems (empty => OK): re-run the replaces on a copy + surface any SeqAuthorError as a message."""
207
+ try:
208
+ apply_seq_replaces(raw17, specs)
209
+ return []
210
+ except SeqAuthorError as ex:
211
+ return [str(ex)]
212
+
213
+
214
+ def validate_inserts(raw17: bytes, specs) -> list:
215
+ """Offline problems (empty => OK): re-run the inserts on a copy + surface any SeqAuthorError as a message."""
216
+ try:
217
+ apply_seq_inserts(raw17, specs)
218
+ return []
219
+ except SeqAuthorError as ex:
220
+ return [str(ex)]
@@ -0,0 +1,300 @@
1
+ """Lossless codec for the raw17 ``btlseq`` attack-SEQUENCE body -- parse -> model -> re-serialize, the
2
+ foundation the disassembler (``seqdis``) and the same-length patcher (``seqpatch``) read from. The raw17
3
+ opening-CAMERA block (``[camOffset:]``) is a SEPARATE, already-solved codec (``camera_codec``); this module
4
+ owns the choreography BODY (``[bodyStart, camOffset)``) the camera codec slices verbatim.
5
+
6
+ PROVEN against the engine source (``btlseq.cs``: ``ReadBattleSequence`` :32-68, the ``Sequencer`` interpreter
7
+ :177-243, the 34-entry ``gSeqProg[]`` delegate table :1223-1259, the ``AdvanceSeqCode`` operand-width table
8
+ :1165-1218, and every ``SeqExec*``/``SeqInit*`` handler) and a 562-file / 3814-sequence corpus sweep:
9
+ ``serialize(parse(b)) == b`` byte-exact on 562/562 real donors (the raw16/camera-codec golden analog).
10
+
11
+ Format facts (all little-endian, all offsets absolute file positions):
12
+
13
+ * Header (8 fixed bytes + 3 variable tables): ``seqBlockOffset i16 @0`` (constant 4; read but unused -- kept
14
+ verbatim), ``camOffset i16 @2`` (start of the camera block), ``seqCount i16 @4``, ``animCount i16 @6``,
15
+ then ``seqOffset[seqCount] i16``, ``animList[animCount] i32``, ``seqBaseAnim[seqCount] u8``.
16
+ * ``bodyStart = 8 + 3*seqCount + 4*animCount`` (always even). Sequence body region = ``[bodyStart, camOffset)``.
17
+ * THE +4 SKEW: a ``seqOffset[i]`` value is the file position MINUS 4 -- sequence i's bytes start at
18
+ ``seqOffset[i] + 4``. ``0`` is a "no sequence" sentinel (absent in the corpus, supported defensively).
19
+ * Each sequence is a flat opcode stream: ``[op u8][operands...]``, terminated by ``0x00`` End or ``0x18``
20
+ FastEnd. Several ``seqOffset`` slots may ALIAS one body (a verbatim duplicate offset -- attacks that share
21
+ choreography); bodies never partially overlap. Inter-body + trailing padding is NOT a derivable alignment
22
+ rule (0/1-byte gaps that can land on odd boundaries; 5 scenes carry a 4-byte trailing pad) -> captured VERBATIM.
23
+ * The interpreter guard is ``op > gSeqProg.Length`` (== ``> 34``), so an opcode byte of exactly ``34`` (0x22)
24
+ is NOT coerced -- it indexes ``gSeqProg[34]`` out of bounds, a latent engine crash; ``35..255`` coerce to
25
+ ``0`` End. Valid opcodes are 0..33; this codec rejects a body opcode of ``34..255`` (strict, for safety).
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import struct
30
+ from dataclasses import dataclass, field as _field
31
+
32
+
33
+ class SeqCodecError(ValueError):
34
+ pass
35
+
36
+
37
+ # ----------------------------------------------------------------- the 34-opcode table (gSeqProg[] @1223-1259)
38
+ # Each entry: name, [ (operand_name, rel_offset_from_opcode, width, signed, kind) ... ]. The instruction TOTAL
39
+ # size = 1 + sum(widths) (every byte is covered, incl. the 0x19 discarded-but-present pad). ``kind`` drives the
40
+ # disassembler rendering + the patch-site taxonomy. Widths/signedness transcribed from each SeqExec*/SeqInit*
41
+ # handler's Read* calls AND cross-checked against the AdvanceSeqCode skip table (skip+1 == size for all 34).
42
+ _OPS = {
43
+ 0x00: ("End", []),
44
+ 0x01: ("Wait", [("frames", 1, 1, False, "frames")]),
45
+ 0x02: ("Calc", []),
46
+ 0x03: ("MoveToTarget", [("frames", 1, 1, False, "frames"), ("distance", 2, 2, True, "distance")]),
47
+ 0x04: ("MoveToTurn", [("frames", 1, 1, False, "frames")]),
48
+ 0x05: ("Anim", [("anim_code", 1, 1, False, "anim_code")]),
49
+ 0x06: ("SVfx", [("svfx_no", 1, 2, False, "svfx"), ("param", 3, 1, False, "param"),
50
+ ("time", 4, 1, False, "frames")]),
51
+ 0x07: ("WaitAnim", []),
52
+ 0x08: ("Vfx", [("fx_no", 1, 2, False, "vfx"), ("a0", 3, 2, True, "coord"),
53
+ ("a1", 5, 2, True, "coord"), ("a2", 7, 2, True, "coord")]),
54
+ 0x09: ("WaitLoadVfx", []),
55
+ 0x0A: ("StartVfx", []),
56
+ 0x0B: ("WaitVfx", []),
57
+ 0x0C: ("Scale", [("factor", 1, 2, True, "scale"), ("frames", 3, 1, False, "frames")]),
58
+ 0x0D: ("MeshHide", [("mask", 1, 2, False, "mesh_mask")]),
59
+ 0x0E: ("Message", [("mess_id", 1, 1, False, "message")]),
60
+ 0x0F: ("MeshShow", [("mask", 1, 2, False, "mesh_mask")]),
61
+ 0x10: ("SetCamera", [("cam", 1, 1, False, "camera")]),
62
+ 0x11: ("DefaultIdle", [("on_off", 1, 1, False, "flag")]),
63
+ 0x12: ("RunCamera", [("cam", 1, 1, False, "camera")]), # cam read inside a predicate; layout stable
64
+ 0x13: ("MoveToPoint", [("frames", 1, 1, False, "frames"), ("x", 2, 2, True, "coord"),
65
+ ("y", 4, 2, True, "coord"), ("z", 6, 2, True, "coord")]),
66
+ 0x14: ("Turn", [("dir", 1, 2, True, "dir"), ("add", 3, 2, True, "angle"), ("time", 5, 1, False, "frames")]),
67
+ 0x15: ("TexAnimPlay", [("tex", 1, 1, False, "tex")]),
68
+ 0x16: ("TexAnimOnce", [("tex", 1, 1, False, "tex")]),
69
+ 0x17: ("TexAnimStop", [("tex", 1, 1, False, "tex")]),
70
+ 0x18: ("FastEnd", []),
71
+ 0x19: ("Sfx", [("sfx_no", 1, 2, False, "sfx"), ("time", 3, 1, False, "frames"),
72
+ ("_pad", 4, 1, False, "pad"), ("vol", 5, 1, False, "param")]), # +4 is a discarded HOLE
73
+ 0x1A: ("VfxContact", [("fx_no", 1, 2, False, "vfx"), ("a0", 3, 2, True, "coord"),
74
+ ("a1", 5, 2, True, "coord"), ("a2", 7, 2, True, "coord")]),
75
+ 0x1B: ("MoveToOffset", [("frames", 1, 1, False, "frames"), ("dx", 2, 2, True, "coord"),
76
+ ("dy", 4, 2, True, "coord"), ("dz", 6, 2, True, "coord")]),
77
+ 0x1C: ("TargetBone", [("bone", 1, 1, False, "bone")]),
78
+ 0x1D: ("FadeOut", [("frames", 1, 1, False, "fade")]),
79
+ 0x1E: ("MoveToTargetZ", [("frames", 1, 1, False, "frames"), ("distance", 2, 2, True, "distance")]),
80
+ 0x1F: ("Shadow", [("on_off", 1, 1, False, "flag")]),
81
+ 0x20: ("RunCameraForced", [("cam", 1, 1, False, "camera")]),
82
+ 0x21: ("MessageTitle", [("mess_id", 1, 1, False, "message")]),
83
+ }
84
+ TERMINATORS = (0x00, 0x18)
85
+ MAX_OPCODE = 0x21 # 33; a body byte of 34 is the latent gSeqProg[34] crash
86
+
87
+
88
+ def _size(op: int) -> int:
89
+ fields = _OPS[op][1]
90
+ return 1 + sum(w for _n, _o, w, _s, _k in fields)
91
+
92
+
93
+ SIZE = {op: _size(op) for op in _OPS} # opcode -> total instruction size (incl. opcode byte)
94
+ OP_NAME = {op: nm for op, (nm, _f) in _OPS.items()}
95
+
96
+
97
+ # ----------------------------------------------------------------- the in-memory model
98
+ @dataclass
99
+ class Instr:
100
+ op: int
101
+ offset: int # absolute file offset of the opcode byte (in the source raw17)
102
+ operands: list # decoded ints, parallel to _OPS[op][1] field descriptors
103
+
104
+ @property
105
+ def name(self) -> str:
106
+ return OP_NAME[self.op]
107
+
108
+ @property
109
+ def fields(self):
110
+ return _OPS[self.op][1]
111
+
112
+
113
+ @dataclass
114
+ class Body:
115
+ offset: int # the seqOffset VALUE (file-pos - 4); absolute start = offset + 4
116
+ gap_before: bytes # alignment padding immediately before this body (verbatim)
117
+ instrs: list # [Instr]
118
+
119
+
120
+ @dataclass
121
+ class Raw17:
122
+ seq_block_offset: int
123
+ cam_offset: int
124
+ seq_count: int
125
+ anim_count: int
126
+ seq_offset: list # [int] per sub_no (the seqOffset table; 0 = sentinel)
127
+ anim_list: list # [int] global anim ids (i32)
128
+ seq_base_anim: list # [int] per sub_no
129
+ bodies: list # [Body] in file (ascending-abs-start) order, distinct offsets only
130
+ final_pad: bytes # padding between the last body end and cam_offset (verbatim)
131
+ camera_block: bytes # raw17[cam_offset:] verbatim (the separate camera codec owns it)
132
+ seq_block_raw: bytes = _field(default=b"", repr=False) # original header tables region (unused; reserved)
133
+
134
+ @property
135
+ def body_start(self) -> int:
136
+ return 8 + 3 * self.seq_count + 4 * self.anim_count
137
+
138
+ def body_for(self, sub_no: int):
139
+ """The Body a sub_no's seqOffset points at (None for the 0 sentinel / out of range)."""
140
+ if not 0 <= sub_no < self.seq_count:
141
+ return None
142
+ off = self.seq_offset[sub_no]
143
+ if off == 0:
144
+ return None
145
+ for b in self.bodies:
146
+ if b.offset == off:
147
+ return b
148
+ return None
149
+
150
+
151
+ # ----------------------------------------------------------------- parse
152
+ def _decode_instr(raw: bytes, pos: int) -> Instr:
153
+ op = raw[pos]
154
+ if op > MAX_OPCODE:
155
+ raise SeqCodecError(f"opcode {op} (0x{op:02x}) at offset {pos} is out of range 0..{MAX_OPCODE} "
156
+ f"(34 would crash the engine's gSeqProg[34] index)")
157
+ operands = []
158
+ for _name, rel, w, signed, _kind in _OPS[op][1]:
159
+ operands.append(int.from_bytes(raw[pos + rel:pos + rel + w], "little", signed=signed))
160
+ return Instr(op, pos, operands)
161
+
162
+
163
+ def _decode_body(raw: bytes, abs_start: int, limit: int) -> tuple:
164
+ """Decode one sequence from ``abs_start`` to its terminator. ``limit`` (== cam_offset) bounds the walk.
165
+ Returns (instrs, end_pos)."""
166
+ pos = abs_start
167
+ instrs = []
168
+ guard = 0
169
+ while pos < limit:
170
+ guard += 1
171
+ if guard > 100000:
172
+ raise SeqCodecError(f"sequence at {abs_start} runs away (no terminator before offset {limit})")
173
+ ins = _decode_instr(raw, pos)
174
+ sz = SIZE[ins.op]
175
+ if pos + sz > limit:
176
+ raise SeqCodecError(f"instruction {ins.name} at {pos} overruns the body region (end {pos + sz} "
177
+ f"> camOffset {limit})")
178
+ instrs.append(ins)
179
+ pos += sz
180
+ if ins.op in TERMINATORS:
181
+ return instrs, pos
182
+ raise SeqCodecError(f"sequence at {abs_start} reached camOffset {limit} with no terminator")
183
+
184
+
185
+ def parse(raw17: bytes) -> Raw17:
186
+ """Parse a raw17 into a lossless :class:`Raw17` model (header + per-body decoded instructions + the verbatim
187
+ camera block). ``serialize(parse(b)) == b`` byte-exact for valid donors."""
188
+ if len(raw17) < 8:
189
+ raise SeqCodecError(f"raw17 too short ({len(raw17)} bytes)")
190
+ seq_block_offset, cam_offset, seq_count, anim_count = struct.unpack_from("<hhhh", raw17, 0)
191
+ if seq_count < 0 or anim_count < 0:
192
+ raise SeqCodecError(f"bad header (seqCount={seq_count}, animCount={anim_count})")
193
+ if not 0 < cam_offset <= len(raw17):
194
+ raise SeqCodecError(f"bad camOffset {cam_offset} (file {len(raw17)} bytes)")
195
+ table_end = 8 + 2 * seq_count + 4 * anim_count + seq_count # seqOffset[] + animList[] + seqBaseAnim[]
196
+ if table_end > len(raw17): # bound BEFORE the unpacks so a malformed header
197
+ raise SeqCodecError(f"header tables (end {table_end}) run past EOF (file {len(raw17)} bytes) -- "
198
+ f"seqCount={seq_count}, animCount={anim_count}") # raises cleanly, never a struct.error
199
+ off = 8
200
+ seq_offset = list(struct.unpack_from(f"<{seq_count}h", raw17, off)); off += 2 * seq_count
201
+ anim_list = list(struct.unpack_from(f"<{anim_count}i", raw17, off)); off += 4 * anim_count
202
+ seq_base_anim = list(raw17[off:off + seq_count]); off += seq_count
203
+ body_start = off
204
+ if body_start != 8 + 3 * seq_count + 4 * anim_count:
205
+ raise SeqCodecError("header table region length mismatch (internal)")
206
+ if body_start > cam_offset:
207
+ raise SeqCodecError(f"header tables (end {body_start}) overrun camOffset {cam_offset}")
208
+
209
+ # distinct, nonzero offsets, decoded once in ascending abs-start order (aliases share a body)
210
+ distinct = sorted(set(o for o in seq_offset if o != 0))
211
+ bodies = []
212
+ prev_end = body_start
213
+ for o in distinct:
214
+ abs_start = o + 4
215
+ if abs_start < body_start or abs_start >= cam_offset:
216
+ raise SeqCodecError(f"seqOffset {o} (abs {abs_start}) outside the body region "
217
+ f"[{body_start}, {cam_offset})")
218
+ if abs_start < prev_end: # a DISTINCT offset landing inside an earlier body =
219
+ raise SeqCodecError(f"seqOffset {o} (abs {abs_start}) overlaps an earlier sequence body that ends "
220
+ f"at {prev_end} -- partial overlap (bodies must be disjoint or EXACT aliases; "
221
+ f"a re-serialize would double-emit the shared tail)") # 0 in the corpus
222
+ gap = bytes(raw17[prev_end:abs_start])
223
+ instrs, end = _decode_body(raw17, abs_start, cam_offset)
224
+ bodies.append(Body(o, gap, instrs))
225
+ prev_end = max(prev_end, end)
226
+ final_pad = bytes(raw17[prev_end:cam_offset])
227
+ return Raw17(seq_block_offset, cam_offset, seq_count, anim_count, seq_offset, anim_list,
228
+ seq_base_anim, bodies, final_pad, bytes(raw17[cam_offset:]))
229
+
230
+
231
+ # ----------------------------------------------------------------- serialize
232
+ def emit_instr(ins: Instr) -> bytes:
233
+ """One instruction's bytes (opcode + operands), byte-exact (covers the 0x19 discarded-pad byte)."""
234
+ if ins.op not in _OPS: # a directly-built Instr with a bogus opcode -> clean error
235
+ raise SeqCodecError(f"opcode {ins.op} is not a valid sequence opcode (0..{MAX_OPCODE})")
236
+ buf = bytearray(SIZE[ins.op])
237
+ buf[0] = ins.op
238
+ for (_name, rel, w, signed, _kind), val in zip(_OPS[ins.op][1], ins.operands):
239
+ buf[rel:rel + w] = int(val).to_bytes(w, "little", signed=signed)
240
+ return bytes(buf)
241
+
242
+
243
+ def serialize(model: Raw17) -> bytes:
244
+ """Re-serialize the model. GOLDEN PATH (no body length change): header + tables verbatim + each body's
245
+ captured gap_before + its instruction bytes + final_pad + the verbatim camera block == the original file."""
246
+ out = bytearray()
247
+ out += struct.pack("<hhhh", model.seq_block_offset, model.cam_offset, model.seq_count, model.anim_count)
248
+ out += struct.pack(f"<{model.seq_count}h", *model.seq_offset)
249
+ out += struct.pack(f"<{model.anim_count}i", *model.anim_list)
250
+ out += bytes(model.seq_base_anim)
251
+ if len(out) != model.body_start:
252
+ raise SeqCodecError("serialized header length mismatch (internal)")
253
+ for b in model.bodies:
254
+ out += b.gap_before
255
+ for ins in b.instrs:
256
+ out += emit_instr(ins)
257
+ out += model.final_pad
258
+ out += model.camera_block
259
+ return bytes(out)
260
+
261
+
262
+ _I16_MAX = (1 << 15) - 1
263
+
264
+
265
+ def serialize_repacked(model: Raw17) -> bytes:
266
+ """Re-serialize with bodies RE-LAID contiguously + every offset recomputed -- the LENGTH-CHANGING path (used
267
+ after a body replace/insert, when ``serialize``'s preserve-exact-layout invariant no longer holds).
268
+
269
+ Only TWO things in the file reference positions: ``camOffset`` (@2) and ``seqOffset[]``. So the repack lays each
270
+ distinct body back-to-back from ``body_start`` (in model order), sets ``seqOffset[i] = new_body_start − 4`` (the
271
+ +4 skew; aliases inherit; the 0 sentinel stays 0), sets ``camOffset = align_up(last_body_end, 4)``, and
272
+ re-appends the camera block VERBATIM (its internal offsets are camOffset-relative, so it floats intact). The
273
+ result is NOT byte-identical to a donor (different padding) but is functionally equivalent + re-parses to the
274
+ same logical sequences. Both i16 offsets are range-checked."""
275
+ body_start = model.body_start
276
+ new_off_for = {} # old seqOffset value -> new seqOffset value (file-pos - 4)
277
+ body_bytes = bytearray()
278
+ for b in model.bodies:
279
+ new_off_for[b.offset] = (body_start + len(body_bytes)) - 4
280
+ for ins in b.instrs:
281
+ body_bytes += emit_instr(ins)
282
+ last_end = body_start + len(body_bytes)
283
+ new_cam_offset = (last_end + 3) & ~3 # 4-align (SE convention; not strictly required by the engine)
284
+ if new_cam_offset > _I16_MAX:
285
+ raise SeqCodecError(f"repacked camOffset {new_cam_offset} exceeds Int16 ({_I16_MAX}) -- bodies too large")
286
+ new_seq_offset = [0 if o == 0 else new_off_for[o] for o in model.seq_offset]
287
+ for sub, o in enumerate(new_seq_offset):
288
+ if not 0 <= o <= _I16_MAX:
289
+ raise SeqCodecError(f"repacked seqOffset[{sub}] = {o} exceeds Int16 range -- sequence body too far")
290
+ out = bytearray()
291
+ out += struct.pack("<hhhh", model.seq_block_offset, new_cam_offset, model.seq_count, model.anim_count)
292
+ out += struct.pack(f"<{model.seq_count}h", *new_seq_offset)
293
+ out += struct.pack(f"<{model.anim_count}i", *model.anim_list)
294
+ out += bytes(model.seq_base_anim)
295
+ if len(out) != body_start:
296
+ raise SeqCodecError("repacked header length mismatch (internal)")
297
+ out += body_bytes
298
+ out += b"\x00" * (new_cam_offset - last_end) # zero pad to the 4-aligned camOffset
299
+ out += model.camera_block
300
+ return bytes(out)
@@ -0,0 +1,106 @@
1
+ """The read-only DISASSEMBLER VIEW for a raw17 ``btlseq`` attack sequence -- the "see the choreography" step,
2
+ the raw17 analog of :mod:`ff9mapkit.battle.battleai` (enemy AI). Decodes via the lossless :mod:`seqcodec`
3
+ parse, then renders each sub-sequence (by ``sub_no`` == attack index) as named instructions with annotated
4
+ operands + the resolved animation ids. Read-only + offline; only the open-source opcode NAMES are committed.
5
+
6
+ The companion "find the offset to patch" view (``battle-seq --sites``) delegates to :mod:`seqpatch`.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from . import seqcodec as _sc
11
+
12
+
13
+ def _anim_note(model: _sc.Raw17, sub_no: int, ins: _sc.Instr) -> str:
14
+ """For an Anim (0x05) instruction, resolve animCode -> the global anim id, mirroring SeqExecAnim
15
+ (btlseq.cs:596-618): 255 = default idle; else ``animList[seqBaseAnim[sub_no] + animCode]``."""
16
+ if ins.op != 0x05:
17
+ return ""
18
+ code = ins.operands[0]
19
+ if code == 255:
20
+ return " # default idle"
21
+ base = model.seq_base_anim[sub_no] if 0 <= sub_no < len(model.seq_base_anim) else 0
22
+ idx = base + code
23
+ if 0 <= idx < len(model.anim_list):
24
+ return f" # -> animList[{base}+{code}] = anim id {model.anim_list[idx]}"
25
+ return f" # -> animList[{base}+{code}] OUT OF RANGE (animCount {len(model.anim_list)})"
26
+
27
+
28
+ def _operand_str(ins: _sc.Instr) -> str:
29
+ parts = []
30
+ for (name, _rel, _w, _signed, _kind), val in zip(ins.fields, ins.operands):
31
+ if name == "_pad":
32
+ continue # the 0x19 discarded hole -- not meaningful to show
33
+ parts.append(f"{name}={val}")
34
+ return ", ".join(parts)
35
+
36
+
37
+ def _extra_note(ins: _sc.Instr) -> str:
38
+ if ins.op == 0x02:
39
+ return " # commits a damage/effect pass (hit-count = #Calc)"
40
+ if ins.op in (0x0E, 0x21) and (ins.operands[0] & 0x80):
41
+ return " # attack-name/title (bit7)"
42
+ if ins.op == 0x12:
43
+ return " # camera fires only if the alt-camera predicate is true"
44
+ return ""
45
+
46
+
47
+ def disassemble_seq(raw17: bytes) -> str:
48
+ """Render a raw17's attack sequences as annotated text: header summary, then each ``sub_no`` (== attack
49
+ index) as one line per instruction (named op + operands + resolved anim ids). Aliased slots are noted."""
50
+ try:
51
+ model = _sc.parse(raw17)
52
+ except _sc.SeqCodecError as ex:
53
+ return f"<unreadable/malformed raw17: {ex}>"
54
+ n_bodies = len(model.bodies)
55
+ lines = [f"btlseq: {model.seq_count} sequence slot(s), {model.anim_count} anim id(s), "
56
+ f"{n_bodies} distinct body(ies), camOffset {model.cam_offset}, "
57
+ f"camera block {len(model.camera_block)} B"]
58
+ seen: dict = {} # offset -> first sub_no (alias detection)
59
+ for sub in range(model.seq_count):
60
+ off = model.seq_offset[sub]
61
+ base = model.seq_base_anim[sub] if sub < len(model.seq_base_anim) else 0
62
+ if off == 0:
63
+ lines.append(f"\n -- sub {sub} (no sequence / sentinel) --")
64
+ continue
65
+ if off in seen:
66
+ lines.append(f"\n -- sub {sub} -> ALIAS of sub {seen[off]} (offset {off}, base-anim {base}) --")
67
+ continue
68
+ seen[off] = sub
69
+ body = model.body_for(sub)
70
+ lines.append(f"\n -- sub {sub} (base-anim {base}, abs {off + 4}, {len(body.instrs)} instr) --")
71
+ for ins in body.instrs:
72
+ ops = _operand_str(ins)
73
+ note = _anim_note(model, sub, ins) or _extra_note(ins)
74
+ lines.append(f" [{ins.offset}] {ins.name}({ops}){note}")
75
+ return "\n".join(lines)
76
+
77
+
78
+ # ----------------------------------------------------------------- scene loading (install-gated, like battleai)
79
+ def _scene_raw17(donor: str, game=None) -> bytes:
80
+ from . import extract as _extract
81
+ raw17 = _extract.read_scene_assets(donor, game=game).get("raw17")
82
+ if not raw17:
83
+ raise FileNotFoundError(f"no btlseq.raw17 found for battle scene {donor!r}")
84
+ return raw17
85
+
86
+
87
+ def analyze_scene_seq(donor: str, game=None) -> str:
88
+ """Read a real battle scene's ``btlseq.raw17`` LIVE from the install + disassemble its attack sequences.
89
+ ``donor`` is the scene name after ``EVT_BATTLE_`` (e.g. ``EF_R007``)."""
90
+ return (f"# attack sequences of scene {donor} (EVT_BATTLE_{donor}.raw17)\n"
91
+ + disassemble_seq(_scene_raw17(donor, game=game)))
92
+
93
+
94
+ def scene_seq_sites(donor: str, game=None) -> str:
95
+ """List a scene's patchable sequence operands (the ``[[scene.seq_patch]]`` targets): byte offset, width,
96
+ current value, context. Read-only -- the 'find the offset to patch' companion to the disassembly."""
97
+ from . import seqpatch as _seqpatch
98
+ sites = _seqpatch.constant_sites(_scene_raw17(donor, game=game))
99
+ lines = [f"# patchable sequence operands of scene {donor} ({len(sites)} sites)",
100
+ "# cite the offset in [[scene.seq_patch]] (seq = <sub_no>, at = <offset>, old = <value>, "
101
+ "new = <same-width value>)"]
102
+ for s in sites:
103
+ shared = f" (SHARED: also drives sub {','.join(str(x) for x in s.shared_subs if x != s.sub_no)})" \
104
+ if len(s.shared_subs) > 1 else ""
105
+ lines.append(f" sub{s.sub_no:<2} at={s.offset:<6} {s.width}B {s.kind:<10} = {s.value:<8} {s.where}{shared}")
106
+ return "\n".join(lines)