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.
- ff9mapkit/__init__.py +18 -0
- ff9mapkit/__main__.py +36 -0
- ff9mapkit/_animdb.py +2994 -0
- ff9mapkit/_animdb_all.py +14125 -0
- ff9mapkit/_fieldtable.py +1516 -0
- ff9mapkit/_fieldtext.py +845 -0
- ff9mapkit/_held_poses.py +44 -0
- ff9mapkit/_itemdb.py +65 -0
- ff9mapkit/_modeldb.py +725 -0
- ff9mapkit/_narrowmap_data.py +10 -0
- ff9mapkit/_npcparams.py +634 -0
- ff9mapkit/_regen_animdb.py +72 -0
- ff9mapkit/_regen_animdb_all.py +66 -0
- ff9mapkit/_regen_fieldtable.py +95 -0
- ff9mapkit/_regen_fieldtext.py +66 -0
- ff9mapkit/_regen_modeldb.py +67 -0
- ff9mapkit/_regen_npcparams.py +123 -0
- ff9mapkit/_regen_scenedb.py +57 -0
- ff9mapkit/_scenedb.py +869 -0
- ff9mapkit/abilities.py +225 -0
- ff9mapkit/animations.py +120 -0
- ff9mapkit/archetypes.py +218 -0
- ff9mapkit/areatitle.py +76 -0
- ff9mapkit/battle/__init__.py +21 -0
- ff9mapkit/battle/abilityfeatures.py +294 -0
- ff9mapkit/battle/actiondelta.py +441 -0
- ff9mapkit/battle/aiauthor.py +305 -0
- ff9mapkit/battle/ailint.py +140 -0
- ff9mapkit/battle/aipatch.py +175 -0
- ff9mapkit/battle/battleai.py +148 -0
- ff9mapkit/battle/battlecsv.py +390 -0
- ff9mapkit/battle/battlepatch.py +395 -0
- ff9mapkit/battle/build.py +558 -0
- ff9mapkit/battle/camera_codec.py +332 -0
- ff9mapkit/battle/camera_data.py +128 -0
- ff9mapkit/battle/characterdelta.py +789 -0
- ff9mapkit/battle/event_data.py +72 -0
- ff9mapkit/battle/extract.py +540 -0
- ff9mapkit/battle/fbx.py +223 -0
- ff9mapkit/battle/reskin.py +149 -0
- ff9mapkit/battle/scene_codec.py +314 -0
- ff9mapkit/battle/scene_data.py +369 -0
- ff9mapkit/battle/scenelint.py +125 -0
- ff9mapkit/battle/seqasm.py +131 -0
- ff9mapkit/battle/seqauthor.py +220 -0
- ff9mapkit/battle/seqcodec.py +300 -0
- ff9mapkit/battle/seqdis.py +106 -0
- ff9mapkit/battle/seqpatch.py +137 -0
- ff9mapkit/battle_bgm.py +133 -0
- ff9mapkit/binutils.py +60 -0
- ff9mapkit/build.py +5445 -0
- ff9mapkit/campaign.py +1276 -0
- ff9mapkit/catalog.py +316 -0
- ff9mapkit/chain.py +358 -0
- ff9mapkit/cli.py +3114 -0
- ff9mapkit/config.py +360 -0
- ff9mapkit/content/__init__.py +13 -0
- ff9mapkit/content/areatitle.py +36 -0
- ff9mapkit/content/ate.py +118 -0
- ff9mapkit/content/camera.py +123 -0
- ff9mapkit/content/chest.py +186 -0
- ff9mapkit/content/choice.py +163 -0
- ff9mapkit/content/conductor.py +217 -0
- ff9mapkit/content/cutscene.py +290 -0
- ff9mapkit/content/encounter.py +41 -0
- ff9mapkit/content/entry_settle.py +50 -0
- ff9mapkit/content/equipment.py +93 -0
- ff9mapkit/content/event.py +191 -0
- ff9mapkit/content/gateway.py +101 -0
- ff9mapkit/content/inventory.py +59 -0
- ff9mapkit/content/itemdata.py +644 -0
- ff9mapkit/content/itemtext.py +168 -0
- ff9mapkit/content/jump.py +114 -0
- ff9mapkit/content/ladder.py +633 -0
- ff9mapkit/content/movement.py +53 -0
- ff9mapkit/content/music.py +97 -0
- ff9mapkit/content/npc.py +348 -0
- ff9mapkit/content/object.py +340 -0
- ff9mapkit/content/onentry.py +135 -0
- ff9mapkit/content/party.py +111 -0
- ff9mapkit/content/pathfind.py +138 -0
- ff9mapkit/content/platform.py +314 -0
- ff9mapkit/content/player.py +168 -0
- ff9mapkit/content/prop.py +75 -0
- ff9mapkit/content/region.py +340 -0
- ff9mapkit/content/reinit.py +59 -0
- ff9mapkit/content/savepoint.py +90 -0
- ff9mapkit/content/shop.py +178 -0
- ff9mapkit/content/sps_trigger.py +66 -0
- ff9mapkit/content/startup.py +71 -0
- ff9mapkit/content/synthesis.py +106 -0
- ff9mapkit/content/text.py +183 -0
- ff9mapkit/content/textcarry.py +290 -0
- ff9mapkit/content/verbatim.py +86 -0
- ff9mapkit/content/walkmesh_hotfix.py +38 -0
- ff9mapkit/data/__init__.py +48 -0
- ff9mapkit/data/_regen_provenance.py +142 -0
- ff9mapkit/data/provenance/blank.es.patch +1 -0
- ff9mapkit/data/provenance/blank.fr.patch +1 -0
- ff9mapkit/data/provenance/blank.gr.patch +1 -0
- ff9mapkit/data/provenance/blank.it.patch +1 -0
- ff9mapkit/data/provenance/blank.jp.patch +1 -0
- ff9mapkit/data/provenance/blank.uk.patch +1 -0
- ff9mapkit/data/provenance/blank.us.patch +1 -0
- ff9mapkit/data/provenance/manifest.json +65 -0
- ff9mapkit/data/provenance/region_template.patch +1 -0
- ff9mapkit/data/reference_arcs.toml +89 -0
- ff9mapkit/data/region_catalog.toml +593 -0
- ff9mapkit/deploystack.py +358 -0
- ff9mapkit/dialogue.py +803 -0
- ff9mapkit/eb/__init__.py +12 -0
- ff9mapkit/eb/_exprtable.py +59 -0
- ff9mapkit/eb/_membertable.py +38 -0
- ff9mapkit/eb/_optables.py +537 -0
- ff9mapkit/eb/_regen_optables.py +76 -0
- ff9mapkit/eb/cmdasm.py +323 -0
- ff9mapkit/eb/disasm.py +332 -0
- ff9mapkit/eb/edit.py +439 -0
- ff9mapkit/eb/exprasm.py +158 -0
- ff9mapkit/eb/model.py +178 -0
- ff9mapkit/eb/opcodes.py +463 -0
- ff9mapkit/eblint.py +177 -0
- ff9mapkit/editor/__init__.py +20 -0
- ff9mapkit/editor/app.py +950 -0
- ff9mapkit/editor/battle_forms.py +240 -0
- ff9mapkit/editor/breadcrumb.py +89 -0
- ff9mapkit/editor/dialogs.py +116 -0
- ff9mapkit/editor/feedback.py +208 -0
- ff9mapkit/editor/forms.py +632 -0
- ff9mapkit/editor/graphview.py +350 -0
- ff9mapkit/editor/jobs.py +342 -0
- ff9mapkit/editor/model.py +243 -0
- ff9mapkit/editor/picker.py +120 -0
- ff9mapkit/editor/theme.py +212 -0
- ff9mapkit/eventscan.py +1441 -0
- ff9mapkit/extract.py +2279 -0
- ff9mapkit/flags.py +693 -0
- ff9mapkit/forkreport.py +1383 -0
- ff9mapkit/hub.py +477 -0
- ff9mapkit/idgated.py +101 -0
- ff9mapkit/infohub.py +580 -0
- ff9mapkit/items.py +63 -0
- ff9mapkit/itemstats.py +346 -0
- ff9mapkit/journey.py +1902 -0
- ff9mapkit/keyitems.py +93 -0
- ff9mapkit/logic_add.py +632 -0
- ff9mapkit/logic_edit.py +728 -0
- ff9mapkit/logic_map.py +526 -0
- ff9mapkit/pack.py +175 -0
- ff9mapkit/playerswap.py +231 -0
- ff9mapkit/prop_archetypes.py +228 -0
- ff9mapkit/provision.py +282 -0
- ff9mapkit/refarc.py +825 -0
- ff9mapkit/save.py +337 -0
- ff9mapkit/save_items.py +1673 -0
- ff9mapkit/scene/__init__.py +11 -0
- ff9mapkit/scene/arena.py +63 -0
- ff9mapkit/scene/bgart.py +140 -0
- ff9mapkit/scene/bgi.py +732 -0
- ff9mapkit/scene/bgs.py +174 -0
- ff9mapkit/scene/bgx.py +185 -0
- ff9mapkit/scene/cam.py +345 -0
- ff9mapkit/scene/guide.py +311 -0
- ff9mapkit/scene/paint.py +506 -0
- ff9mapkit/scene/placeholder.py +107 -0
- ff9mapkit/sjbinary.py +285 -0
- ff9mapkit/sps/__init__.py +17 -0
- ff9mapkit/sps/author.py +294 -0
- ff9mapkit/sps/catalog.py +88 -0
- ff9mapkit/sps/codec.py +264 -0
- ff9mapkit/sps/edit.py +184 -0
- ff9mapkit/sps/lint.py +58 -0
- ff9mapkit/sps/render.py +116 -0
- ff9mapkit/sps/templates.py +47 -0
- ff9mapkit/sps/texture.py +131 -0
- ff9mapkit/walkmesh_hotfixes.py +163 -0
- ff9mapkit/workspace/__init__.py +18 -0
- ff9mapkit/workspace/battledoc.py +985 -0
- ff9mapkit/workspace/builddoc.py +607 -0
- ff9mapkit/workspace/forms_qt.py +586 -0
- ff9mapkit/workspace/importdoc.py +665 -0
- ff9mapkit/workspace/mapview.py +131 -0
- ff9mapkit/workspace/palette.py +85 -0
- ff9mapkit/workspace/savedoc.py +664 -0
- ff9mapkit/workspace/shell.py +6907 -0
- ff9mapkit/workspace/style.py +105 -0
- ff9mapkit/workspace/tuningdialog.py +223 -0
- ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
- ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
- ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
- ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
- ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
- 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)
|