ff9mapkit 1.0.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. ff9mapkit/__init__.py +18 -0
  2. ff9mapkit/__main__.py +36 -0
  3. ff9mapkit/_animdb.py +2994 -0
  4. ff9mapkit/_animdb_all.py +14125 -0
  5. ff9mapkit/_fieldtable.py +1516 -0
  6. ff9mapkit/_fieldtext.py +845 -0
  7. ff9mapkit/_held_poses.py +44 -0
  8. ff9mapkit/_itemdb.py +65 -0
  9. ff9mapkit/_modeldb.py +725 -0
  10. ff9mapkit/_narrowmap_data.py +10 -0
  11. ff9mapkit/_npcparams.py +634 -0
  12. ff9mapkit/_regen_animdb.py +72 -0
  13. ff9mapkit/_regen_animdb_all.py +66 -0
  14. ff9mapkit/_regen_fieldtable.py +95 -0
  15. ff9mapkit/_regen_fieldtext.py +66 -0
  16. ff9mapkit/_regen_modeldb.py +67 -0
  17. ff9mapkit/_regen_npcparams.py +123 -0
  18. ff9mapkit/_regen_scenedb.py +57 -0
  19. ff9mapkit/_scenedb.py +869 -0
  20. ff9mapkit/abilities.py +225 -0
  21. ff9mapkit/animations.py +120 -0
  22. ff9mapkit/archetypes.py +218 -0
  23. ff9mapkit/areatitle.py +76 -0
  24. ff9mapkit/battle/__init__.py +21 -0
  25. ff9mapkit/battle/abilityfeatures.py +294 -0
  26. ff9mapkit/battle/actiondelta.py +441 -0
  27. ff9mapkit/battle/aiauthor.py +305 -0
  28. ff9mapkit/battle/ailint.py +140 -0
  29. ff9mapkit/battle/aipatch.py +175 -0
  30. ff9mapkit/battle/battleai.py +148 -0
  31. ff9mapkit/battle/battlecsv.py +390 -0
  32. ff9mapkit/battle/battlepatch.py +395 -0
  33. ff9mapkit/battle/build.py +558 -0
  34. ff9mapkit/battle/camera_codec.py +332 -0
  35. ff9mapkit/battle/camera_data.py +128 -0
  36. ff9mapkit/battle/characterdelta.py +789 -0
  37. ff9mapkit/battle/event_data.py +72 -0
  38. ff9mapkit/battle/extract.py +540 -0
  39. ff9mapkit/battle/fbx.py +223 -0
  40. ff9mapkit/battle/reskin.py +149 -0
  41. ff9mapkit/battle/scene_codec.py +314 -0
  42. ff9mapkit/battle/scene_data.py +369 -0
  43. ff9mapkit/battle/scenelint.py +125 -0
  44. ff9mapkit/battle/seqasm.py +131 -0
  45. ff9mapkit/battle/seqauthor.py +220 -0
  46. ff9mapkit/battle/seqcodec.py +300 -0
  47. ff9mapkit/battle/seqdis.py +106 -0
  48. ff9mapkit/battle/seqpatch.py +137 -0
  49. ff9mapkit/battle_bgm.py +133 -0
  50. ff9mapkit/binutils.py +60 -0
  51. ff9mapkit/build.py +5445 -0
  52. ff9mapkit/campaign.py +1276 -0
  53. ff9mapkit/catalog.py +316 -0
  54. ff9mapkit/chain.py +358 -0
  55. ff9mapkit/cli.py +3114 -0
  56. ff9mapkit/config.py +360 -0
  57. ff9mapkit/content/__init__.py +13 -0
  58. ff9mapkit/content/areatitle.py +36 -0
  59. ff9mapkit/content/ate.py +118 -0
  60. ff9mapkit/content/camera.py +123 -0
  61. ff9mapkit/content/chest.py +186 -0
  62. ff9mapkit/content/choice.py +163 -0
  63. ff9mapkit/content/conductor.py +217 -0
  64. ff9mapkit/content/cutscene.py +290 -0
  65. ff9mapkit/content/encounter.py +41 -0
  66. ff9mapkit/content/entry_settle.py +50 -0
  67. ff9mapkit/content/equipment.py +93 -0
  68. ff9mapkit/content/event.py +191 -0
  69. ff9mapkit/content/gateway.py +101 -0
  70. ff9mapkit/content/inventory.py +59 -0
  71. ff9mapkit/content/itemdata.py +644 -0
  72. ff9mapkit/content/itemtext.py +168 -0
  73. ff9mapkit/content/jump.py +114 -0
  74. ff9mapkit/content/ladder.py +633 -0
  75. ff9mapkit/content/movement.py +53 -0
  76. ff9mapkit/content/music.py +97 -0
  77. ff9mapkit/content/npc.py +348 -0
  78. ff9mapkit/content/object.py +340 -0
  79. ff9mapkit/content/onentry.py +135 -0
  80. ff9mapkit/content/party.py +111 -0
  81. ff9mapkit/content/pathfind.py +138 -0
  82. ff9mapkit/content/platform.py +314 -0
  83. ff9mapkit/content/player.py +168 -0
  84. ff9mapkit/content/prop.py +75 -0
  85. ff9mapkit/content/region.py +340 -0
  86. ff9mapkit/content/reinit.py +59 -0
  87. ff9mapkit/content/savepoint.py +90 -0
  88. ff9mapkit/content/shop.py +178 -0
  89. ff9mapkit/content/sps_trigger.py +66 -0
  90. ff9mapkit/content/startup.py +71 -0
  91. ff9mapkit/content/synthesis.py +106 -0
  92. ff9mapkit/content/text.py +183 -0
  93. ff9mapkit/content/textcarry.py +290 -0
  94. ff9mapkit/content/verbatim.py +86 -0
  95. ff9mapkit/content/walkmesh_hotfix.py +38 -0
  96. ff9mapkit/data/__init__.py +48 -0
  97. ff9mapkit/data/_regen_provenance.py +142 -0
  98. ff9mapkit/data/provenance/blank.es.patch +1 -0
  99. ff9mapkit/data/provenance/blank.fr.patch +1 -0
  100. ff9mapkit/data/provenance/blank.gr.patch +1 -0
  101. ff9mapkit/data/provenance/blank.it.patch +1 -0
  102. ff9mapkit/data/provenance/blank.jp.patch +1 -0
  103. ff9mapkit/data/provenance/blank.uk.patch +1 -0
  104. ff9mapkit/data/provenance/blank.us.patch +1 -0
  105. ff9mapkit/data/provenance/manifest.json +65 -0
  106. ff9mapkit/data/provenance/region_template.patch +1 -0
  107. ff9mapkit/data/reference_arcs.toml +89 -0
  108. ff9mapkit/data/region_catalog.toml +593 -0
  109. ff9mapkit/deploystack.py +358 -0
  110. ff9mapkit/dialogue.py +803 -0
  111. ff9mapkit/eb/__init__.py +12 -0
  112. ff9mapkit/eb/_exprtable.py +59 -0
  113. ff9mapkit/eb/_membertable.py +38 -0
  114. ff9mapkit/eb/_optables.py +537 -0
  115. ff9mapkit/eb/_regen_optables.py +76 -0
  116. ff9mapkit/eb/cmdasm.py +323 -0
  117. ff9mapkit/eb/disasm.py +332 -0
  118. ff9mapkit/eb/edit.py +439 -0
  119. ff9mapkit/eb/exprasm.py +158 -0
  120. ff9mapkit/eb/model.py +178 -0
  121. ff9mapkit/eb/opcodes.py +463 -0
  122. ff9mapkit/eblint.py +177 -0
  123. ff9mapkit/editor/__init__.py +20 -0
  124. ff9mapkit/editor/app.py +950 -0
  125. ff9mapkit/editor/battle_forms.py +240 -0
  126. ff9mapkit/editor/breadcrumb.py +89 -0
  127. ff9mapkit/editor/dialogs.py +116 -0
  128. ff9mapkit/editor/feedback.py +208 -0
  129. ff9mapkit/editor/forms.py +632 -0
  130. ff9mapkit/editor/graphview.py +350 -0
  131. ff9mapkit/editor/jobs.py +342 -0
  132. ff9mapkit/editor/model.py +243 -0
  133. ff9mapkit/editor/picker.py +120 -0
  134. ff9mapkit/editor/theme.py +212 -0
  135. ff9mapkit/eventscan.py +1441 -0
  136. ff9mapkit/extract.py +2279 -0
  137. ff9mapkit/flags.py +693 -0
  138. ff9mapkit/forkreport.py +1383 -0
  139. ff9mapkit/hub.py +477 -0
  140. ff9mapkit/idgated.py +101 -0
  141. ff9mapkit/infohub.py +580 -0
  142. ff9mapkit/items.py +63 -0
  143. ff9mapkit/itemstats.py +346 -0
  144. ff9mapkit/journey.py +1902 -0
  145. ff9mapkit/keyitems.py +93 -0
  146. ff9mapkit/logic_add.py +632 -0
  147. ff9mapkit/logic_edit.py +728 -0
  148. ff9mapkit/logic_map.py +526 -0
  149. ff9mapkit/pack.py +175 -0
  150. ff9mapkit/playerswap.py +231 -0
  151. ff9mapkit/prop_archetypes.py +228 -0
  152. ff9mapkit/provision.py +282 -0
  153. ff9mapkit/refarc.py +825 -0
  154. ff9mapkit/save.py +337 -0
  155. ff9mapkit/save_items.py +1673 -0
  156. ff9mapkit/scene/__init__.py +11 -0
  157. ff9mapkit/scene/arena.py +63 -0
  158. ff9mapkit/scene/bgart.py +140 -0
  159. ff9mapkit/scene/bgi.py +732 -0
  160. ff9mapkit/scene/bgs.py +174 -0
  161. ff9mapkit/scene/bgx.py +185 -0
  162. ff9mapkit/scene/cam.py +345 -0
  163. ff9mapkit/scene/guide.py +311 -0
  164. ff9mapkit/scene/paint.py +506 -0
  165. ff9mapkit/scene/placeholder.py +107 -0
  166. ff9mapkit/sjbinary.py +285 -0
  167. ff9mapkit/sps/__init__.py +17 -0
  168. ff9mapkit/sps/author.py +294 -0
  169. ff9mapkit/sps/catalog.py +88 -0
  170. ff9mapkit/sps/codec.py +264 -0
  171. ff9mapkit/sps/edit.py +184 -0
  172. ff9mapkit/sps/lint.py +58 -0
  173. ff9mapkit/sps/render.py +116 -0
  174. ff9mapkit/sps/templates.py +47 -0
  175. ff9mapkit/sps/texture.py +131 -0
  176. ff9mapkit/walkmesh_hotfixes.py +163 -0
  177. ff9mapkit/workspace/__init__.py +18 -0
  178. ff9mapkit/workspace/battledoc.py +985 -0
  179. ff9mapkit/workspace/builddoc.py +607 -0
  180. ff9mapkit/workspace/forms_qt.py +586 -0
  181. ff9mapkit/workspace/importdoc.py +665 -0
  182. ff9mapkit/workspace/mapview.py +131 -0
  183. ff9mapkit/workspace/palette.py +85 -0
  184. ff9mapkit/workspace/savedoc.py +664 -0
  185. ff9mapkit/workspace/shell.py +6907 -0
  186. ff9mapkit/workspace/style.py +105 -0
  187. ff9mapkit/workspace/tuningdialog.py +223 -0
  188. ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
  189. ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
  190. ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
  191. ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
  192. ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
  193. ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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