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,186 @@
1
+ """Inject a real openable TREASURE CHEST -- one object whose pose is save-flag-gated (closed/open) and
2
+ whose press handler animates the lid, gives a fixed item or gil, shows a "Received X" box, and latches the
3
+ flag so it STAYS OPEN across saves.
4
+
5
+ Byte-grounded on real FF9 chests (field 200 entry 9; field 407 entries 12/22 -- model 75 = GEO_ACC_F0_TBX),
6
+ decoded opcode-for-opcode. Init (tag 0): CreateObject + TurnInstant + SetObjectLogicalSize(1,40,45) [the
7
+ collision box] + SetStandAnimation(7340) + SetObjectFlags(5) + SetHeadFocusMask(2,0) + a TWO-ARM pose branch
8
+ on a save-persistent GLOB_BOOL opened-flag -> SetStandAnimation(OPEN 7338) when SET / (CLOSED 7339) when
9
+ CLEAR + SetObjectFlags(49) [show + can't-walk-through(16) + don't-hide(32) = the chest's solid collision] +
10
+ EnableHeadFocus(0). The model is ALWAYS shown; only the pose differs, so a re-entered / reloaded field
11
+ re-poses the chest OPEN (the flag persists in gEventGlobal across saves) with zero per-visit bookkeeping.
12
+
13
+ The open handler runs in the chest's OWN object context (so RunAnimation animates the chest's own model -- a
14
+ separate region has no model and could not). SetObjectFlags bits (EventEngine.DoEventCode CFLAG 0x93:2040):
15
+ 1 show, 2 collide-player, 4 collide-NPC, 8 disable-talk, 16 can't-walk-through, 32 don't-hide.
16
+
17
+ Fidelity note: real chests put the open in the object's tag-2 RANGE function and gate the ``Bubble`` "!" on
18
+ the opened-flag; the kit uses the object's tag-3 talk handler (press X while near) and instead sets the
19
+ **disable-talk** flag (bit 8) once opened, so a looted chest shows no "!" and ignores presses -- the same
20
+ "approach + press once, then inert" behaviour, a different dispatch tag.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import struct
25
+
26
+ from .. import items as _items
27
+ from ..eb import edit, opcodes
28
+ from . import event as _event
29
+ from . import npc as _npc
30
+ from . import region as _region
31
+
32
+ CHEST_MODEL = 75 # GEO_ACC_F0_TBX (the kit prop archetype "chest")
33
+ CLOSED_POSE = 7339 # SetStandAnimation rest pose when CLOSED
34
+ OPEN_POSE = 7338 # ... when OPEN (after looting)
35
+ NEUTRAL_POSE = 7340 # the transitional default the real Init sets before the pose branch
36
+ OPEN_ANIM = 7336 # the RunAnimation lid-open clip
37
+ CHEST_LOGICAL_SIZE = (1, 40, 45) # SetObjectLogicalSize -- the real chest's collision box
38
+ CHEST_FLAGS_INIT = 5 # SetObjectFlags initial: show(1) + collide-NPC(4) (matches the real Init)
39
+ CHEST_FLAGS_CLOSED = 49 # show(1) + can't-walk-through(16) + don't-hide(32): solid + talkable (-> "!")
40
+ CHEST_FLAGS_OPEN = 57 # CHEST_FLAGS_CLOSED + disable-talk(8): solid but NO "!" / inert once looted
41
+ CHEST_FLAG_CLASS = _region.GLOB_BOOL # 0xC4 -- save-persistent gEventGlobal bool
42
+ # The opened-flag is REQUIRED (no auto-allocation): inject_chest takes it as `flag_idx`, and build.validate
43
+ # enforces a DEFINED flag in the safe custom band [flags.FIRST_SAFE_FLAG, CHOICE_SCRATCH_FLOOR) -- so it can't
44
+ # shift on reorder (a positional bit would) or collide with FF9's own chest bitfield ([8376, 8511]). A named
45
+ # [[flag]] is the ergonomic, campaign-unique choice.
46
+
47
+ SET_MODEL = 0x2F
48
+ SET_OBJECT_LOGICAL_SIZE = 0x4B
49
+ SET_OBJECT_FLAGS = 0x93
50
+ SET_HEAD_FOCUS_MASK = 0x8B
51
+ ENABLE_HEAD_FOCUS = 0x47
52
+ SET_ANIMATION_FLAGS = 0x3F
53
+ RUN_SOUND_CODE3 = 0xC8 # RunSoundCode3(bank, sound_id, p1, p2, p3) -- the SFX op the real chest uses
54
+ LID_SFX = (637, 638) # the two lid-creak sound ids the real chest plays
55
+ ITEM_JINGLE = 108 # the item-get jingle the real chest plays when the Received box appears
56
+ SFX_BANK = 53248 # 0xD000 -- the sound bank the chest SFX live in
57
+ SFX_PARAMS = (0, 128, 125) # the pan/volume params (byte-faithful to fields 200/407)
58
+
59
+ # The 4 FF9 treasure-chest models (GEO_ACC_F0..F3_TBX) and their per-model animation ids -- decoded byte-for-byte
60
+ # from real fields (the Init's [neutral, open, closed] SetStandAnimation order + the tag-2 lid RunAnimation) and
61
+ # independently cross-verified across many fields, anchored to the in-game-proven F0 chest. The chest OBJECT is
62
+ # otherwise byte-identical across models (same collision LogSize 1,40,45, flags 5->49, opened-flag branch), so
63
+ # only the model id + these 4 animation ids vary. Two clip schemes: F0/F2 share the 73xx clips, F1/F3 share the
64
+ # low ids. ★ Extracting the FBX+textures from p0data (models/1/<id>/) confirms only TWO DISTINCT LOOKS: F1 (91) and
65
+ # F3 (702) are byte-identical (same mesh + both textures); F0 (75) and F2 (701) share the mesh + one texture and
66
+ # differ ONLY in F2's other texture being a ~magenta UNUSED dummy -> renders the same as F0. F2/F3 are per-zone
67
+ # duplicate ids kept here for fidelity; author with F0/F1 for the two real looks. tuple = (model_id, neutral, open, closed, lid).
68
+ CHEST_VARIANTS = {
69
+ "F0": (75, 7340, 7338, 7339, 7336), # GEO_ACC_F0_TBX -- the default wooden chest (in-game proven)
70
+ "F1": (91, 4, 1, 3, 22), # GEO_ACC_F1_TBX -- the 2nd real look
71
+ "F2": (701, 7340, 7338, 7339, 7336), # GEO_ACC_F2_TBX -- F0 with a magenta dummy texture (looks identical to F0)
72
+ "F3": (702, 4, 1, 3, 22), # GEO_ACC_F3_TBX -- byte-identical to F1
73
+ }
74
+ _VARIANT_BY_MODEL = {tup[0]: tup for tup in CHEST_VARIANTS.values()} # model id -> the same 5-tuple
75
+
76
+
77
+ def resolve_chest_variant(model=None):
78
+ """Resolve a ``[[chest]] model`` to ``(model_id, neutral_pose, open_pose, closed_pose, lid_anim)``.
79
+ ``model`` is a variant NAME ("F0".."F3", case-insensitive), a raw model id (75/91/701/702), or ``None``
80
+ (= the default F0 wooden chest). Raises ``ValueError`` on an unknown model -- its open/closed/lid animation
81
+ ids aren't known, and a bare model swap (keeping F0's 73xx clips) would play the wrong / no lid + pose."""
82
+ if model is None or model == "":
83
+ return CHEST_VARIANTS["F0"]
84
+ if isinstance(model, str) and not model.strip().lstrip("-").isdigit():
85
+ key = model.strip().upper()
86
+ if key not in CHEST_VARIANTS:
87
+ raise ValueError(f"unknown chest model {model!r} -- use one of {sorted(CHEST_VARIANTS)} "
88
+ f"(the F0..F3 treasure-chest variants) or a raw model id {sorted(_VARIANT_BY_MODEL)}")
89
+ return CHEST_VARIANTS[key]
90
+ mid = int(model)
91
+ if mid not in _VARIANT_BY_MODEL:
92
+ raise ValueError(f"chest model id {mid} has no known open/closed/lid animations -- use a TBX chest "
93
+ f"variant {sorted(CHEST_VARIANTS)} (ids {sorted(_VARIANT_BY_MODEL)})")
94
+ return _VARIANT_BY_MODEL[mid]
95
+
96
+
97
+ def chest_lid_sfx() -> bytes:
98
+ """The lid-creak open SFX, byte-faithful to the real chest (fields 200/407): SetAnimationFlags(1,0) +
99
+ two RunSoundCode3 (bank 53248, ids 637 then 638)."""
100
+ out = opcodes.encode(SET_ANIMATION_FLAGS, 1, 0)
101
+ for sid in LID_SFX:
102
+ out += opcodes.encode(RUN_SOUND_CODE3, SFX_BANK, sid, *SFX_PARAMS)
103
+ return out
104
+
105
+
106
+ def build_chest_init(*, x: int, z: int, flag_idx: int, model: int = CHEST_MODEL, animset: int | None = None,
107
+ face: int = 0, neutral_pose: int = NEUTRAL_POSE, open_pose: int = OPEN_POSE,
108
+ closed_pose: int = CLOSED_POSE, gate=None) -> bytes:
109
+ """The chest Init (tag 0), opcode-faithful to the real chest, with the SAVABLE open-state: a two-arm
110
+ pose+flags branch on the opened flag -- the OPEN pose + the inert(disable-talk) flags when SET, the
111
+ CLOSED pose + the talkable flags when CLEAR. The collision (size + flags) is unconditional. ``gate`` =
112
+ ``(flag_index, require_set)`` prepends a story-flag gate (same as an NPC's): the chest is ABSENT unless
113
+ that APPEARANCE flag is in state -- a quest-reward chest that only materializes after a beat (distinct
114
+ from ``flag_idx``, the OPENED bit)."""
115
+ animset_v, _hf, _ls = _npc._npc_object_params(model, animset)
116
+ parts = []
117
+ if gate is not None:
118
+ gf, gset = gate
119
+ parts.append(_region.flag_gate(_region.GLOB_BOOL, gf, require_set=gset))
120
+ parts += [
121
+ _npc._d9_const(0, x), _npc._d9_const(4, z), _npc._d9_const(6, face), _npc._d9_const(2, 0),
122
+ bytes([SET_MODEL, 0x00]) + struct.pack("<H", int(model) & 0xFFFF) + bytes([animset_v & 0xFF]),
123
+ _npc._CREATE_OBJECT, _npc._TURN_INSTANT,
124
+ opcodes.encode(SET_OBJECT_LOGICAL_SIZE, *CHEST_LOGICAL_SIZE), # the collision box
125
+ opcodes.set_stand_animation(neutral_pose),
126
+ opcodes.encode(SET_OBJECT_FLAGS, CHEST_FLAGS_INIT),
127
+ opcodes.encode(SET_HEAD_FOCUS_MASK, 2, 0),
128
+ _region.if_else(_region.cond_truthy(CHEST_FLAG_CLASS, flag_idx),
129
+ opcodes.set_stand_animation(open_pose) + opcodes.encode(SET_OBJECT_FLAGS, CHEST_FLAGS_OPEN),
130
+ opcodes.set_stand_animation(closed_pose) + opcodes.encode(SET_OBJECT_FLAGS, CHEST_FLAGS_CLOSED)),
131
+ opcodes.encode(ENABLE_HEAD_FOCUS, 0),
132
+ opcodes.RETURN,
133
+ ]
134
+ return b"".join(parts)
135
+
136
+
137
+ def build_chest_open(flag_idx: int, *, give: bytes, received_text_id: int, payload_value: int,
138
+ open_anim: int = OPEN_ANIM, open_pose: int = OPEN_POSE) -> bytes:
139
+ """The chest press handler (tag 3): no-op if already opened, else animate the lid open, give the
140
+ payload, show the Received box, latch the opened flag, and set the disable-talk flag (so it shows no
141
+ more "!" and ignores presses for the rest of the visit -- the Init handles it on the next load)."""
142
+ return b"".join([
143
+ _region.flag_gate(CHEST_FLAG_CLASS, flag_idx, require_set=False), # already opened -> return
144
+ chest_lid_sfx(), # the lid-creak SFX (637/638)
145
+ opcodes.run_animation(open_anim), opcodes.wait_animation(), # lid opens (on the chest's own model)
146
+ opcodes.set_stand_animation(open_pose), # hold the open pose for this visit
147
+ opcodes.encode(RUN_SOUND_CODE3, SFX_BANK, ITEM_JINGLE, *SFX_PARAMS), # the item-get jingle (with the box)
148
+ give, # AddItem / AddGil
149
+ opcodes.set_text_variable(0, payload_value), # bind the item/amount for the box
150
+ opcodes.window_sync(7, 0, received_text_id), # window TYPE 7 = the "Received X" box
151
+ _region.set_var(CHEST_FLAG_CLASS, flag_idx, 1), # latch the opened flag (save-backed)
152
+ opcodes.encode(SET_OBJECT_FLAGS, CHEST_FLAGS_OPEN), # disable talk -> no "!" the rest of the visit
153
+ opcodes.RETURN,
154
+ ])
155
+
156
+
157
+ def inject_chest(data, x, z, *, flag_idx: int, item=None, gil=None, count: int = 1,
158
+ received_text_id: int = 62, model="F0", face: int = 0, gate=None,
159
+ reserve_party_band: bool = False, spawn_wait_n: int = 2, spawn_wait_occurrence: int = 0):
160
+ """Inject an openable, savable treasure chest at world (x, z) -- ONE object (tag 0 Init + tag 3 open).
161
+ Exactly one of ``item`` (id/name + ``count``) or ``gil`` (amount). ``flag_idx`` (a GLOB_BOOL save index)
162
+ is the opened bit -- it drives the Init open/closed pose+flags and the open handler's once-guard + latch.
163
+ ``model`` picks the chest VARIANT (a name "F0".."F3" or a raw id 75/91/701/702 -- :func:`resolve_chest_variant`
164
+ supplies its own open/closed/neutral pose + lid clip). ``face`` rotates the model; ``gate`` =
165
+ ``(flag_index, require_set)`` makes the chest's APPEARANCE story-gated. Returns new ``.eb`` bytes."""
166
+ if (item is None) == (gil is None):
167
+ raise ValueError("inject_chest needs exactly one of item= or gil=")
168
+ model_id, neutral_pose, open_pose, closed_pose, lid_anim = resolve_chest_variant(model)
169
+ if item is not None:
170
+ item_id = _items.resolve(item)
171
+ give, payload = _event.give_item(item_id, count), item_id
172
+ else:
173
+ give, payload = _event.give_gil(int(gil)), int(gil)
174
+ init = build_chest_init(x=int(x), z=int(z), flag_idx=flag_idx, model=model_id, face=int(face), gate=gate,
175
+ neutral_pose=neutral_pose, open_pose=open_pose, closed_pose=closed_pose)
176
+ openb = build_chest_open(flag_idx, give=give, received_text_id=received_text_id, payload_value=payload,
177
+ open_anim=lid_anim, open_pose=open_pose)
178
+ if len(openb) < 9: # IsActuallyTalkable polls tag3[ip+7/8]; keep it >= 9 bytes
179
+ openb += b"\x00" * (9 - len(openb))
180
+ table_len = 2 * 4
181
+ table = struct.pack("<HH", 0, table_len) + struct.pack("<HH", 3, table_len + len(init))
182
+ entry = bytes([_npc.NPC_ENTRY_TYPE, 2]) + table + init + openb # type-2 object: tag 0 + tag 3
183
+ from . import object as _object
184
+ out, slot = _object.seat_entry(data, entry, reserve_party_band=reserve_party_band)
185
+ return edit.activate(out, opcodes.init_object(slot, 0), spawn_wait_n=spawn_wait_n,
186
+ spawn_wait_occurrence=spawn_wait_occurrence)
@@ -0,0 +1,163 @@
1
+ """Dialogue CHOICES -- show the player a menu of options and branch on the pick.
2
+
3
+ This is the interaction / puzzle primitive: a merchant, a lever with a "Yes/No", a quest-giver. It's
4
+ the conditional-region expression machinery (:mod:`ff9mapkit.content.region`) pointed at the engine's
5
+ choice result instead of a story flag.
6
+
7
+ Grounded BYTE-FOR-BYTE in real FF9 (the Black Mage shop, field 817):
8
+
9
+ WindowSync( 0, 128, 97 ) // the PROMPT + the option rows are ONE text entry:
10
+ // "...Can I help you?[CHOO][MOVE=18,0]Buy/Sell\n...Nothing"
11
+ switch ( GetDialogChoice ) from 0 { // branch on the picked row
12
+ case +0: ... case +1: ... }
13
+
14
+ Engine facts (Memoria source):
15
+ * The window is SYNCHRONOUS (``MES`` 0x1F "wait until it closes"), so the pick is finalised before
16
+ the next opcode runs; ``Dialog`` stores it in ``ETb.sChoose``.
17
+ * A script READS the pick via the expression sysvar token ``B_SYSVAR`` (0x7A) with code 9 ->
18
+ ``GetSysvar(9)`` -> ``ETb.GetChoose()`` (0-based row index). See :func:`region.cond_sysvar_eq`.
19
+ * With no ``[PCHC]``/``[PCHM]`` pre-tags the choice count comes from the rows (all enabled), and
20
+ CANCEL (B) returns the LAST row -- so put the "decline" option last.
21
+
22
+ The prompt/option TEXT (the ``[CHOO][MOVE=18,0]`` rows) is assembled in :mod:`ff9mapkit.build`
23
+ (``collect_text``); here we build only the SCRIPT side: the window call + the per-option branch.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from ..eb import opcodes
29
+ from . import event as _event, region as _region
30
+
31
+ # zone-triggered choices auto-allocate a GLOB gate flag from here (clear of events 8000 + cutscene
32
+ # 8100). It must be GLOB (gEventGlobal is large); the per-field MAP array is only 80 bytes, so a high
33
+ # index there is out of bounds and crashes. once-per-visit is done by resetting this flag in the
34
+ # region's Init (re-runs each field load), not by a transient MAP flag.
35
+ CHOICE_FLAG_BASE = 8200
36
+
37
+
38
+ def option_body(opt: dict, reply_txid: int | None = None) -> bytes:
39
+ """Compose ONE option's actions (the body run if the player picks it). Reuses the event action
40
+ vocabulary so a choice option does exactly what an event does: an optional reply line, then
41
+ give/take item, gil, set a story flag, optionally advance the ScenarioCounter, and (LAST) WARP to
42
+ another field. Order: reply -> give_item -> remove_item -> gil -> set_flag -> set_scenario -> warp.
43
+ ``warp`` is last because a Field op transitions away (anything after it is unreachable) -- this is the
44
+ World-Hub journey-pick primitive: a menu row that seeds the beat then warps into the chosen field."""
45
+ parts = []
46
+ if reply_txid is not None:
47
+ parts.append(_event.message(reply_txid))
48
+ if "give_item" in opt:
49
+ gi = opt["give_item"]
50
+ parts.append(_event.give_item(gi[0], int(gi[1]) if len(gi) > 1 else 1)) # gi[0] = id or name
51
+ if "remove_item" in opt:
52
+ ri = opt["remove_item"]
53
+ parts.append(_event.take_item(ri[0], int(ri[1]) if len(ri) > 1 else 1)) # symmetric: a trade option
54
+ if "gil" in opt:
55
+ parts.append(_event.give_gil(int(opt["gil"])))
56
+ if "set_flag" in opt:
57
+ sf = opt["set_flag"]
58
+ parts.append(_event.set_flag(int(sf[0]), int(sf[1]) if len(sf) > 1 else 1))
59
+ if "set_scenario" in opt:
60
+ parts.append(_event.set_scenario(int(opt["set_scenario"])))
61
+ if "warp" in opt:
62
+ # A choice that warps is ALWAYS a field transition, so it fades out first (fade=True) -- exactly
63
+ # like a gateway/ladder. Without the fade the destination loads in the clear and you see its
64
+ # camera-init frames (the World-Hub static-screen bug). entrance (optional) sets the arrival
65
+ # entrance var; it is not the camera fix (the fade is) -- see event.warp.
66
+ parts.append(_event.warp(int(opt["warp"]), entrance=opt.get("entrance"), fade=True)) # LAST: away
67
+ return b"".join(parts)
68
+
69
+
70
+ def _gated(o: dict) -> bool:
71
+ """An option whose visibility depends on a story flag at runtime (flag-gated hide)."""
72
+ return "requires_flag" in o or "requires_flag_clear" in o
73
+
74
+
75
+ def dynamic_mask_setup(options, default: int) -> bytes:
76
+ """Build the availability mask AT RUNTIME from per-option story flags, then point
77
+ ``EnableDialogChoices`` at it -- the real-field pattern (Dali/Storage's moogle-mail menu:
78
+ ``set_var`` the base, ``if(flag) or_var`` each conditional bit, then ``EnableDialogChoices(VAR | .., 0)``).
79
+
80
+ Always-visible rows form the base word; each flag-gated row ORs its bit in only when its condition
81
+ holds (``requires_flag`` -> visible when SET, ``requires_flag_clear`` -> visible when CLEAR). The
82
+ mask lives in a high scratch word (``region.MASK_SCRATCH_IDX``) and is read back as an UNSIGNED
83
+ UInt16 expression-arg (no sign trap). Statically ``disabled`` rows are simply never ORed in."""
84
+ base = sum(1 << i for i, o in enumerate(options) if not o.get("disabled") and not _gated(o))
85
+ parts = [_region.set_var(_region.GLOB_UINT16, _region.MASK_SCRATCH_IDX, base)]
86
+ for i, o in enumerate(options):
87
+ if o.get("disabled") or not _gated(o):
88
+ continue
89
+ if "requires_flag" in o:
90
+ cond = _region.cond_truthy(_region.GLOB_BOOL, int(o["requires_flag"]))
91
+ else:
92
+ cond = _region.cond_not(_region.GLOB_BOOL, int(o["requires_flag_clear"]))
93
+ parts.append(_region.if_block(cond, _region.or_var(_region.GLOB_UINT16, _region.MASK_SCRATCH_IDX, 1 << i)))
94
+ mask_expr = _region.var_expr(_region.GLOB_UINT16, _region.MASK_SCRATCH_IDX)
95
+ parts.append(opcodes.enable_dialog_choices_var(mask_expr, default))
96
+ return b"".join(parts)
97
+
98
+
99
+ def pre_choose(ch: dict) -> tuple[bytes, str]:
100
+ """Pre-choose config for a choice: which row is highlighted by DEFAULT, which row CANCEL (B) picks,
101
+ and which options are HIDDEN (statically via ``disabled``, or flag-gated via ``requires_flag`` /
102
+ ``requires_flag_clear``). Returns ``(setup_bytes, text_tag)`` -- ``setup`` runs before the window
103
+ (see :func:`region_body`), ``tag`` is prepended to the choice text. ``(b"", "")`` when nothing is
104
+ configured, so a plain choice stays byte-identical.
105
+
106
+ Mechanism (Memoria ``Dialog.SetupChoose`` + ``ETb.SetChooseParam``): an ``EnableDialogChoices``
107
+ opcode sets the availability mask (bit i = row i shown, LSB-first) + the default row; the
108
+ ``[PCHM=count,cancel]`` text tag tells the dialog to APPLY the mask (hidden rows get no widget),
109
+ ``[PCHC=count,cancel]`` sets count/cancel/default WITHOUT hiding. ``GetChoose()`` returns the
110
+ ABSOLUTE row index regardless of hides, so the per-option :func:`branch` is unaffected.
111
+
112
+ Three modes: flag-gated (any ``requires_flag``) -> a runtime mask (:func:`dynamic_mask_setup`);
113
+ static-hide (any ``disabled``) -> a literal partial mask; default/cancel only -> a literal all-on
114
+ mask ``(1<<n)-1`` (NOT 0xFFFF, which sign-extends to -1 and breaks ``SetChooseParam``'s
115
+ ``while availMask>0`` loop -> default collapses to 0)."""
116
+ options = ch.get("options", [])
117
+ n = len(options)
118
+ default = int(ch.get("default", 0))
119
+ cancel = int(ch["cancel"]) if "cancel" in ch else (n - 1) # engine default cancel = last row
120
+ has_static = any(o.get("disabled") for o in options)
121
+ has_dynamic = any(_gated(o) for o in options)
122
+ if not (has_static or has_dynamic or "default" in ch or "cancel" in ch):
123
+ return b"", "" # nothing configured -> byte-identical
124
+ if has_dynamic:
125
+ return dynamic_mask_setup(options, default), f"[PCHM={n},{cancel}]"
126
+ if has_static:
127
+ mask = sum(1 << i for i in range(n) if not options[i].get("disabled"))
128
+ return opcodes.enable_dialog_choices(mask, default), f"[PCHM={n},{cancel}]"
129
+ return opcodes.enable_dialog_choices((1 << n) - 1, default), f"[PCHC={n},{cancel}]"
130
+
131
+
132
+ def branch(option_bodies) -> bytes:
133
+ """``if (GetChoose()==0){b0} if (GetChoose()==1){b1} ...`` -- one independent if-block per option
134
+ (exactly how FF9 lays out choice handlers). Options with an empty body emit nothing."""
135
+ out = b""
136
+ for i, body in enumerate(option_bodies):
137
+ if body:
138
+ out += _region.if_block(_region.cond_sysvar_eq(_region.SYSVAR_CHOICE, i), body)
139
+ return out
140
+
141
+
142
+ def region_body(prompt_txid: int, option_bodies, *, window: int = 1, flags: int = 128,
143
+ setup: bytes = b"") -> bytes:
144
+ """The choice block usable in ANY trigger context (an NPC talk OR a walk-in region): lock the
145
+ player, (optional pre-choose ``setup``), open the prompt+options window, branch on the pick, restore
146
+ control. **No RETURN** -- the caller adds it (NPC) or wraps it in a flag-gated region body (zone).
147
+
148
+ ``setup`` is the optional ``EnableDialogChoices`` opcode from :func:`pre_choose` (default/cancel/
149
+ disabled config); it MUST run before the window opens. Why DisableMove/EnableMove: the engine does
150
+ NOT block field movement while a dialog is open, so without this the d-pad would move BOTH the menu
151
+ cursor AND the character. Real FF9 wraps a choice in DisableMove...EnableMove (e.g. the Black Mage
152
+ shop), and the menu still navigates because choice input comes from the dialog system, not field
153
+ control."""
154
+ return (opcodes.DISABLE_MOVE + setup + opcodes.window_sync(window, flags, prompt_txid)
155
+ + branch(option_bodies) + opcodes.ENABLE_MOVE)
156
+
157
+
158
+ def speak_body(prompt_txid: int, option_bodies, *, window: int = 1, flags: int = 128,
159
+ setup: bytes = b"") -> bytes:
160
+ """A complete ``_SpeakBTN`` (NPC talk) body for a choice: the choice block + RETURN. ``flags`` 128
161
+ is the standard field dialogue flag (same as plain NPC dialogue). ``setup`` = optional pre-choose
162
+ opcode (see :func:`pre_choose`)."""
163
+ return region_body(prompt_txid, option_bodies, window=window, flags=flags, setup=setup) + opcodes.RETURN
@@ -0,0 +1,217 @@
1
+ """Multi-actor cutscene CONDUCTOR -- FF9's central-director idiom (memory project-ff9-cutscene-multiactor).
2
+
3
+ A from-9-real-fields study (565/909/2554/2207/2169 ...) found FF9 coordinates 2+ scripted actors with a
4
+ single CONDUCTOR function -- NOT a per-actor flag handshake. ONE function (a code entry armed by
5
+ ``InitCode`` in Main_Init) owns the control lock and sequences every actor, addressing each one BY UID via
6
+ the ``*Ex`` opcode family (``WindowSyncEx`` / ``TimedTurnEx`` / ``RunAnimationEx`` ...) so it never has to
7
+ context-switch into the actor. An actor's UID is its entry slot (``sid``); the player/control character is
8
+ uid 250 (the engine remaps 250 -> the active control uid). Timing between beats is a plain ``Wait``.
9
+
10
+ This module is the INCREMENT-1 director: SEQUENTIAL beats from a flat, actor-tagged ``steps`` list --
11
+ ``say`` / ``turn`` / ``anim`` driven on a named actor by id, plus ``wait`` / ``set_flag`` at the conductor
12
+ level. Deliberately deferred (later increments, see the memory):
13
+ * animated WALK -- needs ``RunScript`` into a per-actor walk tag (base ``Walk`` only animates in the
14
+ actor's own tag-1 LOOP, state 1), so it's a bigger change than an inline ``*Ex`` op;
15
+ * PARALLEL beats ("these run together") -- FF9 forks with ``RunScriptAsync`` and joins on the script
16
+ *level* (``WaitSharedScript`` only joins the ONE shared script the SAME object spawned, not a global
17
+ barrier), which needs its own grounding pass.
18
+
19
+ Softlock care, mirroring the kit's existing cutscene rules on player-cloned actors:
20
+ * ``turn`` uses ``TurnInstantEx`` (instant, no wait) -- never ``WaitTurnEx`` (a player clone's turn anim
21
+ may not drive the wait to completion -> hang);
22
+ * ``anim`` uses ``RunAnimationEx`` + a fixed ``Wait`` hold -- never ``WaitAnimationEx`` (same hang risk).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import struct
28
+
29
+ from ..eb import EbScript, cmdasm, edit, opcodes
30
+ from . import object as _object # seat_entry (verbatim below-party-band seating)
31
+ from . import region as _region
32
+ from . import event as _event
33
+ from . import cutscene as _cutscene # shared: once_flag_for / DEFAULT_WARMUP / ANIM_HOLD / REORDER_WAIT / say
34
+
35
+ PLAYER_UID = _cutscene.PLAYER_UID # 250 -> the engine's control-character sentinel
36
+
37
+ # Spin-wait (frames) for the field/engine to grant control before locking. A field's entry transition
38
+ # (fade + scrolling-camera settle) RE-ENABLES control at a frame a fixed warmup can't predict -- in-game
39
+ # (2026-06-28) the player could walk + dismiss the first dialogue while the camera settled, then lost
40
+ # control. So: DisableMove, then SPIN until the engine RE-grants control, then DisableMove again -- the
41
+ # re-lock lands AFTER the grant and holds. Capped so a field that never re-grants can't hang.
42
+ CONTROL_POLL_CAP = 90 # frames (~3s) -- the entry settle is ~0.5-1.5s; this is a safe ceiling
43
+
44
+
45
+ def wait_for_control_then_lock(cap: int = CONTROL_POLL_CAP) -> bytes:
46
+ """Bytecode that spins until ``IsMovementEnabled`` (sysvar 2) becomes true -- the field/engine's entry
47
+ control-grant -- then ``DisableMove`` so the lock lands AFTER that grant. Used AFTER an initial
48
+ DisableMove, so it waits for the engine to RE-enable control (its entry-transition grant) and re-locks.
49
+
50
+ Unrolled (no loop counter -> no MAP-byte out-of-bounds risk): ``cap`` copies of
51
+ ``push IsMovementEnabled; JMP_IF granted; Wait(1)`` then ``granted: DisableMove``. Each check exits early
52
+ via a forward ``JMP_IF`` the moment control is granted; if it's never granted the block falls through
53
+ after ``cap`` frames and locks anyway. Assembled with :func:`cmdasm.assemble_block` (resolves the jumps)."""
54
+ lines = []
55
+ for _ in range(int(cap)):
56
+ lines.append("SET({B_SYSVAR[2] B_EXPR_END})") # push IsMovementEnabled (engine usercontrol)
57
+ lines.append("JMP_IF(granted)") # granted -> stop spinning
58
+ lines.append("op_22(1)") # else wait one frame and re-check
59
+ lines += ["granted:", "DisableMove()"] # lock NOW (after the grant) -- the lock that sticks
60
+ return cmdasm.assemble_block("\n".join(lines))
61
+
62
+
63
+ def _uid_for(name, uid_by_name):
64
+ """Resolve an actor NAME to its runtime UID: ``"player"`` -> 250 (control char); else its entry slot."""
65
+ if name == "player":
66
+ return PLAYER_UID
67
+ return uid_by_name.get(name)
68
+
69
+
70
+ def actor_say(uid: int, text_id: int, *, flags: int = 128) -> bytes:
71
+ """Step: the actor at ``uid`` speaks ``text_id`` -- ``WindowSyncEx(uid, 0, flags, txid)`` (the window
72
+ is attributed to that actor by id, so its tail points at them). Blocks until dismissed."""
73
+ return opcodes.window_sync_ex(uid, 0, flags, int(text_id))
74
+
75
+
76
+ def actor_turn(uid: int, angle: int) -> bytes:
77
+ """Step: face ``angle`` INSTANTLY (0=S, 64=W, 128=N, 192=E) -- ``TurnInstantEx(uid, angle)``. Instant
78
+ (no ``WaitTurnEx``) so it never hangs on a player-cloned actor whose turn anim doesn't complete."""
79
+ return opcodes.turn_instant_ex(uid, int(angle))
80
+
81
+
82
+ def actor_anim(uid: int, anim: int, hold: int = _cutscene.ANIM_HOLD) -> bytes:
83
+ """Step: play ``anim`` on the actor then hold ``hold`` frames -- ``RunAnimationEx(uid, anim)`` + a fixed
84
+ ``Wait`` (NOT ``WaitAnimationEx``, which hangs if the clip doesn't drive the wait to completion)."""
85
+ return opcodes.run_animation_ex(uid, int(anim)) + opcodes.wait(int(hold))
86
+
87
+
88
+ WALK_LEVEL = 2 # RunScript script-level for a walk call (matches real fields 565/909/2554)
89
+ WALK_TAG_BASE = 20 # first walk-choreography tag added to an actor's entry (clear of inject_npc's 0/1/3)
90
+
91
+
92
+ def compile_steps(steps, uid_by_name, txids, *, say_flags: int = 128, relock: bool = False,
93
+ walk_calls=None) -> bytes:
94
+ """Compile actor-tagged conductor steps to bytes. Each step is a dict with at most one action:
95
+ ``say`` / ``turn`` / ``anim`` / ``walk`` (need an ``actor = "<name>"``; ``say`` without an actor = a
96
+ narration line), or ``wait`` / ``set_flag`` (conductor-level, no actor). ``say`` steps consume ``txids``
97
+ in order. The actor name resolves to a UID via ``uid_by_name`` (or ``"player"`` -> 250).
98
+
99
+ A ``walk`` step compiles to ``RunScriptSync(2, uid, tag)`` -- the conductor can't walk an actor inline
100
+ (base ``Walk`` acts on the EXECUTING object and there is no targeted WalkEx), so the caller pre-generates
101
+ a walk-choreography tag on the actor's OWN entry (``edit.add_function``) and passes ``walk_calls`` (a
102
+ dict ``step_index -> (uid, tag)``); RunScriptSync runs it in the actor's context (so it animates) and
103
+ BLOCKS until it returns (sequential). ``walk_calls`` is required iff any step has ``walk``.
104
+
105
+ ``relock`` prefixes every beat with ``DisableMove`` -- see :func:`build_body` for why the entry control
106
+ grant needs the spin-lock + per-beat re-lock."""
107
+ walk_calls = walk_calls or {}
108
+ out, ti = [], 0
109
+ for i, s in enumerate(steps):
110
+ if relock:
111
+ out.append(opcodes.DISABLE_MOVE) # re-lock: undo any entry-transition control re-grant
112
+ name = s.get("actor")
113
+ uid = _uid_for(name, uid_by_name) if name else None
114
+ if "say" in s:
115
+ out.append(actor_say(uid, txids[ti], flags=say_flags) if uid is not None
116
+ else _cutscene.say(txids[ti], flags=say_flags))
117
+ ti += 1
118
+ elif "wait" in s:
119
+ out.append(opcodes.wait(int(s["wait"])))
120
+ elif "set_flag" in s:
121
+ sf = s["set_flag"]
122
+ out.append(_cutscene.set_flag(int(sf[0]), int(sf[1]) if len(sf) > 1 else 1))
123
+ elif "turn" in s:
124
+ if uid is None:
125
+ raise ValueError(f"conductor step {s!r}: turn needs actor = \"<name>\"")
126
+ out.append(actor_turn(uid, s["turn"]))
127
+ elif "anim" in s:
128
+ if uid is None:
129
+ raise ValueError(f"conductor step {s!r}: anim needs actor = \"<name>\"")
130
+ out.append(actor_anim(uid, s["anim"]))
131
+ elif "walk" in s:
132
+ if i not in walk_calls:
133
+ raise ValueError(f"conductor step {s!r}: walk needs a pre-generated walk tag (walk_calls)")
134
+ w_uid, w_tag = walk_calls[i]
135
+ out.append(opcodes.run_script_sync(WALK_LEVEL, w_uid, w_tag)) # run the actor's walk tag, block
136
+ else:
137
+ raise ValueError(f"unknown conductor step: {s!r}")
138
+ return b"".join(out)
139
+
140
+
141
+ def walk_tag_body(x: int, z: int, speed: int | None = None) -> bytes:
142
+ """The body of a per-actor WALK tag, run via RunScript in the actor's own context (gExec == the actor,
143
+ so base ``Walk`` moves IT and animates). Reuses the kit's actor-walk recipe (SetWalkTurnSpeed(255) +
144
+ StopAnimation + InitWalk + Walk -- no WaitTurn/WaitAnimation, which hang on a player clone) + a RETURN
145
+ so the blocking ``RunScriptSync`` caller unblocks on arrival.
146
+
147
+ A base ``Walk`` SELF-BLOCKS until arrival, so if another actor's collision box sits in the path it never
148
+ arrives => softlock (in-game 2026-06-28: a walk into a neighbor locked). The cure is faithful STAGING --
149
+ author clear paths between an actor's start and its target (real FF9 does the same; the kit can't reroute
150
+ around live actors). A ``SetPathing(0)`` collision-off wrap was tried and dropped: off the walkmesh the
151
+ Walk can fail to register arrival (a different hang) and the actor can drift off-mesh -- clean spacing is
152
+ both safer and how the real game stages cutscene walks."""
153
+ return _cutscene.actor_walk(int(x), int(z), speed) + opcodes.RETURN
154
+
155
+
156
+ def build_body(steps, uid_by_name, txids, once_flag: int | None, *, flag_class=_region.GLOB_BOOL,
157
+ warmup: int = _cutscene.DEFAULT_WARMUP, owns_control: bool = True,
158
+ exit_warp: int | None = None, say_flags: int = 128,
159
+ reorder: int = _cutscene.REORDER_WAIT, walk_calls=None) -> bytes:
160
+ """The conductor function body, run from a standalone ``InitCode``-armed code entry.
161
+
162
+ Shape: ``[Wait(reorder)] [DisableMove] [Wait(warmup)] <beats> [EnableMove]`` gated
163
+ ``if (!once_flag) { ...; once_flag = 1 }`` when ``once_flag`` is set. The leading ``reorder`` Wait lets
164
+ Main_Init reach its own ``EnableMove`` first so the conductor's ``DisableMove`` is the last control-setter
165
+ (the lock sticks); the ``warmup`` Wait (after the lock, so the player can't wander) lets the field's entry
166
+ fade settle AND lets the actor objects finish spawning before the conductor addresses them by uid.
167
+
168
+ ``exit_warp`` (a field id) ends the scene with a fade-to-black ``Field(exit_warp)`` instead of restoring
169
+ control (the warp sits OUTSIDE the once-gate so it always fires); the fade avoids the destination loading
170
+ in the clear. With ``exit_warp`` set, no ``EnableMove`` is emitted (the destination restores control).
171
+
172
+ Control lock (the load-bearing part -- two in-game iterations to get right, 2026-06-28): a fixed warmup
173
+ can't beat the field's entry control-grant, which re-enables control as the fade + scrolling-camera settle
174
+ finish (the player could walk + dismiss the first window mid-settle). So under ``owns_control`` the conductor
175
+ (1) ``DisableMove`` immediately, (2) ``wait_for_control_then_lock`` -- SPINS until the engine RE-grants
176
+ control, then ``DisableMove`` again so the lock lands AFTER the grant -- and (3) ``compile_steps(relock=True)``
177
+ re-locks before every beat as a backstop. The spin doubles as the actor-spawn settle (it runs ~until the
178
+ grant, by which point the InitObject'd actors exist for the by-id ``*Ex`` ops)."""
179
+ inner = opcodes.wait(int(reorder)) if reorder and reorder > 0 else b""
180
+ if owns_control:
181
+ inner += opcodes.DISABLE_MOVE # disable, so the spin waits for the engine's RE-grant
182
+ inner += wait_for_control_then_lock() # ... spin to that grant, then re-lock (the lock that holds)
183
+ elif warmup > 0:
184
+ inner += opcodes.wait(int(warmup)) # no lock: still settle so the actors exist before the beats
185
+ inner += compile_steps(steps, uid_by_name, txids, say_flags=say_flags, relock=owns_control,
186
+ walk_calls=walk_calls)
187
+ if owns_control and exit_warp is None:
188
+ inner += opcodes.ENABLE_MOVE
189
+ if once_flag is not None:
190
+ inner += _region.set_var(flag_class, once_flag, 1)
191
+ body = _region.if_block(_region.cond_not(flag_class, once_flag), inner)
192
+ else:
193
+ body = inner
194
+ if exit_warp is not None:
195
+ body += _event.warp(int(exit_warp), fade=True)
196
+ return body + opcodes.RETURN
197
+
198
+
199
+ def inject_conductor(data, steps, uid_by_name, txids, *, once_flag: int | None = None,
200
+ flag_class=_region.GLOB_BOOL, warmup: int = _cutscene.DEFAULT_WARMUP,
201
+ owns_control: bool = True, exit_warp: int | None = None, say_flags: int = 128,
202
+ walk_calls=None, reserve_party_band: bool = False,
203
+ spawn_wait_n: int = 2, spawn_wait_occurrence: int = 0) -> bytes:
204
+ """Seat the conductor as a single-function code entry and arm it via ``InitCode`` in Main_Init (over a
205
+ Wait filler), exactly like a narration cutscene. Returns new .eb bytes. ``walk_calls`` (a dict
206
+ ``step_index -> (uid, tag)``) maps each ``walk`` step to its pre-generated per-actor walk tag.
207
+
208
+ ``reserve_party_band``: on a VERBATIM fork the donor's last 9 slots are the playable characters, so the
209
+ conductor INSERTS just below them (``object.seat_entry``) -- keeping the band as the top slots and not
210
+ perturbing the actors it addresses (which seat below the band before it, so their uids stay valid)."""
211
+ body = build_body(steps, uid_by_name, txids, once_flag, flag_class=flag_class, warmup=warmup,
212
+ owns_control=owns_control, exit_warp=exit_warp, say_flags=say_flags,
213
+ walk_calls=walk_calls)
214
+ entry = bytes([0x00, 0x01]) + struct.pack("<HH", 0, 4) + body
215
+ out, slot = _object.seat_entry(data, entry, reserve_party_band=reserve_party_band)
216
+ return edit.activate(out, opcodes.init_code(slot, 0), spawn_wait_n=spawn_wait_n,
217
+ spawn_wait_occurrence=spawn_wait_occurrence)