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,75 @@
1
+ """Place a static set-dressing PROP -- the real FF9 prop recipe (no emulation; replicated from bytes).
2
+
3
+ A prop is the NPC object MINUS the character behaviours: it holds a single static pose and does NOT turn
4
+ to face the player. Verified byte-for-byte against shipping fields -- the save-moogle (field 300, entry 5)
5
+ and the chest (field 115, entry 9) -- whose Init does:
6
+
7
+ SetModel + CreateObject + SetStandAnimation(<pose>) + SetObjectFlags(..) + EnableHeadFocus(0)
8
+
9
+ `EnableHeadFocus(0)` (engine source: "Enable or disable the character turning his head toward an active
10
+ object") is exactly the switch that kills the turn-to-player behaviour an NPC has. So a prop is just the
11
+ proven NPC injection (:func:`content.npc.inject_npc`) with the static pose in all gesture slots plus that
12
+ tail appended to Init -- we add nothing the engine doesn't already do for its own props.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from ..eb import EbScript, opcodes
17
+ from .npc import ANIM_ORDER, inject_npc
18
+
19
+ ENABLE_HEAD_FOCUS = 0x47 # "Enable or disable the character turning his head toward an active object"
20
+ TURN_INSTANT = 0x36
21
+ ATTACH_OBJECT = 0x4C # "Attach an object to another one" -- AttachObject(attachedUid, carryingUid, bone)
22
+ SET_OBJECT_FLAGS = 0x93 # bits: 1 show model, 2 collide player, 4 collide NPC, 8 disable talk
23
+ HELD_FLAGS = 7 # show + collide + collideNPC -- the flags the shipping held cup sets
24
+ # NB: do NOT blanket-apply SetObjectFlags here. Per the engine (EventEngine.DoEventCode, CFLAG 0x93) the
25
+ # flag bits are {1: show model, 2: collide player, 4: collide NPC, 8: disable talk, ...} and it REPLACES
26
+ # the object's low 6 bits. The shipping props' SetObjectFlags(14) (= 2+4+8) omits bit 1 -> "show model"
27
+ # off -> the prop vanishes (in-game-verified: adding it hid all four props). Our prop is a cloned player
28
+ # object that is already shown + collidable, so we only need to kill head-tracking. A future
29
+ # interactivity option can add a SHOW-preserving flag (e.g. 1|8 to also disable the talk prompt).
30
+
31
+
32
+ def prop_init_tail(face: int | None = None) -> bytes:
33
+ """The bytes a prop's Init runs after CreateObject: disable head-tracking (+ an optional instant
34
+ facing). Mirrors the shipping save-moogle / chest objects minus the model-hiding SetObjectFlags."""
35
+ tail = opcodes.encode(ENABLE_HEAD_FOCUS, 0) # EnableHeadFocus(0): no turn-to-face
36
+ if face:
37
+ tail += opcodes.encode(TURN_INSTANT, int(face) & 0xFF) # TurnInstant(face)
38
+ return tail
39
+
40
+
41
+ def inject_prop(data, x: int, z: int, *, model: int, pose: int, face: int | None = None,
42
+ dialogue_text_id: int | None = None, slot: int | None = None,
43
+ attach_to: int | None = None, bone: int = 11,
44
+ spawn_wait_n: int = 2, spawn_wait_occurrence: int = 0,
45
+ gate_flag: int | None = None, gate_require_set: bool = True,
46
+ reserve_party_band: bool = False) -> bytes:
47
+ """Place a prop ``model`` at world (x, z), held at ``pose`` (an animation id), head-tracking OFF.
48
+
49
+ ``attach_to`` (a carrying object's uid = its entry slot) binds this prop to that object's ``bone``
50
+ so it follows it -- the real held-item recipe ``AttachObject(self_uid, carrier_uid, bone)`` (bone
51
+ defaults to 11, the right hand the shipping cup uses). The prop's own uid IS its entry slot, so the
52
+ slot is resolved up front. ``dialogue_text_id`` makes it readable; ``face``/``gate_flag`` as usual.
53
+ ``reserve_party_band`` (the VERBATIM-fork path) seats the prop BELOW the party-character band (only for
54
+ a STATIC prop; an ``attach_to`` held item resolves its slot up front and is unsupported there).
55
+ Returns new ``.eb`` bytes."""
56
+ anims = {k: pose for k in ANIM_ORDER} # all five gesture slots = the (held) pose
57
+ if attach_to is not None: # ATTACHED: bind to the carrier's bone
58
+ if reserve_party_band:
59
+ raise ValueError("inject_prop: attach_to (held item) is not supported with reserve_party_band "
60
+ "(its uid is its slot, resolved before the band-aware insert)")
61
+ if slot is None:
62
+ slot = EbScript.from_bytes(data).first_free_slot() # the prop's uid == its slot (= attachedUid)
63
+ tail = opcodes.encode(ATTACH_OBJECT, slot, int(attach_to), int(bone))
64
+ tail += opcodes.encode(SET_OBJECT_FLAGS, HELD_FLAGS) # show + collide (like the shipping cup)
65
+ else: # STATIC: just kill head-tracking
66
+ tail = prop_init_tail(face)
67
+ # a non-interactive prop is BARE (Init-only, no tag-3 talk func -> the engine's IsActuallyTalkable
68
+ # short-circuits instead of indexing past it = no per-frame IndexOutOfRange). A prop with dialogue
69
+ # keeps a real tag-3 WindowSync so it stays readable.
70
+ return inject_npc(data, x, z, model=model, anims=anims,
71
+ talk_text_id=(dialogue_text_id if dialogue_text_id is not None else 62),
72
+ init_tail=tail, slot=slot, bare=(dialogue_text_id is None),
73
+ spawn_wait_n=spawn_wait_n, spawn_wait_occurrence=spawn_wait_occurrence,
74
+ gate_flag=gate_flag, gate_require_set=gate_require_set,
75
+ reserve_party_band=reserve_party_band)
@@ -0,0 +1,340 @@
1
+ """General conditional region triggers + the field-script flag/expression primitives.
2
+
3
+ This is the kit's first *authored-logic* injector. Every other content module stamps a canned
4
+ block (an exit, an NPC, an encounter); this one builds a region whose ``_Range`` trigger runs a
5
+ flag-gated body you compose -- the reusable primitive behind multi-camera switch zones (see
6
+ :mod:`ff9mapkit.content.camera`) and, by the same shape, chests / story flags / one-shot events
7
+ (``if (!done) { give...; set done = 1 }``).
8
+
9
+ Everything here is grounded BYTE-FOR-BYTE in real FF9 field bytecode -- decoded from the camera
10
+ switch regions of Gargan Roo/Passage (``evt_gargan_gr_lef_0``) + the field-109 exit region. The
11
+ field event "expression" sub-language (opcode ``0x05``) is a little RPN stack terminated by ``0x7F``:
12
+
13
+ push a variable : <class> <idx> class 0xD5 = GlobUInt8, 0xC5 = GlobBool
14
+ push a constant : 0x7D <i16>
15
+ operators : 0x0E = logical NOT, 0x20 = '==', 0x2C = '=' (assign)
16
+ end : 0x7F
17
+
18
+ So ``set V = k`` -> ``05 <cls> <idx> 7D <k:i16> 2C 7F``
19
+ ``if (V)`` -> ``05 <cls> <idx> 7F`` (truthy)
20
+ ``if (!V)`` -> ``05 <cls> <idx> 0E 7F``
21
+ ``if (V == k)``-> ``05 <cls> <idx> 7D <k:i16> 20 7F``
22
+
23
+ Control flow uses two relative jumps whose operand is the byte length of the block they skip:
24
+ ``0x02`` = jump-if-FALSE (the ``if`` skip), ``0x03`` = jump-if-TRUE (used by ``ifnot``). A region
25
+ entry is engine type ``1`` with an Init func (tag 0 = ``SetRegion`` polygon) and a Range func
26
+ (tag 2 = the trigger body, which only runs while ``usercontrol == 1``).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import struct
32
+
33
+ from ..eb import EbScript, edit, opcodes
34
+
35
+ # --- expression var classes (the engine's variable scopes) ---
36
+ # A var token byte = 0xC0 | (VariableType << 2) | VariableSource (EBin.getVarOperation). CRITICAL: the
37
+ # SOURCE decides PERSISTENCE -- Global (src 0) reads/writes the SAVE-BACKED gEventGlobal (survives
38
+ # field reloads); Map (src 1) is a PER-FIELD array WIPED on every field load. (HW's naming is
39
+ # inverted: HW "GenBool" = engine Global = persistent; HW "GlobBool" = engine Map = transient.)
40
+ GLOB_BOOL = 0xC4 # Global + Bit -> SAVE-PERSISTENT bool (story flags, chest "once", etc.)
41
+ MAP_BOOL = 0xC5 # Map + Bit -> transient per-field bool (resets on reload; rarely what you want)
42
+ GLOB_UINT8 = 0xD5 # Map + Byte -> transient byte (the camera-switch flag; reset per load by design)
43
+ GLOB_BYTE = 0xD4 # Global + Byte -> SAVE-BACKED single byte: writes ONLY gEventGlobal[idx] (one byte),
44
+ # unlike GLOB_UINT16 which spans [idx, idx+1] and zeroes the neighbour. The engine
45
+ # reads e.g. the Pandemonium lift-config bytes 361/362 this way (token 0xF4 =
46
+ # 0xD4 | the long-index bit). Use this to seed ONE byte without clobbering the next.
47
+ GLOB_UINT16 = 0xDC # Global + UInt16 -> save-backed 16-bit word. Read via the EXPRESSION path it is
48
+ # UNSIGNED (0..65535, no sign-extension -- EBin.GetVariableValueInternal), so it
49
+ # holds a choice availability mask without the 0xFFFF->-1 sign trap of a literal.
50
+ MAP_INT16 = 0xD9 # Map + Int16 -> transient SIGNED 16-bit (wiped per field load). The navigable
51
+ # ladder's per-frame climb-target scratch (field 706 uses MAP.I16[2]); re-derived
52
+ # from the player's height every frame so its transient value never matters.
53
+ GLOB_INT16 = 0xD8 # Global + Int16. Idx 2 (D8:2) is the engine's ARRIVAL-ENTRANCE var: set it right
54
+ # before Field()/WorldMap() and the destination field's player-init switches on it.
55
+ VAR_CLASSES = {"glob_bool": GLOB_BOOL, "map_bool": MAP_BOOL, "glob_uint8": GLOB_UINT8, "glob_byte": GLOB_BYTE}
56
+
57
+ # A scratch word high in gEventGlobal (byte offset; vars index BYTES, bits index BITS -- so byte 2040
58
+ # is bits 16320+, clear of base-game vars [low offsets] AND the kit's 8000+ bit-flags [bytes ~1000]).
59
+ # Rebuilt every time a choice opens (set_var -> or_var), so its transient value never matters across
60
+ # opens; F10's gEventGlobal reset is harmless to it.
61
+ MASK_SCRATCH_IDX = 2040
62
+
63
+ # --- expression opcodes / tokens ---
64
+ EXPR_OP = 0x05 # expression statement (its single operand is a token stream)
65
+ T_CONST = 0x7D # 0x7D <i16>
66
+ T_NOT = 0x0E
67
+ T_EQ = 0x20
68
+ T_ASSIGN = 0x2C # B_LET ('=')
69
+ T_OR_ASSIGN = 0x3F # B_OR_LET ('|='); real-field verified (Dali/Storage 407: `VAR |= 2` = 05 .. 3F 7F)
70
+ T_LT = 0x18 # B_LT ('<')
71
+ T_ITEMCOUNT = 0x64 # GetItemCount: unary fn token -- pops an item-id const, pushes the held count
72
+ # (real-field verified, Dali/Storage 407 chest guard `GetItemCount(236) < 99`)
73
+ T_SYSVAR = 0x7A # push GetSysvar(<code>) -- EBin.B_SYSVAR (122); reads the next byte as the code
74
+ T_END = 0x7F
75
+
76
+ # Arithmetic / comparison / input operator tokens -- the engine's binary-op opcodes, verified
77
+ # byte-for-byte against field 706's navigable vine climb (see content.ladder.navigable_climb_body).
78
+ T_MULT = 0x11 # B_MULT '*'
79
+ T_DIV = 0x12 # B_DIV '/'
80
+ T_MOD = 0x13 # B_MOD '%' (the ladder anim window: (animFrame+1) % frames)
81
+ T_PLUS = 0x14 # B_PLUS '+'
82
+ T_MINUS = 0x15 # B_MINUS '-'
83
+ T_GT = 0x19 # B_GT '>'
84
+ T_LE = 0x1A # B_LE '<='
85
+ T_GE = 0x1B # B_GE '>='
86
+ T_ANDAND = 0x27 # B_ANDAND '&&'
87
+ T_OROR = 0x28 # B_OROR '||'
88
+ T_KEY = 0x59 # B_KEY: pop a button-mask const, push (mask & held-inputs ? 1 : 0) -- HELD input
89
+ T_KEYON = 0x4F # B_KEYON: pop a button-mask const, push (mask & pressed-THIS-FRAME ? 1 : 0) -- the
90
+ # press EDGE. The ATE "press SELECT" trigger (field 552 [11667]: `1 B_KEYON` with
91
+ # SELECT=EventInput.Select=1u). Edge (not held) so one tap = one open.
92
+ T_OBJVAR = 0x78 # B_OBJSPECA: read an object var -> 78 <uid> <field> (uid 255 = self)
93
+
94
+ # Field-event input button masks (EventInput.cs): the const a B_KEY/B_KEYON token tests against.
95
+ KEY_SELECT = 1 # EventInput.Select (1u) -- the ATE menu trigger
96
+ KEY_START = 8 # EventInput.Start (8u)
97
+
98
+ # A couple of useful system-variable codes (EventEngine.GetSysvar switch): 2 = usercontrol
99
+ # (IsMovementEnabled), 9 = ETb.GetChoose() = the index the player picked in the last choice window.
100
+ SYSVAR_USERCONTROL = 2
101
+ SYSVAR_CHOICE = 9
102
+ JMP_FALSE = 0x02 # jump-if-false 02 <skip:i16>
103
+ JMP_TRUE = 0x03 # jump-if-true 03 <skip:i16>
104
+ SETREGION_OP = 0x29
105
+ REGION_ENTRY_TYPE = 1
106
+ RANGE_TAG = 2 # the player-in-region (tread) trigger func -- runs EVERY frame in the quad
107
+ INTERACT_TAG = 3 # the press-action-while-in-quad func -- fires on the action button (a lever/sign)
108
+
109
+
110
+ def _cls(var_class) -> int:
111
+ if isinstance(var_class, str):
112
+ return VAR_CLASSES[var_class]
113
+ return int(var_class) & 0xFF
114
+
115
+
116
+ def _i16(v: int) -> bytes:
117
+ return struct.pack("<h", int(v))
118
+
119
+
120
+ def _push_var(var_class, idx: int) -> bytes:
121
+ """Encode a variable reference token. Index <= 0xFF -> ``<cls> <idx>``; a larger index sets the
122
+ long-index bit (0x20) on the class byte and uses a 2-byte little-endian index, exactly as the
123
+ engine encodes it (EBin.getVarOperation: ``index << 8``, ``| 0x20`` when index > 0xFF). This lets
124
+ flags live high in gEventGlobal (clear of base-game flags) and still decode correctly."""
125
+ c = _cls(var_class)
126
+ if 0 <= idx <= 0xFF:
127
+ return bytes([c, idx])
128
+ return bytes([c | 0x20]) + struct.pack("<H", idx & 0xFFFF)
129
+
130
+
131
+ # --- expression statements (opcode 0x05 + token stream) ---
132
+ def set_var(var_class, idx: int, value: int) -> bytes:
133
+ """``set VAR = value`` -> ``05 <var> 7D <value:i16> 2C 7F``."""
134
+ return bytes([EXPR_OP]) + _push_var(var_class, idx) + bytes([T_CONST]) + _i16(value) + bytes([T_ASSIGN, T_END])
135
+
136
+
137
+ FIELD_ENTRANCE_IDX = 2 # D8:2 = the arrival-entrance var the next Field()/WorldMap() arrives through
138
+
139
+
140
+ def set_field_entrance(ent: int) -> bytes:
141
+ """``D8:2 = ent`` -- set the entrance the next ``Field()``/``WorldMap()`` arrives through (the
142
+ destination field's player-init switches on it to place the player). ``05 D8 02 7D <ent:i16> 2C 7F``
143
+ (verified vs field 70's warp + field 380/404/706 ladder tops)."""
144
+ return set_var(GLOB_INT16, FIELD_ENTRANCE_IDX, ent)
145
+
146
+
147
+ def or_var(var_class, idx: int, value: int) -> bytes:
148
+ """``VAR |= value`` -> ``05 <var> 7D <value:i16> 3F 7F`` (B_OR_LET). Used to OR a bit into a mask
149
+ scratch (real-field verified: Dali/Storage builds its moogle-mail availability mask this way)."""
150
+ return bytes([EXPR_OP]) + _push_var(var_class, idx) + bytes([T_CONST]) + _i16(value) + bytes([T_OR_ASSIGN, T_END])
151
+
152
+
153
+ def var_expr(var_class, idx: int) -> bytes:
154
+ """A BARE variable read for use as an opcode EXPRESSION-arg (no leading ``0x05`` statement byte):
155
+ ``<var-token> 7F``. Pass with ``arg_flags`` bit set so the engine evaluates it (``getv`` -> CalcExpr).
156
+ Real-field verified (Dali/Storage 407: a CHOOSEPARAM arg is ``d6 09 7f`` = bare var + END)."""
157
+ return _push_var(var_class, idx) + bytes([T_END])
158
+
159
+
160
+ def cond_truthy(var_class, idx: int) -> bytes:
161
+ """``if (VAR)`` condition expr -> ``05 <var> 7F``."""
162
+ return bytes([EXPR_OP]) + _push_var(var_class, idx) + bytes([T_END])
163
+
164
+
165
+ def cond_not(var_class, idx: int) -> bytes:
166
+ """``if (!VAR)`` condition expr -> ``05 <var> 0E 7F``."""
167
+ return bytes([EXPR_OP]) + _push_var(var_class, idx) + bytes([T_NOT, T_END])
168
+
169
+
170
+ def cond_eq(var_class, idx: int, value: int) -> bytes:
171
+ """``if (VAR == value)`` condition expr -> ``05 <var> 7D <value:i16> 20 7F``."""
172
+ return bytes([EXPR_OP]) + _push_var(var_class, idx) + bytes([T_CONST]) + _i16(value) + bytes([T_EQ, T_END])
173
+
174
+
175
+ def cond_item_count_lt(item_id: int, limit: int = 99) -> bytes:
176
+ """``if (GetItemCount(item) < limit)`` condition expr -> ``05 7D <item:i16> 64 7D <limit:i16> 18 7F``.
177
+ The FF9 treasure-chest space guard: don't open/give if the player can't carry it (default cap 99).
178
+ Real-field verified (Dali/Storage 407: ``05 7d ec 00 64 7d 63 00 18 7f`` = ``GetItemCount(236) < 99``)."""
179
+ return (bytes([EXPR_OP, T_CONST]) + _i16(item_id) + bytes([T_ITEMCOUNT, T_CONST])
180
+ + _i16(limit) + bytes([T_LT, T_END]))
181
+
182
+
183
+ def push_sysvar(code: int) -> bytes:
184
+ """A system-variable read token: ``7A <code>`` -> push ``GetSysvar(code)`` (EBin.B_SYSVAR). The
185
+ movement gate is exactly this for code 2 (``05 7A 02 7F`` = IsMovementEnabled), so it's proven."""
186
+ return bytes([T_SYSVAR, code & 0xFF])
187
+
188
+
189
+ def obj_var(uid: int, field: int) -> bytes:
190
+ """An object-variable read token: ``78 <uid> <field>`` (uid 255 = self / the current object).
191
+ getvobj field codes: 0=X, 1=world-Y-up (=-pos.y), 2=Z, 3=angle, 4=flags, 5=uid, 6=level,
192
+ 7=animFrame. Verified vs field 706's vine climb (``78 FF 01`` = self world-Y, ``78 FF 07`` =
193
+ self animFrame)."""
194
+ return bytes([T_OBJVAR, uid & 0xFF, field & 0xFF])
195
+
196
+
197
+ def cond_sysvar_eq(code: int, value: int) -> bytes:
198
+ """``if (GetSysvar(code) == value)`` condition expr -> ``05 7A <code> 7D <value:i16> 20 7F``.
199
+
200
+ With ``code`` = :data:`SYSVAR_CHOICE` (9) this is the dialogue-choice test: branch on which row the
201
+ player picked in the preceding choice window (``ETb.GetChoose()``)."""
202
+ return bytes([EXPR_OP]) + push_sysvar(code) + bytes([T_CONST]) + _i16(value) + bytes([T_EQ, T_END])
203
+
204
+
205
+ def cond_ate_select(avail_class, avail_idx: int, select_mask: int = KEY_SELECT) -> bytes:
206
+ """The real ATE menu-open gate, byte-for-byte as field 552 [11667]:
207
+ ``if ( usercontrol==1 AND <avail>==1 AND B_KEYON(SELECT) )``.
208
+
209
+ RPN: ``usercontrol==1 , <avail>==1 , && , (SELECT B_KEYON) , &&``. Returns the full condition expr
210
+ (``EXPR_OP ... T_END``) for :func:`if_block`. ``<avail>`` is the author's own availability flag (set
211
+ in Main_Init alongside ``ATE(mode)``), so the menu opens ONLY while the ATE is offered and the player
212
+ taps SELECT this frame. Decoded from the Lindblum Main-St hub (Small-Town Knight ATE)."""
213
+ toks = (push_sysvar(SYSVAR_USERCONTROL) + bytes([T_CONST]) + _i16(1) + bytes([T_EQ])
214
+ + _push_var(avail_class, avail_idx) + bytes([T_CONST]) + _i16(1) + bytes([T_EQ])
215
+ + bytes([T_ANDAND])
216
+ + bytes([T_CONST]) + _i16(select_mask) + bytes([T_KEYON])
217
+ + bytes([T_ANDAND]))
218
+ return bytes([EXPR_OP]) + toks + bytes([T_END])
219
+
220
+
221
+ # --- control flow ---
222
+ # 'ifnot (IsMovementEnabled) { return }' -- the verbatim region-trigger prologue (gates the body on
223
+ # usercontrol, exactly like every real exit/switch region). 7a 02 = IsMovementEnabled builtin.
224
+ MOVEMENT_GATE = bytes([EXPR_OP, 0x7A, 0x02, T_END, JMP_TRUE, 0x01, 0x00, opcodes.RETURN[0]])
225
+
226
+
227
+ JMP_UNCOND = 0x01 # the undocumented UNCONDITIONAL relative jump (operand = signed i16 skip), CLAUDE.md s7.
228
+ # The engine is uniformly IP-relative, so 0x01 <i16> skips <i16> bytes unconditionally --
229
+ # the hop the two-arm if/else uses to jump over the else-body after the then-body.
230
+
231
+
232
+ def jump(skip: int) -> bytes:
233
+ """An unconditional relative jump over ``skip`` bytes -> ``01 <skip:i16>``."""
234
+ return bytes([JMP_UNCOND]) + _i16(skip)
235
+
236
+
237
+ def if_block(cond: bytes, body: bytes) -> bytes:
238
+ """``if (cond) { body }`` -> cond + ``02 <len(body):i16>`` (jump-if-false past body) + body."""
239
+ return cond + bytes([JMP_FALSE]) + _i16(len(body)) + body
240
+
241
+
242
+ def if_else(cond: bytes, then_body: bytes, else_body: bytes) -> bytes:
243
+ """``if (cond) { then_body } else { else_body }`` -- the explicit TWO-ARM branch.
244
+
245
+ Emits ``cond + 02 <len(then)+3> + then_body + 01 <len(else)> + else_body``: the JMP_FALSE skips the
246
+ then-body AND the 3-byte unconditional hop (landing on else); when cond is true the then-body runs and
247
+ the hop jumps over else. Byte-grounded on the real treasure-chest Init pose branch (fields 200/407:
248
+ ``05{flag} 02 <7> SetStandAnimation(open) 01 <4> SetStandAnimation(closed)`` -- the savable open/closed
249
+ pose). The kit had only single-jump ``if_block``/``if_not_block``; this is the missing else arm."""
250
+ hop = jump(len(else_body))
251
+ return cond + bytes([JMP_FALSE]) + _i16(len(then_body) + len(hop)) + then_body + hop + else_body
252
+
253
+
254
+ def if_not_block(cond: bytes, body: bytes) -> bytes:
255
+ """``if (!cond) { body }`` -> cond + ``03 <len(body):i16>`` (jump-if-TRUE past body) + body."""
256
+ return cond + bytes([JMP_TRUE]) + _i16(len(body)) + body
257
+
258
+
259
+ def flag_gate(var_class, idx: int, *, require_set: bool = True) -> bytes:
260
+ """A story-flag PROLOGUE: ``ifnot (flag matches) { return }``. Prepend it to a function so the
261
+ function only proceeds when the flag is in the required state (the way real FF9 gates NPCs /
262
+ triggers by scenario). ``require_set`` True -> proceed only when the flag is SET; False -> only
263
+ when CLEAR. Same shape as :data:`MOVEMENT_GATE` (push flag, conditional jump over an early
264
+ ``return``)."""
265
+ cond = cond_truthy(var_class, idx) # pushes the flag's truth
266
+ jmp = JMP_TRUE if require_set else JMP_FALSE # skip the 'return' when the flag is in-state
267
+ return cond + bytes([jmp]) + _i16(1) + opcodes.RETURN
268
+
269
+
270
+ # --- region entry assembly ---
271
+ def set_region(points) -> bytes:
272
+ """``SetRegion`` polygon op: ``29 00 <count> <(x,z) i16 pairs>``. 4 convex corners is the
273
+ real-field norm (the engine's IsInQuad fans consecutive triplets; a convex quad is safe)."""
274
+ pts = [tuple(p) for p in points]
275
+ if len(pts) < 3:
276
+ raise ValueError("a region needs at least 3 points")
277
+ out = bytes([SETREGION_OP, 0x00, len(pts) & 0xFF])
278
+ for x, z in pts:
279
+ out += _i16(x) + _i16(z)
280
+ return out
281
+
282
+
283
+ def gated_set_region(zone, var_class, idx: int) -> bytes:
284
+ """An Init body that defines the region quad ONLY while flag ``idx`` is CLEAR (else nothing) + a
285
+ return. So a spent one-shot trigger sets up no quad on a later visit -> no leftover interaction
286
+ prompt / tread. ``if (flag) skip SetRegion`` == :func:`if_not_block` over :func:`cond_truthy`."""
287
+ return if_not_block(cond_truthy(var_class, idx), set_region(zone)) + opcodes.RETURN
288
+
289
+
290
+ def build_region_entry(zone, range_body: bytes, *, init_extra: bytes = b"", tag: int = RANGE_TAG,
291
+ init_body: bytes | None = None) -> bytes:
292
+ """Assemble a type-1 region entry: Init (tag 0 = SetRegion(zone) + ``init_extra``; return) + a
293
+ trigger func at ``tag`` (default :data:`RANGE_TAG` 2 = tread, every frame in the quad;
294
+ :data:`INTERACT_TAG` 3 = press-action-in-quad, a lever/sign). ``init_extra`` runs once on field
295
+ load (when InitRegion arms the region) -- e.g. a ``set flag = 0`` to re-arm a once-per-visit
296
+ tread trigger each visit. ``init_body`` overrides the Init body entirely (e.g.
297
+ :func:`gated_set_region` for a one-shot trigger that vanishes once spent)."""
298
+ ib = init_body if init_body is not None else (set_region(zone) + init_extra + opcodes.RETURN)
299
+ funcs = [(0, ib), (tag, range_body)]
300
+ table_len = len(funcs) * 4
301
+ table = bytearray()
302
+ pos = table_len
303
+ for tag, body in funcs:
304
+ table += struct.pack("<HH", tag, pos)
305
+ pos += len(body)
306
+ return bytes([REGION_ENTRY_TYPE, len(funcs)]) + bytes(table) + b"".join(b for _, b in funcs)
307
+
308
+
309
+ def prepend_range_gate(data, slot: int, gate_bytes: bytes) -> bytes:
310
+ """Insert ``gate_bytes`` at the start of the region in ``slot``'s Range (tag 2) function, so the
311
+ trigger only runs when the gate passes. Safe via :func:`edit.insert_bytes`: Range is the entry's
312
+ LAST function, so the gate just becomes its first bytes and no func-table ``fpos`` needs fixing."""
313
+ eb = EbScript.from_bytes(data)
314
+ rng = eb.entry(slot).func_by_tag(RANGE_TAG)
315
+ if rng is None:
316
+ raise ValueError(f"entry {slot} has no Range (tag {RANGE_TAG}) to gate")
317
+ if rng.abs_end != eb.entry(slot).abs_end:
318
+ raise ValueError(f"Range is not the last function of entry {slot}; cannot prepend safely")
319
+ return edit.insert_bytes(data, rng.abs_start, gate_bytes)
320
+
321
+
322
+ def inject_region(data, zone, range_body: bytes, *, slot: int | None = None, activate: bool = True,
323
+ spawn_wait_n: int = 2, spawn_wait_occurrence: int = 0, init_extra: bytes = b"",
324
+ tag: int = RANGE_TAG, init_body: bytes | None = None, reserve_party_band: bool = False):
325
+ """Append a conditional region (Init=SetRegion(zone) + ``init_extra``, Range=range_body) into a
326
+ free slot.
327
+
328
+ Returns ``(new_bytes, slot)``. If ``activate`` (default), the region is turned on at field load
329
+ by overwriting a Main_Init ``Wait(n)`` filler with ``InitRegion(slot, 0)`` -- shift-free. Pass
330
+ ``activate=False`` for a zone that another zone enables at runtime (the switch-pair toggle).
331
+ ``init_extra`` runs in the region's Init on each load (e.g. a flag reset for once-per-visit).
332
+ ``reserve_party_band`` (the VERBATIM-fork path): seat the region BELOW the engine's reserved
333
+ party-character band instead of into a free slot (else it lands in an unused character slot)."""
334
+ from . import object as _object # local: object imports region -> avoid the top-level cycle
335
+ entry = build_region_entry(zone, range_body, init_extra=init_extra, tag=tag, init_body=init_body)
336
+ out, slot = _object.seat_entry(data, entry, reserve_party_band=reserve_party_band, slot=slot)
337
+ if activate:
338
+ out = edit.activate(out, opcodes.init_region(slot, 0), spawn_wait_n=spawn_wait_n,
339
+ spawn_wait_occurrence=spawn_wait_occurrence)
340
+ return out, slot
@@ -0,0 +1,59 @@
1
+ """Add the after-battle handler (entry-0 tag-10 "Main_Reinit") a custom field needs.
2
+
3
+ After a random battle, EventEngine restores the field then calls Request(entry0, 0, 10).
4
+ ``EnterBattleEnd`` has suspended every object; only when the tag-10 handler RETURNS at level
5
+ 0 does ``ExitBattleEnd`` un-suspend them. Battle fields ship a Main_Reinit; fields cloned
6
+ from a cutscene field (like our blank) have none, so the player stays frozen after battle.
7
+
8
+ Minimal handler: ``EnableMove ; return``. With ``with_fade=True`` it is prefixed with a quick
9
+ ``FadeFilter`` fade-in, because the battle-return fade is a 256-frame *timed* fade that only a
10
+ field-issued FadeFilter overrides (Main_Init issues one, but after battle the field runs
11
+ tag-10, not Main_Init).
12
+
13
+ Re-layout: entry-0's function table grows by one 4-byte slot (existing funcs' fpos += 4); the
14
+ new function body is appended after entry-0's code; every later entry shifts in the file so
15
+ its entry-table offset += growth. entryCount is unchanged.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import struct
21
+
22
+ from ..binutils import set_u16, u16
23
+ from ..eb import EbScript, opcodes
24
+
25
+ REINIT_TAG = 10
26
+
27
+
28
+ def add_reinit(eb_bytes, *, with_fade: bool = True, fade_frames: int = 16,
29
+ tag: int = REINIT_TAG) -> bytes:
30
+ """Add an entry-0 tag-10 handler (EnableMove; return), optionally with a fast fade-in."""
31
+ body = b""
32
+ if with_fade:
33
+ body += opcodes.fade_filter(2, fade_frames, 0, 0, 0, 0) # SUB => fade-IN over N frames
34
+ body += opcodes.ENABLE_MOVE + opcodes.RETURN
35
+
36
+ b = bytearray(eb_bytes)
37
+ entry_count = b[3]
38
+ off0, sz0 = u16(b, 128), u16(b, 130)
39
+ es = 128 + off0
40
+ etype, fc = b[es], b[es + 1]
41
+ fbase = es + 2
42
+ funcs = [[u16(b, fbase + i * 4), u16(b, fbase + i * 4 + 2)] for i in range(fc)]
43
+ if any(t == tag for t, _ in funcs):
44
+ raise ValueError(f"entry 0 already has a function with tag {tag}")
45
+ code = bytes(b[fbase + fc * 4: es + sz0])
46
+ new_funcs = [[t, fp + 4] for t, fp in funcs] + [[tag, (fc + 1) * 4 + len(code)]]
47
+ new_entry = bytearray([etype, fc + 1])
48
+ for t, fp in new_funcs:
49
+ new_entry += struct.pack("<HH", t, fp)
50
+ new_entry += code + body
51
+ growth = len(new_entry) - sz0
52
+
53
+ out = bytearray(bytes(b[:es]) + bytes(new_entry) + bytes(b[es + sz0:]))
54
+ set_u16(out, 130, len(new_entry)) # entry-0 size
55
+ for i in range(1, entry_count): # relocate later entries
56
+ slot = 128 + i * 8
57
+ if u16(out, slot + 2) > 0 and u16(out, slot) > off0:
58
+ set_u16(out, slot, u16(out, slot) + growth)
59
+ return bytes(out)
@@ -0,0 +1,90 @@
1
+ """Save-point synthesis -- a functional FF9 save point as a press-to-interact region.
2
+
3
+ The FUNCTIONAL save is a single opcode: ``Menu(4, 0)`` (0x75) -> ``EventService.StartMenu`` ->
4
+ ``OpenSaveMenu`` (``SaveLoadUI.SerializeType.Save``). Verified byte-exact (``75 00 04 00``) against the
5
+ real Dali save moogle (field 122 entry 5 tag 3). The real moogle's full act -- jump out of the barrel,
6
+ the Save/Shop dialogue choice, the player-pose ``RunScriptAsync`` surgery -- is COSMETIC; none of it is
7
+ needed to save the game. So instead of grafting that un-graftable 7-entry-ish cluster, the kit SYNTHESIZES
8
+ the save: a press-action region whose interact func opens the save menu.
9
+
10
+ It is the navigable cousin of :mod:`content.jump`'s ``action`` region -- same Init ``SetRegion`` / tread
11
+ ``Bubble`` ("!") / action shape -- but the action dispatch is ``DisableMove; Menu(4, 0); EnableMove``
12
+ instead of a player-arc ``RunScriptSync`` (so, unlike a jump, NO player-function graft is required; the
13
+ save is a self-contained engine call). The optional visible barrel/moogle set-dressing + the cosmetic
14
+ jump-out are a separate, later layer (place a ``[[prop]]``/``[[npc]]`` over the zone); this is the
15
+ functional core.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import struct
20
+
21
+ from ..eb import EbScript, edit, opcodes
22
+ from . import region as _region
23
+
24
+ SAVE_MENU_ID = 4 # EventService.FF9Menu_Command case 4u -> OpenSaveMenu
25
+ SAVE_SUB_ID = 0 # OpenSaveMenu requires sub_id == 0
26
+
27
+
28
+ def save_dispatch() -> bytes:
29
+ """The interact body: ``DisableMove; Menu(4, 0); EnableMove; RETURN``. Locks control while the save
30
+ UI is up (so the player can't walk under it) and restores it after -- mirrors the jump action's
31
+ ``DisableMove ... EnableMove`` bracket, with the save menu in place of the arc."""
32
+ return (opcodes.DISABLE_MOVE
33
+ + opcodes.menu(SAVE_MENU_ID, SAVE_SUB_ID)
34
+ + opcodes.ENABLE_MOVE + opcodes.RETURN)
35
+
36
+
37
+ def _assemble_entry(funcs) -> bytes:
38
+ """Assemble a type-1 (region) entry from ``[(tag, body), ...]`` -- the func table (4 bytes/func:
39
+ ``<tag:u16><fpos:u16>``) then the concatenated bodies. Same layout as :func:`content.jump`."""
40
+ table = b""
41
+ pos = len(funcs) * 4
42
+ for tag, body in funcs:
43
+ table += struct.pack("<HH", tag, pos)
44
+ pos += len(body)
45
+ return bytes([_region.REGION_ENTRY_TYPE, len(funcs)]) + table + b"".join(b for _, b in funcs)
46
+
47
+
48
+ def savepoint_region(zone, *, bubble: bool = True) -> bytes:
49
+ """A type-1 region entry for a save point: Init ``SetRegion(zone)`` / tread (tag 2) ``Bubble(1)`` (the
50
+ floating "!" prompt, if ``bubble``) / action (tag 3) :func:`save_dispatch`. Both trigger funcs are
51
+ gated by :data:`content.region.MOVEMENT_GATE` (fire only while ``usercontrol == 1``), exactly like
52
+ every real exit/switch/jump region."""
53
+ init = _region.set_region([tuple(p) for p in zone]) + opcodes.RETURN
54
+ tread = _region.MOVEMENT_GATE + (opcodes.bubble(1) if bubble else b"") + opcodes.RETURN
55
+ action = _region.MOVEMENT_GATE + save_dispatch()
56
+ funcs = [(0, init), (_region.RANGE_TAG, tread), (_region.INTERACT_TAG, action)]
57
+ return _assemble_entry(funcs)
58
+
59
+
60
+ def inject_savepoint(data, zone, *, bubble: bool = True, activate: bool = True):
61
+ """Inject one save point: append a save-point region at the next free slot and arm it (``InitRegion``
62
+ in Main_Init). Returns ``(new_bytes, region_slot)``. ``zone`` is a 4- or 5-point quad (the press
63
+ area); ``bubble=False`` hides the "!" prompt (e.g. when a visible model already signals the save)."""
64
+ eb = EbScript.from_bytes(data)
65
+ slot = eb.first_free_slot()
66
+ data = edit.append_entry(data, slot, savepoint_region(zone, bubble=bubble))
67
+ if activate:
68
+ data = edit.activate(data, opcodes.init_region(slot, 0))
69
+ return data, slot
70
+
71
+
72
+ def inject_savepoints(data, savepoints, *, activate: bool = True):
73
+ """Inject every ``[[savepoint]]`` (each a dict with ``zone`` + optional ``bubble``). Returns
74
+ ``(new_bytes, [slot, ...])``."""
75
+ slots = []
76
+ for sp in savepoints:
77
+ data, slot = inject_savepoint(data, sp["zone"], bubble=sp.get("bubble", True), activate=activate)
78
+ slots.append(slot)
79
+ return data, slots
80
+
81
+
82
+ def graft_director(data, director_body):
83
+ """Graft the save-sequence DIRECTOR (the donor field's entry-0 tag-1, from
84
+ :func:`eventscan.extract_savepoint_director`) into the fork's EMPTY entry-0 tag-1, so it puppeteers the
85
+ carried save Moogle. The director references no entries -- it drives the Moogle through shared transient
86
+ MAP vars only -- so it grafts VERBATIM (replace the empty system-loop body with it). The carried Moogle +
87
+ carried cask + this director then reconstitute the source field's exact state machine over those shared
88
+ vars: the Moogle lowers into the barrel, pops out on a cask push, and runs the save flourish. The fork's
89
+ entry-0 tag-1 is empty in a blank field, so this is a clean swap (docs/SAVEPOINT.md)."""
90
+ return edit.replace_function_body(data, 0, 1, bytes(director_body))