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