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,340 @@
|
|
|
1
|
+
"""Graft a real field object's VERBATIM ``.eb`` entry into a fork -- faithful object carry.
|
|
2
|
+
|
|
3
|
+
The faithful alternative to the player-clone synthesis (:mod:`content.npc` / :mod:`content.prop`):
|
|
4
|
+
instead of re-synthesizing an imported NPC/prop by cloning the player and swapping the model (which
|
|
5
|
+
renders it upside-down / mis-sized -- "Zidane in a barrel skin"), this APPENDS the donor object's real
|
|
6
|
+
entry bytes at a free slot and arms it from ``Main_Init``, remapping only its explicit slot/uid
|
|
7
|
+
references. The object then renders byte-identical to the source field.
|
|
8
|
+
|
|
9
|
+
This is the generalization of :func:`content.ladder.inject_ladder`'s ``sequences`` graft (append a
|
|
10
|
+
helper entry at a free slot + remap its ``STARTSEQ`` arg) from one helper function to a whole object
|
|
11
|
+
entry + its instancing. The specs come from :func:`ff9mapkit.eventscan.scan_objects_verbatim` (or an
|
|
12
|
+
import ``[[object]]`` sidecar); each carries the verbatim entry bytes, the ``carry_tags`` subset
|
|
13
|
+
(``init_only`` objects drop interactive funcs that call a player tag a blank fork lacks), the
|
|
14
|
+
``InitObject`` instances, and -- when the object is positioned from ``Main_Init``'s D9 vars rather than
|
|
15
|
+
self-positioning -- the ``needs_d9`` placement. Full recipe + the cross-reference remap table:
|
|
16
|
+
``docs/OBJECT_CARRY.md``. The AUTHORED ``[[npc]]``/``[[prop]]`` path is untouched -- this is import-only.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import struct
|
|
21
|
+
|
|
22
|
+
from .. import eventscan
|
|
23
|
+
from ..binutils import u16
|
|
24
|
+
from ..eb import EbScript, edit, opcodes
|
|
25
|
+
from ..eb.disasm import argsize, expr_obj_uid_offsets, iter_code
|
|
26
|
+
from . import region as _region
|
|
27
|
+
|
|
28
|
+
_LOOP_TAG = 1 # an object's per-frame LOOP function
|
|
29
|
+
_FIELD_OP = 0x2B # Field(dest) -- a warp; in an object's LOOP it makes the object a cutscene director
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def carry_bytes(entry_bytes, carry_tags=None) -> bytes:
|
|
33
|
+
"""Return the entry holding only ``carry_tags`` functions (``None`` = the whole entry, verbatim).
|
|
34
|
+
|
|
35
|
+
Re-emits the type byte + a rebuilt function table + the kept bodies VERBATIM (intra-func jumps are
|
|
36
|
+
function-relative, so dropping a sibling function never disturbs a kept one; ``fpos`` is recomputed
|
|
37
|
+
for the new layout). ``carry_tags=None`` (or a superset of the entry's tags) round-trips byte-for-byte.
|
|
38
|
+
"""
|
|
39
|
+
b = bytes(entry_bytes)
|
|
40
|
+
etype, fc = b[0], b[1]
|
|
41
|
+
funcs = [(u16(b, 2 + i * 4), u16(b, 2 + i * 4 + 2)) for i in range(fc)] # (tag, fpos)
|
|
42
|
+
bodies = []
|
|
43
|
+
for i, (tag, fpos) in enumerate(funcs):
|
|
44
|
+
start = 2 + fpos # fpos is relative to entryStart+2 (= slice offset 2)
|
|
45
|
+
end = (2 + funcs[i + 1][1]) if i + 1 < fc else len(b)
|
|
46
|
+
bodies.append((tag, b[start:end]))
|
|
47
|
+
if carry_tags is not None:
|
|
48
|
+
keep = set(carry_tags)
|
|
49
|
+
bodies = [(t, body) for t, body in bodies if t in keep]
|
|
50
|
+
table, pos = b"", len(bodies) * 4
|
|
51
|
+
for tag, body in bodies:
|
|
52
|
+
table += struct.pack("<HH", tag, pos)
|
|
53
|
+
pos += len(body)
|
|
54
|
+
return bytes([etype, len(bodies)]) + table + b"".join(body for _, body in bodies)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _loop_warps(entry_bytes) -> bool:
|
|
58
|
+
"""True if the entry's LOOP (tag 1) fires a ``Field()`` warp -- a cutscene WARP director carried as an NPC.
|
|
59
|
+
A SYNTHESIZED fork must NOT carry these: their loop re-fires the warp / cast-rotation against the asserted
|
|
60
|
+
beat (the #13 stacked-spawn / warp-out bug seen forking the Dali shop). Checked on the CARRIED bytes, so an
|
|
61
|
+
``init_only`` object whose loop was already dropped is NOT flagged (it still renders). Only an actual
|
|
62
|
+
``Field()`` flags it -- phase-switch-only animated props and the save Moogle (no LOOP warp) are unaffected,
|
|
63
|
+
so the proven prop/save-point carries keep working; ``--verbatim`` keeps directors whole regardless."""
|
|
64
|
+
b = bytes(entry_bytes)
|
|
65
|
+
if len(b) < 2:
|
|
66
|
+
return False
|
|
67
|
+
fc = b[1]
|
|
68
|
+
funcs = [(u16(b, 2 + i * 4), u16(b, 2 + i * 4 + 2)) for i in range(fc)]
|
|
69
|
+
for i, (tag, fpos) in enumerate(funcs):
|
|
70
|
+
if tag != _LOOP_TAG:
|
|
71
|
+
continue
|
|
72
|
+
start = 2 + fpos
|
|
73
|
+
end = (2 + funcs[i + 1][1]) if i + 1 < fc else len(b)
|
|
74
|
+
return any(ins.op == _FIELD_OP for ins in iter_code(b, start, end))
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _arg_byte_offset(ins, ai):
|
|
79
|
+
"""Byte offset (relative to ``ins.off``) of immediate operand ``ai``. Decoder-derived (opcode head +
|
|
80
|
+
the argflag byte when present + the widths of the preceding immediates), so it is correct for both
|
|
81
|
+
the no-argflag low opcodes (``Init*`` < 0x10) and the argflag-carrying ones -- unlike the ladder's
|
|
82
|
+
fixed ``+2``. Returns None if a preceding operand is an expression (variable width)."""
|
|
83
|
+
off = 2 if ins.op >= 0x100 else 1 # opcode head (0xFF-paged = 2 bytes)
|
|
84
|
+
if ins.op >= 0x10 and len(ins.args) != 0: # the argflag bitmask byte
|
|
85
|
+
off += 1
|
|
86
|
+
for k in range(ai):
|
|
87
|
+
if k < len(ins.arg_is_expr) and ins.arg_is_expr[k]:
|
|
88
|
+
return None
|
|
89
|
+
off += argsize(ins.op, k)
|
|
90
|
+
return off
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _remap_value(kind, val, donor_idx, new_slot, donor_player_entry, donor2new):
|
|
94
|
+
"""Remap one slot/uid value when an entry moves ``donor_idx`` -> ``new_slot`` (docs/OBJECT_CARRY.md S3).
|
|
95
|
+
``donor_player_entry`` is the primary PC entry index (int) OR the full collection of PC entry indices
|
|
96
|
+
(a field with several ``DefinePlayerCharacter`` entries -- ANY of them aliases the controlUID 250)."""
|
|
97
|
+
if val == donor_idx: # self by slot / entry index -> the new slot
|
|
98
|
+
return new_slot
|
|
99
|
+
if kind == "uid":
|
|
100
|
+
if val in (eventscan.UID_PLAYER, eventscan.UID_SELF) or val in eventscan.PARTY_UIDS:
|
|
101
|
+
return val # engine specials -- slot-independent, kept
|
|
102
|
+
if eventscan._is_player_entry(val, donor_player_entry):
|
|
103
|
+
return eventscan.UID_PLAYER # player BY ENTRY INDEX -> the controlUID alias 250
|
|
104
|
+
if val in donor2new: # a carried sibling -> its new slot
|
|
105
|
+
return donor2new[val]
|
|
106
|
+
return val # uncarried (a kept func never has one) -> leave
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def remap_entry_refs(data, slot, donor_idx, donor_player_entry, donor2new, player_tag_remap=None) -> bytes:
|
|
110
|
+
"""Same-length, in-place remap of every slot/uid reference the grafted entry at ``slot`` makes.
|
|
111
|
+
Patches each :data:`ff9mapkit.eventscan.REF_OPS` immediate operand byte via the decoder-derived
|
|
112
|
+
offset (never a fixed +N); only width-1 operands are touched, so internal jumps survive untouched.
|
|
113
|
+
|
|
114
|
+
``player_tag_remap`` (the player-function graft, docs/PLAYER_GRAFT.md): when an object ``RunScript``s
|
|
115
|
+
the PLAYER, the called function moved to a fresh fork tag, so this also remaps the RunScript TAG (arg2)
|
|
116
|
+
-- but ONLY when the call targets the player (uid 250 or the donor player entry index); a self/sibling
|
|
117
|
+
call's tag lives in that object's own tag space and is left verbatim (the field-122 cask's tag-2 has
|
|
118
|
+
BOTH forms: ``RunScript(player, 24)`` -> remap, ``RunScript(self, 30)`` -> keep)."""
|
|
119
|
+
eb = EbScript.from_bytes(data)
|
|
120
|
+
b = bytearray(data)
|
|
121
|
+
for f in eb.entry(slot).funcs:
|
|
122
|
+
for ins in eb.instrs(f):
|
|
123
|
+
spec = eventscan.REF_OPS.get(ins.op)
|
|
124
|
+
if spec:
|
|
125
|
+
for kind in ("slot", "uid"):
|
|
126
|
+
for ai in spec.get(kind, ()):
|
|
127
|
+
if ai >= len(ins.arg_is_expr) or ins.arg_is_expr[ai]:
|
|
128
|
+
continue
|
|
129
|
+
val = ins.imm(ai)
|
|
130
|
+
if val is None:
|
|
131
|
+
continue
|
|
132
|
+
if kind == "uid" and ins.op in eventscan.INIT_OPS and val == 0:
|
|
133
|
+
continue # uid 0 aliases the slot -- not an explicit ref
|
|
134
|
+
new = _remap_value(kind, val, donor_idx, slot, donor_player_entry, donor2new)
|
|
135
|
+
if new == val:
|
|
136
|
+
continue
|
|
137
|
+
bo = _arg_byte_offset(ins, ai)
|
|
138
|
+
if bo is None or argsize(ins.op, ai) != 1:
|
|
139
|
+
continue # only same-length 1-byte operands are patchable
|
|
140
|
+
b[ins.off + bo] = new & 0xFF
|
|
141
|
+
if player_tag_remap and ins.op in eventscan.RUNSCRIPT_OPS: # site (a): the called PLAYER tag
|
|
142
|
+
uid, tag = ins.imm(1), ins.imm(2)
|
|
143
|
+
if (uid == eventscan.UID_PLAYER or eventscan._is_player_entry(uid, donor_player_entry)) \
|
|
144
|
+
and tag in player_tag_remap:
|
|
145
|
+
bo = _arg_byte_offset(ins, 2)
|
|
146
|
+
if bo is not None and argsize(ins.op, 2) == 1:
|
|
147
|
+
b[ins.off + bo] = player_tag_remap[tag] & 0xFF
|
|
148
|
+
# site (b): a sibling uid read inside an EXPRESSION operand -- the op78 (B_OBJSPECA) token. The
|
|
149
|
+
# immediate REF_OPS loop above skips expr args, so without this a grafted body that reads
|
|
150
|
+
# `op78(<entry>)` (e.g. a MoveInstantXZY positioned off a sibling, or a Seq helper's self/sibling
|
|
151
|
+
# read) keeps the DONOR index after the move -> acts on the wrong/empty fork entry. The uid is a
|
|
152
|
+
# 1-byte token operand (same-length patch). Decoder-walked (NOT a raw 0x78 scan -> no false hits).
|
|
153
|
+
for off in expr_obj_uid_offsets(data, f.abs_start, f.abs_end):
|
|
154
|
+
new = _remap_value("uid", b[off], donor_idx, slot, donor_player_entry, donor2new)
|
|
155
|
+
if new != b[off]:
|
|
156
|
+
b[off] = new & 0xFF
|
|
157
|
+
return bytes(b)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _arm(data, slot, arg, needs_d9):
|
|
161
|
+
"""Spawn the grafted object from ``Main_Init``. A self-positioning object arms with a shift-free
|
|
162
|
+
``InitObject`` (overwrite a ``Wait`` filler, else insert). A ``Main_Init``-D9-positioned object gets
|
|
163
|
+
its D9 placement set immediately before the ``InitObject`` (one inserted block, so the order holds)."""
|
|
164
|
+
if needs_d9:
|
|
165
|
+
# TOML inline-table keys arrive as strings ("0"/"4"); coerce to the int var index the engine reads
|
|
166
|
+
block = b"".join(_region.set_var(eventscan.POS_VAR_CLASS, idx, val)
|
|
167
|
+
for idx, val in sorted((int(i), int(v)) for i, v in needs_d9.items()))
|
|
168
|
+
block += opcodes.init_object(slot, arg)
|
|
169
|
+
f0 = EbScript.from_bytes(data).entry(0).func_by_tag(0)
|
|
170
|
+
if f0 is None:
|
|
171
|
+
raise ValueError("field has no Main_Init (entry 0 tag 0) to arm the object from")
|
|
172
|
+
return edit.insert_bytes(data, f0.abs_start, block)
|
|
173
|
+
return edit.activate(data, opcodes.init_object(slot, arg))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# --- party-band-aware NPC insertion (add a NEW kit NPC to a VERBATIM fork) -----------------------
|
|
177
|
+
# The engine reserves the LAST `PARTY_BAND_SIZE` entry slots for the 9 playable characters, addressed
|
|
178
|
+
# POSITIONALLY (the character with event id `e` is the entry at slot `sSourceObjN-9+e`; EventEngine.cs
|
|
179
|
+
# SetupPartyUID + the comment "9 entry slots are reserved at the end of the entry list"). An NPC is, by
|
|
180
|
+
# the engine's own definition (`GetNumberNPC`: `sid < sSourceObjN-9`), an object BELOW that band. So a new
|
|
181
|
+
# NPC can't just take eb.first_free_slot() (which on a real field is an unused CHARACTER slot inside the
|
|
182
|
+
# band -- 818/818 real fields, measured); it must be seated below the band, pushing the 9 characters up one
|
|
183
|
+
# slot each. That renumber is transparent to the engine's UID indirection but NOT to the ~790/818 fields
|
|
184
|
+
# that reference a band character by RAW slot/uid (Main_Init `InitObject`s each present character by its raw
|
|
185
|
+
# slot) -- those are remapped +1 here.
|
|
186
|
+
PARTY_BAND_SIZE = 9
|
|
187
|
+
_SPECIAL_UIDS = frozenset((eventscan.UID_PLAYER, eventscan.UID_SELF, *eventscan.PARTY_UIDS))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def shift_slot_refs(data, lo: int, hi: int, delta: int) -> bytes:
|
|
191
|
+
"""Add ``delta`` to every RAW slot/uid reference whose value is in ``[lo, hi]`` (inclusive), across
|
|
192
|
+
every entry -- the same-length operand patch that keeps references valid when a contiguous block of
|
|
193
|
+
entry SLOTS is renumbered. Reuses the decoder-derived operand surface (:data:`ff9mapkit.eventscan.REF_OPS`
|
|
194
|
+
slot/uid args + the ``op78`` obj-uid expression token), the SAME one :func:`remap_entry_refs` uses for a
|
|
195
|
+
grafted entry -- here over a value RANGE rather than one moved index. Engine specials (250 player / 255
|
|
196
|
+
self / 251-254 party) and a uid-0 slot-alias on ``Init*`` are never touched."""
|
|
197
|
+
eb = EbScript.from_bytes(data)
|
|
198
|
+
b = bytearray(eb.to_bytes())
|
|
199
|
+
for e in eb.entries:
|
|
200
|
+
if e.empty:
|
|
201
|
+
continue
|
|
202
|
+
for f in e.funcs:
|
|
203
|
+
for ins in eb.instrs(f):
|
|
204
|
+
spec = eventscan.REF_OPS.get(ins.op)
|
|
205
|
+
if spec:
|
|
206
|
+
for kind in ("slot", "uid"):
|
|
207
|
+
for ai in spec.get(kind, ()):
|
|
208
|
+
if ai >= len(ins.arg_is_expr) or ins.arg_is_expr[ai]:
|
|
209
|
+
continue
|
|
210
|
+
val = ins.imm(ai)
|
|
211
|
+
if val is None or not lo <= val <= hi:
|
|
212
|
+
continue
|
|
213
|
+
if kind == "uid" and (val in _SPECIAL_UIDS
|
|
214
|
+
or (ins.op in eventscan.INIT_OPS and val == 0)):
|
|
215
|
+
continue
|
|
216
|
+
if argsize(ins.op, ai) != 1: # only same-length 1-byte operands are patchable
|
|
217
|
+
continue
|
|
218
|
+
bo = _arg_byte_offset(ins, ai)
|
|
219
|
+
if bo is not None:
|
|
220
|
+
b[ins.off + bo] = (val + delta) & 0xFF
|
|
221
|
+
for off in expr_obj_uid_offsets(eb.data, f.abs_start, f.abs_end): # op78 sibling-uid reads
|
|
222
|
+
v = eb.data[off]
|
|
223
|
+
if v not in _SPECIAL_UIDS and lo <= v <= hi:
|
|
224
|
+
b[off] = (v + delta) & 0xFF
|
|
225
|
+
return bytes(b)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def insert_entry_before_band(data, entry_bytes, *, band_size: int = PARTY_BAND_SIZE):
|
|
229
|
+
"""Insert ``entry_bytes`` as a NEW object entry at the slot JUST BELOW the reserved party-character
|
|
230
|
+
band (the last ``band_size`` slots); return ``(new_data, new_slot)``.
|
|
231
|
+
|
|
232
|
+
Two steps: (1) ``+1``-remap every reference to a band slot (``[N-band_size, N-1]``) across the whole
|
|
233
|
+
script -- the characters' slot index rises by one when we make room below them; (2) insert the new entry
|
|
234
|
+
at index ``N-band_size`` (:func:`ff9mapkit.eb.edit.insert_entry_at`), shifting the band records up one and
|
|
235
|
+
bumping the entry count. The 9 character BODIES are byte-identical afterward (only their slot index +
|
|
236
|
+
table offset change), and ``new_slot == N-band_size`` is below the now-shifted band, so the engine
|
|
237
|
+
counts it as an NPC (``GetNumberNPC``: ``sid < sSourceObjN-9``). The caller arms it from Main_Init.
|
|
238
|
+
Raises if there is no full band to insert below (entry count <= ``band_size``)."""
|
|
239
|
+
eb = EbScript.from_bytes(data)
|
|
240
|
+
n = eb.entry_count
|
|
241
|
+
band_lo = n - band_size
|
|
242
|
+
if band_lo < 1:
|
|
243
|
+
raise ValueError(f"field has only {n} entries; need > {band_size} to seat an NPC below the party band")
|
|
244
|
+
shifted = shift_slot_refs(data, band_lo, n - 1, 1)
|
|
245
|
+
out = edit.insert_entry_at(shifted, band_lo, entry_bytes)
|
|
246
|
+
return out, band_lo
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def seat_entry(data, entry_bytes, *, reserve_party_band: bool = False, slot=None):
|
|
250
|
+
"""Place a new entry and return ``(new_bytes, slot)`` -- the shared allocator behind every content
|
|
251
|
+
injector (NPC / region / gateway / event). On the SYNTHESIZE path it appends into a free slot (a blank
|
|
252
|
+
field has spare NPC slots); on a VERBATIM fork (``reserve_party_band``) it INSERTS just below the engine's
|
|
253
|
+
reserved party-character band (:func:`insert_entry_before_band`), so the new entry is a true below-band
|
|
254
|
+
NPC/region and the 9 characters stay the top slots. Sequential calls compose: each insert shifts the band
|
|
255
|
+
up one and remaps band refs, leaving earlier-seated entries (which sit below the band) untouched."""
|
|
256
|
+
if reserve_party_band:
|
|
257
|
+
return insert_entry_before_band(data, entry_bytes)
|
|
258
|
+
eb = EbScript.from_bytes(data)
|
|
259
|
+
if slot is None:
|
|
260
|
+
slot = eb.first_free_slot()
|
|
261
|
+
return edit.append_entry(data, slot, entry_bytes), slot
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def graft_objects(data, specs, *, load=None, player_tag_remap=None, out_slot_map=None, out_skipped=None) -> bytes:
|
|
265
|
+
"""Graft each spec's VERBATIM object entry into ``data`` and arm it. ``specs`` come from
|
|
266
|
+
:func:`ff9mapkit.eventscan.scan_objects_verbatim` (entry bytes inline) or an import sidecar (a ``bin``
|
|
267
|
+
ref + a ``load(ref) -> bytes`` callable). Objects flagged ``graft_safety == "refuse"`` are skipped
|
|
268
|
+
(the importer leaves those to the authored ``[[npc]]``/``[[prop]]`` path); cutscene WARP-directors (a
|
|
269
|
+
``Field()`` in the kept LOOP) are ALSO skipped (#13b -- they'd re-warp the fork; ``--verbatim`` keeps them).
|
|
270
|
+
Pass ``out_skipped`` (a list) to collect the dropped directors' ``donor_idx``. Returns the new bytes.
|
|
271
|
+
|
|
272
|
+
Two passes, like the ladder ``sequences`` graft: (1) append every entry first so all new slots exist
|
|
273
|
+
(so a sibling cross-reference can resolve); (2) remap each entry's references + arm it from Main_Init.
|
|
274
|
+
|
|
275
|
+
``out_slot_map`` (optional): a dict the caller passes in; on return it holds ``{donor_idx: fork_slot}``
|
|
276
|
+
for every grafted (non-refused) OBJECT. The text-carry path (:mod:`content.textcarry`) needs it to find
|
|
277
|
+
each grafted entry and remap its window TXIDs; existing callers omit it and are unaffected.
|
|
278
|
+
|
|
279
|
+
``seqs`` on a spec (docs/OBJECT_CARRY.md S2 v1.5): the BENIGN ``STARTSEQ`` helper entries the object
|
|
280
|
+
launches from a kept tag (``{entry, bytes}`` or ``{entry, bin}``). Each is appended at a free slot
|
|
281
|
+
FIELD-SCOPED-DEDUPED (a shared helper once, not once per consumer) and its launcher arg is remapped via
|
|
282
|
+
``donor2new`` like the ladder ``sequences`` graft -- but a helper is a runtime-launched Seq, so it is
|
|
283
|
+
appended-and-remapped, NEVER ``InitObject``'d.
|
|
284
|
+
"""
|
|
285
|
+
specs = [s for s in specs if s.get("graft_safety") != "refuse"]
|
|
286
|
+
# #13b: a SYNTHESIZED fork must NOT carry cutscene WARP-directors -- an object whose KEPT loop (tag 1) fires
|
|
287
|
+
# Field() re-warps / rotates the cast at the asserted beat (the stacked-spawn / warp-out bug seen forking the
|
|
288
|
+
# Dali shop). Drop them here (`--verbatim` keeps them whole; the author can re-add a static [[npc]]). Checked
|
|
289
|
+
# on the carry_tags-filtered bytes so an init_only object that already drops its loop is left rendering.
|
|
290
|
+
kept = []
|
|
291
|
+
for s in specs:
|
|
292
|
+
raw = s.get("entry_bytes")
|
|
293
|
+
if raw is None and load is not None and s.get("bin") is not None:
|
|
294
|
+
raw = load(s["bin"])
|
|
295
|
+
if raw is not None and _loop_warps(carry_bytes(raw, s.get("carry_tags"))):
|
|
296
|
+
if out_skipped is not None:
|
|
297
|
+
out_skipped.append(int(s.get("donor_idx", -1)))
|
|
298
|
+
continue
|
|
299
|
+
kept.append(s)
|
|
300
|
+
specs = kept
|
|
301
|
+
if not specs:
|
|
302
|
+
return data
|
|
303
|
+
|
|
304
|
+
def _pents(s): # primary PC int OR the full PC-entry list (multi-PC)
|
|
305
|
+
return s.get("donor_player_entries") or s.get("donor_player_entry")
|
|
306
|
+
|
|
307
|
+
donor2new, appended, helpers = {}, [], []
|
|
308
|
+
for s in specs: # PASS 1 -- reserve every slot (objects + their helpers)
|
|
309
|
+
raw = s.get("entry_bytes")
|
|
310
|
+
if raw is None:
|
|
311
|
+
if load is None:
|
|
312
|
+
raise ValueError(f"object spec {s.get('donor_idx')} has no entry_bytes and no loader")
|
|
313
|
+
raw = load(s["bin"])
|
|
314
|
+
raw = carry_bytes(raw, s.get("carry_tags"))
|
|
315
|
+
slot = EbScript.from_bytes(data).first_free_slot()
|
|
316
|
+
data = edit.append_entry(data, slot, raw)
|
|
317
|
+
donor2new[int(s["donor_idx"])] = slot
|
|
318
|
+
appended.append((s, slot))
|
|
319
|
+
for h in (s.get("seqs") or []): # the STARTSEQ helpers this object carries
|
|
320
|
+
hi = int(h["entry"])
|
|
321
|
+
if hi in donor2new: # field-scoped dedup -- a shared helper is appended once
|
|
322
|
+
continue
|
|
323
|
+
hraw = h.get("bytes")
|
|
324
|
+
if hraw is None:
|
|
325
|
+
if load is None:
|
|
326
|
+
raise ValueError(f"seq helper {hi} has no bytes and no loader")
|
|
327
|
+
hraw = load(h["bin"])
|
|
328
|
+
hslot = EbScript.from_bytes(data).first_free_slot()
|
|
329
|
+
data = edit.append_entry(data, hslot, hraw)
|
|
330
|
+
donor2new[hi] = hslot
|
|
331
|
+
helpers.append((hi, hslot, _pents(s)))
|
|
332
|
+
for s, slot in appended: # PASS 2 -- remap references + arm from Main_Init
|
|
333
|
+
data = remap_entry_refs(data, slot, int(s["donor_idx"]), _pents(s), donor2new, player_tag_remap)
|
|
334
|
+
for inst in (s.get("instances") or [{"arg": 0}]):
|
|
335
|
+
data = _arm(data, slot, int(inst.get("arg", 0)), s.get("needs_d9") or {})
|
|
336
|
+
for hi, hslot, pents in helpers: # helpers: remap their own refs, but NEVER arm (Seq-launched)
|
|
337
|
+
data = remap_entry_refs(data, hslot, hi, pents, donor2new, player_tag_remap)
|
|
338
|
+
if out_slot_map is not None:
|
|
339
|
+
out_slot_map.update({int(s["donor_idx"]): slot for s, slot in appended})
|
|
340
|
+
return data
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Field-ENTRY one-shot hooks -- the ``[[on_entry]]`` block.
|
|
2
|
+
|
|
3
|
+
A real FF9 field's entry cutscene runs from the field's OWN ``.eb`` (entry-0 + actor sequences), so a
|
|
4
|
+
``--verbatim`` fork already carries it. (NOT a C# ``NarrowMapList`` table -- that's the engine's per-field
|
|
5
|
+
camera-WIDTH table, no cutscene logic; the old "fires from NarrowMapList, the .eb can't carry it" framing
|
|
6
|
+
was a misread -- ``docs/FORK_FIDELITY.md`` #10.) This block is for a **synthesize** fork (which doesn't ship
|
|
7
|
+
the donor ``.eb``) and for ADDING a new gated entry beat: fire a lightweight beat (a narration ``message``
|
|
8
|
+
and/or story-state writes) the moment the player ENTERS the field, **once**, optionally **gated by the
|
|
9
|
+
story state** -- so a fork can fire "the entry cutscene the real field plays at scenario N".
|
|
10
|
+
|
|
11
|
+
It sits between the existing field-load levers, filling the gap each leaves:
|
|
12
|
+
|
|
13
|
+
* ``[startup]`` -- presets story state UNCONDITIONALLY on EVERY entry (the flat beat assert).
|
|
14
|
+
* ``[cutscene]`` -- a control-locked ordered SEQUENCE (actor choreography), fires once, but UNGATED.
|
|
15
|
+
* ``[[event]]`` -- fires on a TREAD / talk zone, not on entry.
|
|
16
|
+
* ``[[on_entry]]`` -- fires on field LOAD, **gated by ``requires_flag`` / ``requires_scenario``**, once.
|
|
17
|
+
|
|
18
|
+
The gating is the new capability: neither ``[startup]`` (unconditional) nor ``[cutscene]`` (ungated)
|
|
19
|
+
can say "fire this beat only when the ScenarioCounter is N / story bit B is set".
|
|
20
|
+
|
|
21
|
+
It arms like a narration cutscene (:func:`ff9mapkit.content.cutscene.inject_cutscene`): a standalone
|
|
22
|
+
code entry run by an ``InitCode`` in Main_Init. So it runs at field load, *before* Main_Init re-enables
|
|
23
|
+
control -- which is why it has **no movement gate** (an event's ``MOVEMENT_GATE`` would never pass
|
|
24
|
+
here, since usercontrol is still 0). A ``message`` beat reuses the cutscene's reorder-``Wait`` +
|
|
25
|
+
``DisableMove`` / ``EnableMove`` dance so the window shows cleanly during the entry fade and the player
|
|
26
|
+
can't wander while it's up.
|
|
27
|
+
|
|
28
|
+
Byte-identical when absent: a field with no ``[[on_entry]]`` blocks injects nothing.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import struct
|
|
33
|
+
|
|
34
|
+
from ..eb import EbScript, edit, opcodes
|
|
35
|
+
from . import region as _region
|
|
36
|
+
from . import cutscene as _cutscene
|
|
37
|
+
from . import startup as _startup
|
|
38
|
+
|
|
39
|
+
# Auto once-flag band for a single-field build (a campaign member must pass an explicit `flag = N` --
|
|
40
|
+
# its per-member block is fully reserved for cutscene/events/choices). 8300+ sits clear of the event
|
|
41
|
+
# (8000+), cutscene (8100) and choice (8200+) auto-bands and below the chest region (8376+).
|
|
42
|
+
ONENTRY_FLAG_BASE = 8300
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def scenario_gate(value: int) -> bytes:
|
|
46
|
+
"""``ifnot (ScenarioCounter == value) { return }`` -- the entry-condition prologue. Same shape as
|
|
47
|
+
:func:`ff9mapkit.content.region.flag_gate` but tests the save-backed UInt16 ScenarioCounter
|
|
48
|
+
(``GLOB_UINT16`` at byte 0) for equality: push ``SC == value``; if TRUE skip the early ``return``."""
|
|
49
|
+
cond = _region.cond_eq(_region.GLOB_UINT16, _startup.SCENARIO_BYTE, int(value))
|
|
50
|
+
return cond + bytes([_region.JMP_TRUE]) + struct.pack("<h", 1) + opcodes.RETURN
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def on_entry_body(*, message_txid: int | None = None, set_flag_pairs=(), scenario: int | None = None,
|
|
54
|
+
item_pairs=(), gil: int | None = None,
|
|
55
|
+
once_flag: int | None = None, requires_flag: int | None = None,
|
|
56
|
+
requires_set: bool = True, requires_scenario: int | None = None) -> bytes:
|
|
57
|
+
"""The bytecode for ONE on-entry hook (no entry/return wrapper beyond the trailing ``RETURN``).
|
|
58
|
+
|
|
59
|
+
Shape::
|
|
60
|
+
|
|
61
|
+
[ifnot requires_flag { return }] # optional story-bit gate
|
|
62
|
+
[ifnot SC == requires_scenario { return }] # optional beat gate
|
|
63
|
+
if (!once_flag) { # once -- omitted when once_flag is None (fires every entry)
|
|
64
|
+
once_flag = 1 # dedup BEFORE the beat (treasure-chest convention)
|
|
65
|
+
[Wait(2); DisableMove] # only when there's a message (lock outlives Main_Init's EnableMove)
|
|
66
|
+
[WindowSync(message_txid)] # the narration beat
|
|
67
|
+
<set_scenario>; <set_flags...> # the story-state advance
|
|
68
|
+
<give_item...>; <give_gil> # per-entry starting bag/gil (scripted, not the global CSV)
|
|
69
|
+
[EnableMove]
|
|
70
|
+
}
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
The gates sit OUTSIDE the once-block, so a hook whose condition isn't met yet returns without
|
|
74
|
+
spending its once-flag -- it can still fire on a LATER entry once the beat is reached. Returns
|
|
75
|
+
``b""``-safe building blocks only; raises nothing."""
|
|
76
|
+
gates = b""
|
|
77
|
+
if requires_flag is not None:
|
|
78
|
+
gates += _region.flag_gate(_region.GLOB_BOOL, int(requires_flag), require_set=requires_set)
|
|
79
|
+
if requires_scenario is not None:
|
|
80
|
+
gates += scenario_gate(int(requires_scenario))
|
|
81
|
+
|
|
82
|
+
writes = b""
|
|
83
|
+
if scenario is not None:
|
|
84
|
+
writes += _region.set_var(_region.GLOB_UINT16, _startup.SCENARIO_BYTE, int(scenario))
|
|
85
|
+
for idx, val in set_flag_pairs:
|
|
86
|
+
writes += _region.set_var(_region.GLOB_BOOL, int(idx), 1 if val else 0)
|
|
87
|
+
# Per-entry STARTING ITEMS (a journey's per-destination bag/gil, scripted -- not the mod-GLOBAL
|
|
88
|
+
# New-Game CSV that a whole hub shares). They sit inside the once-block, so they're given exactly
|
|
89
|
+
# ONCE per save (the once-flag dedups), and behind the optional requires_scenario beat gate.
|
|
90
|
+
if item_pairs or gil is not None:
|
|
91
|
+
from . import event as _event
|
|
92
|
+
for item_id, count in item_pairs:
|
|
93
|
+
writes += _event.give_item(item_id, int(count))
|
|
94
|
+
if gil is not None:
|
|
95
|
+
writes += _event.give_gil(int(gil))
|
|
96
|
+
|
|
97
|
+
actions = (opcodes.window_sync(1, 128, int(message_txid)) if message_txid is not None else b"") + writes
|
|
98
|
+
if message_txid is not None:
|
|
99
|
+
# mirror the narration cutscene: yield a couple of frames so the lock outlives Main_Init's
|
|
100
|
+
# own EnableMove (which runs in the first frame after this InitCode), then lock for the window.
|
|
101
|
+
inner = (opcodes.wait(_cutscene.REORDER_WAIT) + opcodes.DISABLE_MOVE + actions
|
|
102
|
+
+ opcodes.ENABLE_MOVE)
|
|
103
|
+
else:
|
|
104
|
+
inner = actions
|
|
105
|
+
|
|
106
|
+
if once_flag is not None:
|
|
107
|
+
core = _region.if_block(_region.cond_not(_region.GLOB_BOOL, int(once_flag)),
|
|
108
|
+
_region.set_var(_region.GLOB_BOOL, int(once_flag), 1) + inner)
|
|
109
|
+
else:
|
|
110
|
+
core = inner
|
|
111
|
+
return gates + core + opcodes.RETURN
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def inject_on_entries(data, hooks, *, spawn_wait_n: int = 2, spawn_wait_occurrence: int = 0):
|
|
115
|
+
"""Inject any number of on-entry hooks. Each becomes a standalone code entry (the body from
|
|
116
|
+
:func:`on_entry_body`) armed by an ``InitCode`` in Main_Init -- the proven narration-cutscene
|
|
117
|
+
arming, run sequentially so each successive ``InitCode`` consumes the next Main_Init ``Wait``
|
|
118
|
+
filler and then INSERTS once the two fillers are spent (safe via the fpos-fixing fallback in
|
|
119
|
+
:func:`ff9mapkit.eb.edit.activate`).
|
|
120
|
+
|
|
121
|
+
``hooks`` is a list of dicts with the resolved keys of :func:`on_entry_body` (``message_txid``,
|
|
122
|
+
``set_flag_pairs``, ``scenario``, ``once_flag``, ``requires_flag``, ``requires_set``,
|
|
123
|
+
``requires_scenario``). Returns new ``.eb`` bytes; a no-op (input unchanged) when ``hooks`` is empty."""
|
|
124
|
+
hooks = list(hooks)
|
|
125
|
+
if not hooks:
|
|
126
|
+
return data if isinstance(data, (bytes, bytearray)) else data.to_bytes()
|
|
127
|
+
out = data if isinstance(data, (bytes, bytearray)) else data.to_bytes()
|
|
128
|
+
for h in hooks:
|
|
129
|
+
body = on_entry_body(**h)
|
|
130
|
+
entry = bytes([0x00, 0x01]) + struct.pack("<HH", 0, 4) + body
|
|
131
|
+
slot = EbScript.from_bytes(out).first_free_slot()
|
|
132
|
+
out = edit.append_entry(out, slot, entry)
|
|
133
|
+
out = edit.activate(out, opcodes.init_code(slot, 0), spawn_wait_n=spawn_wait_n,
|
|
134
|
+
spawn_wait_occurrence=spawn_wait_occurrence)
|
|
135
|
+
return out
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Party-membership authoring -- the ``[party]`` block.
|
|
2
|
+
|
|
3
|
+
Add (or remove) **existing** playable characters to/from the party at field load. This is the authoring
|
|
4
|
+
complement to ``import --swap-player`` (which changes who you WALK as): field CONTROL and party STATE are
|
|
5
|
+
**decoupled** mechanisms (memory ``project-ff9-pc-party-system``). ``[party]`` touches
|
|
6
|
+
``FF9StateGlobal.party.member[]`` -- who's in the MENU + BATTLE -- NOT who you control.
|
|
7
|
+
|
|
8
|
+
The add is FF9's real JOIN form, an EXPRESSION call ``B_PARTYADD`` (op ``0x6D``): the in-game-proven probe
|
|
9
|
+
bytes ``05 C5 93 7D <CharacterOldIndex> 00 6D 2C 7F`` (2026-06-11 -- injecting ``partyadd(Steiner)`` into a
|
|
10
|
+
clean field's Main_Init makes the party menu show the new member with starting equipment, because the 12
|
|
11
|
+
PLAYER structs exist at boot). The remove is the statement op ``RemoveParty`` (``0xDD``). The sequence is
|
|
12
|
+
**prepended to Main_Init** (entry 0 tag 0) like ``[startup]``, so it runs at field load.
|
|
13
|
+
|
|
14
|
+
★ CAVEAT: a ``SetPartyReserve`` (``0xB4``) that runs AFTER our prepend rebuilds the recruitable roster and
|
|
15
|
+
can WIPE the add -- so on a verbatim fork of a field whose Main_Init resets the party, ``build._apply_party``
|
|
16
|
+
emits a warning. Pick a field whose Main_Init doesn't reset the party (a synthesized field never does).
|
|
17
|
+
Adds are ``.eb``-only (no DLL); FF9 renders only the party LEADER in the field, so an added member shows in
|
|
18
|
+
the menu/battle, not as a walking follower.
|
|
19
|
+
|
|
20
|
+
Author-side only; the ``CharacterOldIndex`` id space (Zidane 0 .. Blank 11) is the same one the party ops
|
|
21
|
+
take and ``fork-report``'s Party axis decodes (a test pins this table to ``forkreport.CHAR_OLD_INDEX``).
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from . import region as _region
|
|
26
|
+
from ..eb import edit, opcodes
|
|
27
|
+
|
|
28
|
+
B_PARTYADD = 0x6D # expression fn (op_binary 109): partyadd(CharacterOldIndex) -> first empty slot
|
|
29
|
+
REMOVE_PARTY = 0xDD # statement op: RemoveParty(CharacterOldIndex)
|
|
30
|
+
SET_PARTY_RESERVE = 0xB4 # statement op: SetPartyReserve(mask) -- rebuilds the roster (can wipe a prior add)
|
|
31
|
+
PARTY_SCRATCH = 0x93 # MAP_BOOL throwaway index for the partyadd result (matches the proven probe bytes)
|
|
32
|
+
|
|
33
|
+
# name -> CharacterOldIndex (the .eb id space; NOT the GEO model id, NOT the internal CharacterId enum).
|
|
34
|
+
# Kept in lockstep with forkreport.CHAR_OLD_INDEX by test_party (defined here to avoid an import cycle).
|
|
35
|
+
CHAR_OLD_INDEX = {0: "Zidane", 1: "Vivi", 2: "Garnet", 3: "Steiner", 4: "Freya", 5: "Quina", 6: "Eiko",
|
|
36
|
+
7: "Amarant", 8: "Beatrix", 9: "Cinna", 10: "Marcus", 11: "Blank"}
|
|
37
|
+
NAME_TO_INDEX = {name.lower(): idx for idx, name in CHAR_OLD_INDEX.items()}
|
|
38
|
+
ALIASES = {"dagger": "garnet", "salamander": "amarant"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_member(name) -> int:
|
|
42
|
+
"""CharacterOldIndex for a member name (case-insensitive; aliases ``dagger``->garnet,
|
|
43
|
+
``salamander``->amarant). A bare int 0..11 passes through. Raises ``ValueError`` on an unknown name."""
|
|
44
|
+
if isinstance(name, bool): # guard: bools are ints in Python, never a valid member
|
|
45
|
+
raise ValueError(f"party member must be a name or 0..11, not {name!r}")
|
|
46
|
+
if isinstance(name, int):
|
|
47
|
+
if name in CHAR_OLD_INDEX:
|
|
48
|
+
return name
|
|
49
|
+
raise ValueError(f"party member index {name} out of range (0..11)")
|
|
50
|
+
key = ALIASES.get(str(name).lower().strip(), str(name).lower().strip())
|
|
51
|
+
if key not in NAME_TO_INDEX:
|
|
52
|
+
raise ValueError(f"unknown party member {name!r} -- choose from "
|
|
53
|
+
f"{', '.join(CHAR_OLD_INDEX[i] for i in sorted(CHAR_OLD_INDEX))} "
|
|
54
|
+
f"(aliases: dagger, salamander)")
|
|
55
|
+
return NAME_TO_INDEX[key]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def add_member(char_id: int) -> bytes:
|
|
59
|
+
"""``partyadd(char_id)`` -> ``05 C5 93 7D <id:i16> 6D 2C 7F`` -- the in-game-proven JOIN form (push the
|
|
60
|
+
MAP scratch result var, push the CharacterOldIndex const, apply B_PARTYADD, assign the result, end)."""
|
|
61
|
+
return (bytes([_region.EXPR_OP, _region.MAP_BOOL, PARTY_SCRATCH, _region.T_CONST])
|
|
62
|
+
+ _region._i16(int(char_id)) + bytes([B_PARTYADD, _region.T_ASSIGN, _region.T_END]))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def remove_member(char_id: int) -> bytes:
|
|
66
|
+
"""``RemoveParty(char_id)`` -> ``DD 00 <id>`` (statement op ``0xDD``, one literal byte arg)."""
|
|
67
|
+
return opcodes.encode(REMOVE_PARTY, int(char_id))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def party_body(adds=(), removes=()) -> bytes:
|
|
71
|
+
"""The Main_Init party sequence (bare bytecode, prepended into Main_Init). Returns ``b""`` when empty
|
|
72
|
+
(so a field with no ``[party]`` stays byte-identical). Removes run first (free a slot), then adds."""
|
|
73
|
+
out = b""
|
|
74
|
+
for cid in removes:
|
|
75
|
+
out += remove_member(int(cid))
|
|
76
|
+
for cid in adds:
|
|
77
|
+
out += add_member(int(cid))
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def inject_party(eb, adds=(), removes=()) -> bytes:
|
|
82
|
+
"""Prepend the party sequence to **Main_Init** (entry 0, tag 0). :func:`edit.insert_in_function` fixes the
|
|
83
|
+
entry/func tables; an offset-0 prepend is ALWAYS safe -- even on the ~11% of fields whose Main_Init opens
|
|
84
|
+
with a 0x06 scenario jump table (the engine is IP-relative, so the table shifts wholesale). No adds/removes
|
|
85
|
+
-> the input bytes unchanged (byte-identical to a field with no ``[party]``). Accepts bytes or an
|
|
86
|
+
:class:`EbScript`."""
|
|
87
|
+
data = bytes(eb) if isinstance(eb, (bytes, bytearray)) else eb.to_bytes()
|
|
88
|
+
body = party_body(adds, removes)
|
|
89
|
+
if not body:
|
|
90
|
+
return data
|
|
91
|
+
return edit.insert_in_function(data, 0, 0, 0, body)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def field_resets_party(eb) -> bool:
|
|
95
|
+
"""True if the field rebuilds the party roster with ``SetPartyReserve`` (``0xB4``) anywhere that runs at
|
|
96
|
+
field load -- which executes AFTER a prepended ``[party]`` op and can override it. Scans every non-empty
|
|
97
|
+
entry's Init (tag 0) and main loop (tag 1), not just Main_Init: real party-reset logic usually lives in an
|
|
98
|
+
object Init or entry-0 tag-1 (only 2 of 111 reset fields keep it in entry-0/tag-0). The reset can be partial
|
|
99
|
+
or scenario-gated, so this drives an advisory warning, not an error."""
|
|
100
|
+
from ..eb import EbScript
|
|
101
|
+
s = eb if hasattr(eb, "entries") else EbScript.from_bytes(bytes(eb))
|
|
102
|
+
for e in s.entries:
|
|
103
|
+
if e.empty:
|
|
104
|
+
continue
|
|
105
|
+
for f in e.funcs:
|
|
106
|
+
if f.tag not in (0, 1):
|
|
107
|
+
continue
|
|
108
|
+
for ins in s.instrs(f):
|
|
109
|
+
if ins.op == SET_PARTY_RESERVE:
|
|
110
|
+
return True
|
|
111
|
+
return False
|