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,633 @@
1
+ """Ladder primitive -- a region the player climbs, replicating FF9's REAL ladder mechanism.
2
+
3
+ Decoded byte-for-byte from Treno/Residence (the real game; entry 15 = the ladder region, entry 19 =
4
+ the player):
5
+
6
+ - tread (tag 2): ``ifnot(usercontrol) return ; Bubble(1)`` -> the floating "!" prompt
7
+ - action (tag 3): ``ifnot(usercontrol) return ; DisableMove ;
8
+ RunScriptSync(2, 250, <climb_tag>) ; EnableMove`` -> run the PLAYER's climb
9
+ - the player's climb function (``climb_tag``): runs in the player's OWN context (UID 250), so its
10
+ moves move the PLAYER; ``RunScriptSync`` waits for it.
11
+
12
+ Why this shape (the hard-won truth): the controlled player's script loop is NOT stepped while
13
+ ``usercontrol == 1``, so a region -> flag -> player-loop scheme can't drive a climb during free
14
+ walking. The region must call the player's climb DIRECTLY via ``RunScriptSync`` (which is exactly what
15
+ the real game does). The real climb is bespoke per-ladder jump arcs (hard-coded coords) -- not
16
+ generalizable -- so the kit's climb is a simple teleport to the destination (+ an optional climb
17
+ gesture). The TRIGGER is faithful; the climb body is simplified.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import struct
22
+
23
+ from ..eb import EbScript, edit, opcodes
24
+ from ..eb.disasm import iter_code
25
+ from . import region as _region
26
+
27
+ PLAYER_UID = 250 # the controlled player's object UID (standard across FF9 fields)
28
+ FIRST_CLIMB_TAG = 17 # the real Treno player ladder funcs start at tag 17; one tag per ladder
29
+ RUNSCRIPT_LEVEL = 2 # the script level arg the real ladder uses for RunScriptSync
30
+ WAIT = 0x22
31
+ STARTSEQ = 0x43 # RunSharedScript -- launches "entry arg0 of this field" as a concurrent Seq
32
+ SETUP_JUMP = 0xE2 # SetupJump(x, y, z, arc): the climb's per-rung jump arcs (absolute dest)
33
+ LADDER_FLAG = 4 # AddCharacterAttribute(4) = "on a ladder": don't floor-snap during a climb
34
+ ZONE_MARGIN = 150 # padding (world units) around the climb's span when auto-sizing a zone
35
+
36
+
37
+ def _s16(v: int) -> int:
38
+ return v - 65536 if v >= 32768 else v
39
+
40
+
41
+ def climb_landings(climb_bytes: bytes) -> list:
42
+ """Every ``SetupJump`` (X, Z) destination in a climb -- the absolute world points the player
43
+ lands on while climbing (top, bottom, and any intermediate rungs)."""
44
+ from ..eb.disasm import read_code
45
+ out, pos = [], 0
46
+ while pos < len(climb_bytes):
47
+ try:
48
+ ins, nxt = read_code(climb_bytes, pos)
49
+ except Exception:
50
+ break
51
+ if ins.op == SETUP_JUMP and len(ins.args) >= 3:
52
+ out.append((_s16(ins.args[0]), _s16(ins.args[2]))) # args = (jumpX, jumpY, jumpZ, steps)
53
+ pos = nxt
54
+ return out
55
+
56
+
57
+ def widen_zone_for_climb(zone, climb_bytes: bytes, margin: int = ZONE_MARGIN) -> list:
58
+ """Return a 4-corner bbox quad covering BOTH the real entry zone AND every climb landing point.
59
+
60
+ An imported real ladder's ``SetRegion`` zone only covers the side the player normally approaches
61
+ from, so a FORK (where the player can end up at either end) gets no '!' prompt at the far end and
62
+ can't climb back. Unioning the zone with the climb's ``SetupJump`` destinations (+ margin) makes
63
+ the trigger span the whole ladder, so it's bidirectional. (Proven in-game: CPMP simple ladder.)"""
64
+ pts = [tuple(p) for p in (zone or [])] + climb_landings(climb_bytes)
65
+ if not pts:
66
+ return zone
67
+ xs = [p[0] for p in pts]
68
+ zs = [p[1] for p in pts]
69
+ x0, x1 = min(xs) - margin, max(xs) + margin
70
+ z0, z1 = min(zs) - margin, max(zs) + margin
71
+ return [[x0, z1], [x1, z1], [x1, z0], [x0, z0]]
72
+
73
+
74
+ def find_player_entry(eb: EbScript) -> int:
75
+ """Index of the player entry -- the one running DefinePlayerCharacter (opcode 0x2C)."""
76
+ for e in eb.entries:
77
+ if e.empty:
78
+ continue
79
+ for f in e.funcs:
80
+ for ins in eb.instrs(f):
81
+ if ins.op == 0x2C:
82
+ return e.index
83
+ raise ValueError("no player entry (DefinePlayerCharacter) found -- can't attach a climb function")
84
+
85
+
86
+ def climb_body(dest, *, animation: int | None = None, anim_hold: int = 40) -> bytes:
87
+ """The player's climb function body: an optional climb gesture, then teleport to ``dest``
88
+ ``(x, z)`` or ``(x, z, y)`` + re-enable walkmesh pathing. Runs in the player's context (via
89
+ RunScriptSync), so ``MoveInstantXZY`` moves the player."""
90
+ x, z = int(dest[0]), int(dest[1])
91
+ y = int(dest[2]) if len(dest) > 2 else 0
92
+ body = b""
93
+ if animation is not None:
94
+ body += opcodes.run_animation(int(animation)) + opcodes.encode(WAIT, int(anim_hold))
95
+ body += opcodes.move_instant_xzy(x, z, y) + opcodes.set_pathing(1) + opcodes.RETURN
96
+ return body
97
+
98
+
99
+ def climb_arc_body(arc_from, arc_to, *, rungs: int = 4, steps: int = 6) -> bytes:
100
+ """DEPRECATED -- superseded by :func:`navigable_climb_body`. This auto-plays a fixed rung-hop
101
+ sequence end-to-end, which is NOT how FF9 ladders work (real ladders are navigable: you hold the
102
+ d-pad to climb up/down rung-by-rung). Kept only for back-compat; use the navigable climb instead.
103
+
104
+ An ANIMATED generic climb: interpolated `SetupJump`/`Jump` rung-hops from ``arc_from`` to
105
+ ``arc_to``, each ``(x, z)`` or ``(x, z, height)`` (height defaults 0 = on the floor). Runs in the
106
+ player's context (RunScriptSync), so each rung moves the PLAYER; the engine projects every world
107
+ rung through the camera, so the climb traces the painted/borrowed ladder for free -- the faithful
108
+ jump-arc behavior, auto-generated from two endpoints (no hand-authored coords). Direction-agnostic:
109
+ pass (bottom, top) to ascend or (top, bottom) to descend. `rungs` = hops, `steps` = frames/hop.
110
+ Ends with `SetPathing(1)` to re-enable walkmesh collision at the destination."""
111
+ fx, fz = int(arc_from[0]), int(arc_from[1])
112
+ fy = int(arc_from[2]) if len(arc_from) > 2 else 0
113
+ tx, tz = int(arc_to[0]), int(arc_to[1])
114
+ ty = int(arc_to[2]) if len(arc_to) > 2 else 0
115
+ rungs = max(1, int(rungs))
116
+ body = opcodes.add_character_attribute(LADDER_FLAG) # ladder flag: don't snap to floor mid-climb
117
+ for i in range(1, rungs + 1):
118
+ f = i / rungs
119
+ x = round(fx + (tx - fx) * f)
120
+ z = round(fz + (tz - fz) * f)
121
+ y = round(fy + (ty - fy) * f)
122
+ body += opcodes.setup_jump(x, z, y, steps) + opcodes.jump()
123
+ if ty != 0: # ending ABOVE the (flat) walkmesh: hold here,
124
+ body += opcodes.set_pathing(0) # keep collision off so it isn't snapped down
125
+ else: # ending ON the floor: dismount to normal walking
126
+ body += opcodes.remove_character_attribute(LADDER_FLAG) + opcodes.set_pathing(1)
127
+ body += opcodes.RETURN
128
+ return body
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # The NAVIGABLE climb -- FF9's real ladder mechanism, recreated from two endpoints.
133
+ #
134
+ # Decoded byte-for-byte from field 706 (EVT_GIZ_TO_WORLD, the Gizamaluke vine; entry 14 = player,
135
+ # func tag 11). The real ladder is a per-frame state machine, NOT an auto-played sequence: mount onto
136
+ # the vine, then a loop that reads the HELD d-pad + the player's world-Y each frame, advances a scratch
137
+ # target +/-step, and snaps the player onto the 3D line between the two endpoints (MoveInstantXZY with
138
+ # X/Z linear in height); leaving the height band ends the loop and a height-keyed selector dismounts at
139
+ # whichever end you left from. The per-vine constants (the line equation, the band) are DERIVED here
140
+ # from the two world endpoints, so it reproduces 706's loop verbatim for 706's endpoints yet works for
141
+ # any new painted vine -- the truthful from-scratch ladder. See project memory + the Session 22 decode.
142
+ # ---------------------------------------------------------------------------
143
+ SELF = 255
144
+ F_Y = 1 # op78 field 1 = world-Y-up (= -pos.y); the climb tracks this
145
+ F_ANIMFRAME = 7
146
+ CLIMB_SCRATCH = 2 # MAP.I16[2]: the per-frame climb target (matches 706; transient per-field)
147
+ CLIMB_ANIM = 10539 # the per-frame climb-cycle animation (model-specific; Zidane in 706)
148
+ MOUNT_ANIM = 10687 # SetJumpAnimation for the mount arc
149
+ DISMOUNT_ANIM = 11453 # SetJumpAnimation for the dismount arc
150
+
151
+
152
+ def _const(v: int) -> bytes:
153
+ return bytes([_region.T_CONST]) + struct.pack("<h", int(v))
154
+
155
+
156
+ def _selfv(field: int) -> bytes:
157
+ return _region.obj_var(SELF, field)
158
+
159
+
160
+ def _scratch() -> bytes:
161
+ return _region._push_var(_region.MAP_INT16, CLIMB_SCRATCH)
162
+
163
+
164
+ def _stmt(*toks: bytes) -> bytes:
165
+ """A complete expression statement: ``05 <tokens> 7F``."""
166
+ return bytes([_region.EXPR_OP]) + b"".join(toks) + bytes([_region.T_END])
167
+
168
+
169
+ def _arg(*toks: bytes) -> bytes:
170
+ """A bare expression operand (for an opcode arg with its arg_flags bit set): ``<tokens> 7F``."""
171
+ return b"".join(toks) + bytes([_region.T_END])
172
+
173
+
174
+ class _Asm:
175
+ """A tiny label assembler for the climb's mixed forward+backward jumps. Every jump's operand is
176
+ ``target_off - (jmp_off + 3)`` as a signed i16 (verified: the engine does ``ip = A + 3 + offset``),
177
+ so one rule covers the forward if-skips AND the loop back-edge -- computed after layout."""
178
+
179
+ def __init__(self):
180
+ self._items = [] # ('raw', bytes) | ('lbl', name) | ('jmp', op, target)
181
+
182
+ def raw(self, b: bytes):
183
+ if b:
184
+ self._items.append(("raw", bytes(b)))
185
+ return self
186
+
187
+ def label(self, name: str):
188
+ self._items.append(("lbl", name))
189
+ return self
190
+
191
+ def jmp(self, op: int, target: str):
192
+ self._items.append(("jmp", op, target))
193
+ return self
194
+
195
+ def assemble(self) -> bytes:
196
+ labels, off = {}, 0
197
+ for it in self._items:
198
+ if it[0] == "lbl":
199
+ labels[it[1]] = off
200
+ elif it[0] == "raw":
201
+ off += len(it[1])
202
+ else:
203
+ off += 3
204
+ out, off = bytearray(), 0
205
+ for it in self._items:
206
+ if it[0] == "raw":
207
+ out += it[1]
208
+ off += len(it[1])
209
+ elif it[0] == "jmp":
210
+ _, op, tgt = it
211
+ out += bytes([op]) + struct.pack("<h", labels[tgt] - (off + 3))
212
+ off += 3
213
+ return bytes(out)
214
+
215
+
216
+ def _dismount(anim: int, x: int, z: int, y: int = 0, steps: int = 6, frames=(2, 8)) -> bytes:
217
+ """Jump off the vine onto the floor at height ``y``, re-enable walkmesh collision + clear the
218
+ ladder flag + restore the animation flags -- the clean dismount (706's floor walk-off, leaned out).
219
+ Real floors are often elevated (non-zero ``y``); ``frames`` = the jump anim's in/out window."""
220
+ return (opcodes.set_jump_animation(anim, frames[0], frames[1]) + opcodes.run_jump_animation()
221
+ + opcodes.wait_animation() + opcodes.setup_jump(x, z, y, steps) + opcodes.jump()
222
+ + opcodes.run_land_animation() + opcodes.wait_animation()
223
+ + opcodes.set_pathing(1) + opcodes.remove_character_attribute(LADDER_FLAG)
224
+ + opcodes.set_animation_flags(0, 0))
225
+
226
+
227
+ def navigable_climb_body(bottom, top, *, floor_landing=None, top_landing=None, step: int = 20,
228
+ up_mask: int = 0x10, down_mask: int = 0x40, right_alias: bool = False,
229
+ dirs=None, rungs=None,
230
+ climb_anim: int = CLIMB_ANIM, climb_frames: int = 12,
231
+ mount_anim: int = MOUNT_ANIM, dismount_anim: int = DISMOUNT_ANIM,
232
+ mount_steps: int = 4, dismount_steps: int = 6,
233
+ two_way_mount: bool = False, top_mount_anim=None, top_mount_steps=None,
234
+ face_angle: int | None = None, top_action: str = "floor",
235
+ top_field: int | None = None, top_entrance: int = 0,
236
+ top_worldmap: int | None = None) -> bytes:
237
+ """Recreate FF9's NAVIGABLE ladder climb for a vine between two world endpoints.
238
+
239
+ ``bottom`` / ``top`` = ``(x, z, y)`` world points (``y`` = up-positive height; they MUST differ in
240
+ ``y``). The player mounts at ``bottom`` and climbs by holding the d-pad: each frame the loop reads
241
+ the held direction (``B_KEY``) + the player's world-Y, advances the scratch target +/- ``step``,
242
+ and snaps the player onto the 3D line between the endpoints (``MoveInstantXZY``, X/Z linear in
243
+ height). Leaving the band ends the loop; a height-keyed selector dismounts to the floor at the end
244
+ you left from (``floor_landing`` for the bottom end, ``top_landing`` for the top -- both default to
245
+ the vine's own x/z at floor level). Runs in the player's own context (the region RunScriptSync's
246
+ it), so its moves move the PLAYER.
247
+
248
+ The line equation + height band are DERIVED from the two endpoints, so passing 706's endpoints
249
+ reproduces 706's loop byte-for-byte, while any new painted vine just supplies its own two points
250
+ (read off the paint guide, same as walkmesh placement). ``up_mask`` / ``down_mask`` are the
251
+ ``B_KEY`` button bits (vertical ladder default Up=0x10 / Down=0x40; pass ``right_alias`` for a
252
+ diagonal screen vine that also climbs on Right). Returns the climb function body.
253
+
254
+ ``rungs`` = an optional list of >=2 world points (bottom..top) for a MULTI-RUNG (bent) vine -- a
255
+ piecewise-linear climb (the real Cleyra ladder shape): consecutive points form segments, each with
256
+ its own slope, and the snap picks the segment whose selfY band holds the target. When given it
257
+ overrides ``bottom``/``top`` (= rungs[0]/rungs[-1]); the band/mount/dismount use those endpoints."""
258
+ if rungs is not None:
259
+ rungs = [(int(p[0]), int(p[1]), int(p[2]) if len(p) > 2 else 0) for p in rungs]
260
+ if len(rungs) < 2:
261
+ raise ValueError("navigable ladder rungs need >= 2 points (bottom..top)")
262
+ bottom, top = rungs[0], rungs[-1]
263
+ bx, bz = int(bottom[0]), int(bottom[1])
264
+ by = int(bottom[2]) if len(bottom) > 2 else 0
265
+ tx, tz = int(top[0]), int(top[1])
266
+ ty = int(top[2]) if len(top) > 2 else 0
267
+ if ty == by:
268
+ raise ValueError("navigable ladder: top and bottom must differ in height (y)")
269
+ # selfY = -worldY (op78 field 1). Band = [lo, hi] in selfY space; the line is anchored at bottom.
270
+ sy_bottom, sy_top = -by, -ty
271
+ lo, hi = min(sy_bottom, sy_top), max(sy_bottom, sy_top)
272
+ exit_threshold = (lo + hi) // 2
273
+ anchor, slope_den = sy_bottom, sy_top - sy_bottom
274
+ x_slope, z_slope = tx - bx, tz - bz
275
+ # MULTI-RUNG: one (bx, bz, x_slope, z_slope, anchor, slope_den, top_sy) per segment (bottom->top).
276
+ segments = None
277
+ if rungs is not None:
278
+ segments = [(px, pz, qx - px, qz - pz, -py, (-qy) - (-py), -qy)
279
+ for (px, pz, py), (qx, qz, qy) in zip(rungs, rungs[1:])]
280
+ fl = floor_landing if floor_landing else (bx, bz)
281
+ flx, flz = int(fl[0]), int(fl[1]); fly = int(fl[2]) if len(fl) > 2 else 0
282
+ tl = top_landing if top_landing else (tx, tz)
283
+ tlx, tlz = int(tl[0]), int(tl[1]); tly = int(tl[2]) if len(tl) > 2 else 0
284
+ if top_action == "field" and top_field is None:
285
+ raise ValueError('navigable ladder top_action="field" needs top_field')
286
+ if top_action == "worldmap" and top_worldmap is None:
287
+ raise ValueError('navigable ladder top_action="worldmap" needs top_worldmap')
288
+
289
+ def line(base, slope, anc=None, den=None): # base + (target - anc) * slope / den (706 verbatim)
290
+ anc = anchor if anc is None else anc
291
+ den = slope_den if den is None else den
292
+ return _arg(_const(base), _const(slope), _scratch(), _const(anc),
293
+ bytes([_region.T_MINUS]), bytes([_region.T_MULT]),
294
+ _const(den), bytes([_region.T_DIV]), bytes([_region.T_PLUS]))
295
+
296
+ def band(): # (selfY <= hi) && (selfY >= lo) -- still on the vine
297
+ return _stmt(_selfv(F_Y), _const(hi), bytes([_region.T_LE]),
298
+ _selfv(F_Y), _const(lo), bytes([_region.T_GE]), bytes([_region.T_ANDAND]))
299
+
300
+ def anim_window(advance): # SetAnimationInOut((animFrame+advance)%N, ...): step the climb anim
301
+ # a ONE-frame window = the climb's clock. advance=1 plays it FORWARD (ascending), advance=N-1
302
+ # (= -1 mod N) plays it BACKWARD (descending) so the hands match the down motion (real GZML).
303
+ w = _arg(_selfv(F_ANIMFRAME), _const(advance), bytes([_region.T_PLUS]),
304
+ _const(climb_frames), bytes([_region.T_MOD]))
305
+ return opcodes.encode(0x3D, w, w, arg_flags=0b11)
306
+
307
+ def set_target(sign): # MAP.I16[2] = selfY (+/- step); sign=None just holds (= selfY, no move)
308
+ if sign is None:
309
+ return _stmt(_scratch(), _selfv(F_Y), bytes([_region.T_ASSIGN]))
310
+ return _stmt(_scratch(), _selfv(F_Y), _const(step), bytes([sign]), bytes([_region.T_ASSIGN]))
311
+
312
+ a = _Asm()
313
+
314
+ def _pair(v): # a per-END value: scalar -> (v, v); a [bottom, top] list/tuple -> as given
315
+ return (int(v[0]), int(v[1])) if isinstance(v, (list, tuple)) else (int(v), int(v))
316
+ da_b, da_t = _pair(dismount_anim) # per-end dismount ANIM (bottom, top) -- e.g. CPMP 11453 / 10685
317
+ ds_b, ds_t = _pair(dismount_steps) # per-end dismount STEPS -- e.g. CPMP 6 / 8
318
+ tm_anim = mount_anim if top_mount_anim is None else int(top_mount_anim)
319
+ tm_steps = mount_steps if top_mount_steps is None else int(top_mount_steps)
320
+
321
+ def mount_arc(m_anim, dx, dz, dy, m_steps): # face + jump-on anim + jump ONTO a vine end + grip
322
+ return ((opcodes.turn_instant(int(face_angle)) if face_angle is not None else b"")
323
+ + opcodes.set_jump_animation(int(m_anim), 2, 6) + opcodes.run_jump_animation()
324
+ + opcodes.wait_animation() + opcodes.add_character_attribute(LADDER_FLAG)
325
+ + opcodes.setup_jump(int(dx), int(dz), int(dy), int(m_steps)) + opcodes.jump()
326
+ + opcodes.run_land_animation() + opcodes.wait_animation()
327
+ + opcodes.set_pathing(0) + opcodes.set_animation_flags(1, 0)
328
+ + opcodes.set_animation_in_out(0, 0))
329
+ # MOUNT. selfY > the mid-band threshold => arriving near the BOTTOM end.
330
+ a.raw(_stmt(_selfv(F_Y), _const(exit_threshold), bytes([_region.T_GT])))
331
+ if two_way_mount:
332
+ # TWO-WAY (CPMP/TRNO): mount the END you approached -- bottom-arc if low, top-arc if high. Each end
333
+ # has its own jump anim + dest + steps. Needs a region at BOTH floors (inject_navigable_ladder
334
+ # top_zone) so you can stand at either end to press action.
335
+ a.jmp(_region.JMP_FALSE, "TOPMOUNT")
336
+ a.raw(mount_arc(mount_anim, bx, bz, by, mount_steps))
337
+ a.jmp(0x01, "LOOP")
338
+ a.label("TOPMOUNT")
339
+ a.raw(mount_arc(tm_anim, tx, tz, ty, tm_steps))
340
+ else:
341
+ # single bottom-mount; high on the vine (RE-ENTRY) -> skip the mount (the player-init already
342
+ # placed you with the ladder flag + SetPathing(0)) -> drop into the loop. Faithful to 706.
343
+ a.jmp(_region.JMP_FALSE, "LOOP")
344
+ a.raw(mount_arc(mount_anim, bx, bz, by, mount_steps))
345
+ # NAVIGATE LOOP
346
+ a.label("LOOP")
347
+ # input: a FIRST-MATCH-WINS else-if chain (like the real GZML loop). Each held direction advances
348
+ # the climb anim one frame, sets the target +/- step, and JUMPS to the snap (skipping the rest) --
349
+ # so an up-diagonal climbs UP even though the down mask (Down|Left) can overlap it. No input -> HOLD
350
+ # (target = selfY; the anim window is NOT advanced, so it freezes on a grip pose rather than looping).
351
+ # input bindings: an explicit `dirs` list of (mask, "up"|"down") -- first-match-wins -- subsumes the
352
+ # up_mask/down_mask/right_alias shorthand and expresses real multi-key fields (TRNO/UDFT bind both
353
+ # Up AND Left to climb up: dirs=[[0x10,"up"],[0x80,"up"],[0x60,"down"]]). Default = up=0x10/down=0x40.
354
+ if dirs is not None:
355
+ dir_list = [(int(m), _region.T_MINUS if str(d).lower() == "up" else _region.T_PLUS) for m, d in dirs]
356
+ else:
357
+ dir_list = [(up_mask, _region.T_MINUS), (down_mask, _region.T_PLUS)]
358
+ if right_alias:
359
+ dir_list.append((0x20, _region.T_MINUS)) # Right = a second 'up' binding
360
+ for i, (mask, sign) in enumerate(dir_list):
361
+ adv = 1 if sign == _region.T_MINUS else climb_frames - 1 # up = forward, down = backward
362
+ a.raw(_stmt(_const(mask), bytes([_region.T_KEY]))) # if (mask held)
363
+ a.jmp(_region.JMP_FALSE, f"DIR{i}")
364
+ a.raw(anim_window(adv) + set_target(sign))
365
+ a.jmp(0x01, "SNAP")
366
+ a.label(f"DIR{i}")
367
+ a.raw(set_target(None)) # HOLD: no direction held
368
+ a.label("SNAP")
369
+ # snap the player onto the vine line for the new height (X/Z exprs; middle arg = bare target)
370
+ if segments:
371
+ # MULTI-RUNG (bent vine): pick the segment whose selfY band holds the target, snap to ITS line.
372
+ # Checked bottom->top by each segment's top-of-band (selfY GE); the last segment is the fallback.
373
+ for i, (sbx, sbz, sxs, szs, sanc, sden, stop) in enumerate(segments[:-1]):
374
+ a.raw(_stmt(_scratch(), _const(stop), bytes([_region.T_GE]))) # target at/below seg i's top?
375
+ a.jmp(_region.JMP_FALSE, f"SEG{i + 1}")
376
+ a.raw(opcodes.encode(0xA1, line(sbx, sxs, sanc, sden), _arg(_scratch()),
377
+ line(sbz, szs, sanc, sden), arg_flags=0b111))
378
+ a.jmp(0x01, "SNAP_DONE")
379
+ a.label(f"SEG{i + 1}")
380
+ lbx, lbz, lxs, lzs, lanc, lden, _stop = segments[-1]
381
+ a.raw(opcodes.encode(0xA1, line(lbx, lxs, lanc, lden), _arg(_scratch()),
382
+ line(lbz, lzs, lanc, lden), arg_flags=0b111))
383
+ a.label("SNAP_DONE")
384
+ else:
385
+ a.raw(opcodes.encode(0xA1, line(bx, x_slope), _arg(_scratch()), line(bz, z_slope),
386
+ arg_flags=0b111))
387
+ # climb-cycle anim while on the vine, else a 1-frame wait
388
+ a.raw(band())
389
+ a.jmp(_region.JMP_FALSE, "OFFVINE")
390
+ a.raw(opcodes.run_animation(climb_anim) + opcodes.wait_animation())
391
+ a.jmp(0x01, "ANIMDONE")
392
+ a.label("OFFVINE")
393
+ a.raw(opcodes.wait(1))
394
+ a.label("ANIMDONE")
395
+ # loop while still on the vine
396
+ a.raw(band())
397
+ a.jmp(_region.JMP_TRUE, "LOOP")
398
+ # EXIT: selfY > midpoint -> BOTTOM (floor) dismount; else -> the TOP end (per top_action)
399
+ a.raw(_stmt(_selfv(F_Y), _const(exit_threshold), bytes([_region.T_GT])))
400
+ a.jmp(_region.JMP_FALSE, "TOP_END")
401
+ a.raw(_dismount(da_b, flx, flz, fly, ds_b)) # bottom -> floor dismount (per-end anim)
402
+ a.jmp(0x01, "END")
403
+ a.label("TOP_END")
404
+ if top_action == "field": # top -> a Field() gateway
405
+ # The engine's field transition: fade out, WAIT for the fade to finish, set the arrival
406
+ # entrance, then Field(). We do NOT emit PreloadField -- it is opcode 0xFD (HINT), "ignored in
407
+ # the non-PSX versions" (a no-op on Steam); and crucially it must NOT be confused with 0x2A =
408
+ # Battle (emitting 0x2A here literally fired a battle using the field id as the scene). Move/menu
409
+ # are already disabled by the region that RunScriptSync'd this climb.
410
+ a.raw(opcodes.fade_filter(6, 24, 0, 255, 255, 255) + opcodes.wait(25)
411
+ + _region.set_field_entrance(int(top_entrance))
412
+ + opcodes.field(int(top_field)) + opcodes.terminate_entry(255))
413
+ elif top_action == "worldmap": # top -> the world map
414
+ a.raw(opcodes.fade_filter(6, 24, 0, 255, 255, 255) + opcodes.wait(25)
415
+ + _region.set_field_entrance(int(top_entrance))
416
+ + opcodes.world_map(int(top_worldmap)) + opcodes.terminate_entry(255))
417
+ else: # "floor": dismount onto a top floor
418
+ a.raw(_dismount(da_t, tlx, tlz, tly, ds_t)) # top -> floor dismount (per-end anim)
419
+ a.label("END")
420
+ a.raw(opcodes.RETURN)
421
+ return a.assemble()
422
+
423
+
424
+ def ladder_region(zone, climb_tag: int, *, player_uid: int = PLAYER_UID) -> bytes:
425
+ """A type-1 region entry: Init ``SetRegion(zone)`` / tread ``Bubble(1)`` / action ``DisableMove;
426
+ RunScriptSync(player climb); EnableMove`` -- the real FF9 ladder trigger."""
427
+ init = _region.set_region(zone) + opcodes.RETURN
428
+ tread = _region.MOVEMENT_GATE + opcodes.bubble(1) + opcodes.RETURN
429
+ action = (_region.MOVEMENT_GATE + opcodes.DISABLE_MOVE
430
+ + opcodes.run_script_sync(RUNSCRIPT_LEVEL, player_uid, climb_tag)
431
+ + opcodes.ENABLE_MOVE + opcodes.RETURN)
432
+ funcs = [(0, init), (_region.RANGE_TAG, tread), (_region.INTERACT_TAG, action)]
433
+ table = b""
434
+ pos = len(funcs) * 4
435
+ for tag, body in funcs:
436
+ table += struct.pack("<HH", tag, pos)
437
+ pos += len(body)
438
+ return bytes([_region.REGION_ENTRY_TYPE, len(funcs)]) + table + b"".join(b for _, b in funcs)
439
+
440
+
441
+ def inject_ladder(data, zone, dest=None, *, climb_bytes: bytes | None = None,
442
+ arc_from=None, arc_to=None, rungs: int = 4, steps: int = 6,
443
+ sequences: dict | None = None, climb_tag: int = FIRST_CLIMB_TAG,
444
+ player_uid: int = PLAYER_UID, animation: int | None = None, activate: bool = True):
445
+ """Inject a ladder: add a climb function (``climb_tag``) to the player entry + a ladder region
446
+ (tread "!" prompt + action -> RunScriptSync the climb), and arm the region. Returns
447
+ ``(new_bytes, region_slot)``. For multiple ladders pass a distinct ``climb_tag`` each.
448
+
449
+ The climb is either FAITHFUL or EMULATED:
450
+ * ``climb_bytes`` -- a real ladder's climb function extracted verbatim by
451
+ ``eventscan.scan_ladders`` (exact jump arcs, perspective-correct). Grafted as-is; its internal
452
+ jumps are function-relative so they survive the move. This is what ``import`` emits for a fork.
453
+ * ``dest`` -- ``(x, z[, y])``; ``climb_body`` builds a teleport (+ optional gesture). The simple
454
+ generic climb when you have no real ladder to copy.
455
+
456
+ ``sequences`` (``{original_entry_index: entry_bytes}``, from ``scan_ladders``) are the concurrent
457
+ helper entries the climb launches via STARTSEQ (e.g. the SetPitchAngle forward-lean). Each is
458
+ appended at a free slot and the climb's STARTSEQ entry-args are remapped to those slots (a
459
+ same-length 1-byte patch -- the climb stays byte-for-byte otherwise). Empty for simple ladders."""
460
+ animated = arc_from is not None and arc_to is not None
461
+ if climb_bytes is None and dest is None and not animated:
462
+ raise ValueError("inject_ladder needs climb_bytes (faithful), arc_from+arc_to (animated arc), or dest (teleport)")
463
+ if animated:
464
+ body = bytearray(climb_arc_body(arc_from, arc_to, rungs=rungs, steps=steps))
465
+ elif climb_bytes is not None:
466
+ body = bytearray(climb_bytes)
467
+ else:
468
+ body = bytearray(climb_body(dest, animation=animation))
469
+ if sequences: # graft the STARTSEQ helper entries + remap
470
+ ei2slot = {}
471
+ for ei in sorted(sequences):
472
+ slot = EbScript.from_bytes(data).first_free_slot()
473
+ data = edit.append_entry(data, slot, sequences[ei])
474
+ ei2slot[ei] = slot
475
+ for ins in iter_code(bytes(body), 0, len(body)):
476
+ if ins.op == STARTSEQ and ins.args and ins.args[0] in ei2slot:
477
+ body[ins.off + 2] = ei2slot[ins.args[0]] # STARTSEQ = 0x43, argflag, entry-arg
478
+ body = bytes(body)
479
+ eb = EbScript.from_bytes(data)
480
+ pe = find_player_entry(eb)
481
+ data = edit.add_function(data, pe, climb_tag, body)
482
+ eb = EbScript.from_bytes(data)
483
+ slot = eb.first_free_slot()
484
+ data = edit.append_entry(data, slot, ladder_region([tuple(p) for p in zone], climb_tag,
485
+ player_uid=player_uid))
486
+ if activate:
487
+ data = edit.activate(data, opcodes.init_region(slot, 0))
488
+ return data, slot
489
+
490
+
491
+ def square_zone(center, radius: int = 150) -> list:
492
+ """A 5-point IsInQuad-safe square trigger zone (side 2*radius) centred on ``(x, z)``."""
493
+ cx, cz = int(center[0]), int(center[1])
494
+ r = int(radius)
495
+ c = [[cx - r, cz + r], [cx + r, cz + r], [cx + r, cz - r], [cx - r, cz - r]]
496
+ return c + [c[-1]] # double last vertex (IsInQuad fan safety)
497
+
498
+
499
+ def inject_bidirectional_ladder(data, top, bottom, *, radius: int = 150, rungs: int = 4,
500
+ steps: int = 6, animation: int | None = None,
501
+ first_tag: int = FIRST_CLIMB_TAG):
502
+ """A from-scratch BIDIRECTIONAL ladder with no real climb to copy: a trigger zone at EACH end, the
503
+ player's location picks the direction (top zone -> down to ``bottom``, bottom zone -> up to
504
+ ``top``), so it climbs both ways WITHOUT reading runtime position. ``top``/``bottom`` are the
505
+ trigger-zone centre + landing point for each end.
506
+
507
+ If EITHER endpoint carries a height (``(x, z, y)``, y>0) the climb is ANIMATED -- interpolated
508
+ `SetupJump`/`Jump` rung-hops that the engine projects so they trace the painted/borrowed ladder
509
+ (the faithful behavior, auto-generated). If both are flat ``(x, z)`` it falls back to an instant
510
+ teleport (the zero-info generic). Returns ``(new_bytes, next_tag)`` (consumes two climb tags)."""
511
+ animated = len(top) > 2 or len(bottom) > 2
512
+ if animated: # arc DOWN from the top zone, arc UP from the bottom
513
+ data, _ = inject_ladder(data, square_zone(top, radius), arc_from=top, arc_to=bottom,
514
+ rungs=rungs, steps=steps, climb_tag=first_tag)
515
+ data, _ = inject_ladder(data, square_zone(bottom, radius), arc_from=bottom, arc_to=top,
516
+ rungs=rungs, steps=steps, climb_tag=first_tag + 1)
517
+ else: # flat endpoints -> instant teleport fallback
518
+ data, _ = inject_ladder(data, square_zone(top, radius), dest=bottom,
519
+ climb_tag=first_tag, animation=animation)
520
+ data, _ = inject_ladder(data, square_zone(bottom, radius), dest=top,
521
+ climb_tag=first_tag + 1, animation=animation)
522
+ return data, first_tag + 2
523
+
524
+
525
+ def inject_navigable_ladder(data, bottom, top, *, floor_landing=None, top_landing=None, zone=None,
526
+ top_zone=None, radius: int = 200, climb_tag: int = FIRST_CLIMB_TAG,
527
+ player_uid: int = PLAYER_UID, activate: bool = True, **climb_kw):
528
+ """A from-scratch NAVIGABLE ladder between two world endpoints -- FF9's REAL ladder mechanism,
529
+ recreated (NOT the deprecated auto-hop): ONE trigger zone at the vine base -> press action -> hold
530
+ the d-pad to climb up/down, snapped onto the painted vine, dismount at either end.
531
+
532
+ ``bottom`` / ``top`` = ``(x, z, y)`` world points (``y`` = up-positive height). The trigger ``zone``
533
+ defaults to a square at the floor step-off point (``floor_landing`` or the bottom's x/z) -- where
534
+ the player stands to mount. Extra climb params (``step``, ``up_mask`` / ``down_mask``,
535
+ ``right_alias``, ``climb_anim`` / ``mount_anim`` / ``dismount_anim``, ``face_angle`` ...) pass
536
+ through to :func:`navigable_climb_body`. The generated body is grafted exactly like a faithful
537
+ climb (it IS a climb body), so it reuses the proven trigger/region machinery. Returns
538
+ ``(new_bytes, region_slot)``. One climb function => one ladder; pass a distinct ``climb_tag`` each."""
539
+ body = navigable_climb_body(bottom, top, floor_landing=floor_landing, top_landing=top_landing,
540
+ **climb_kw)
541
+ if zone is None:
542
+ base = floor_landing if floor_landing is not None else (int(bottom[0]), int(bottom[1]))
543
+ zone = square_zone(base, radius)
544
+ data, slot = inject_ladder(data, zone, climb_bytes=body, climb_tag=climb_tag,
545
+ player_uid=player_uid, activate=activate)
546
+ if top_zone is not None: # TWO-WAY mount: a SECOND trigger at the TOP floor invoking the SAME
547
+ tz = (square_zone(top_zone, radius) # climb (its height selector picks
548
+ if isinstance(top_zone[0], (int, float)) # the top arc); accept a centre or a
549
+ else [tuple(p) for p in top_zone]) # full quad, like `zone`.
550
+ eb = EbScript.from_bytes(data)
551
+ slot2 = eb.first_free_slot()
552
+ data = edit.append_entry(data, slot2, ladder_region(tz, climb_tag, player_uid=player_uid))
553
+ if activate:
554
+ data = edit.activate(data, opcodes.init_region(slot2, 0))
555
+ return data, slot
556
+
557
+
558
+ def reentry_spawn_block(x: int, z: int, y: int, *, face: int = 0,
559
+ climb_anim: int = CLIMB_ANIM) -> bytes:
560
+ """The on-vine RE-ENTRY placement (no RETURN -- meant to be inlined as an ``if`` body in the
561
+ player-init): place the player ON THE VINE at world ``(x, z)`` height ``y``, gripping (ladder flag
562
+ + detached from the walkmesh + the climb idle pose) and facing ``face``. So when you return to the
563
+ field from the ladder's top gateway you appear high on the vine and climb DOWN to get off (706's
564
+ re-entry pattern). ``y`` is the up-positive height; the encoder negates it like the climb does."""
565
+ return (opcodes.add_character_attribute(LADDER_FLAG)
566
+ + opcodes.move_instant_xzy(int(x), int(z), int(y))
567
+ + opcodes.turn_instant(int(face) & 0xFF)
568
+ + opcodes.set_pathing(0)
569
+ + opcodes.set_animation_flags(1, 0) + opcodes.set_animation_in_out(0, 0)
570
+ + opcodes.run_animation(int(climb_anim)))
571
+
572
+
573
+ REENTRY_WARMUP = 2 # frames the CLIMB-RUN code entry waits so it runs after the Init has placed you
574
+ # (the PLACEMENT itself is in the Init -> frame 0, pre-render, NO base flash)
575
+
576
+
577
+ def inject_reentry_spawn(data, entrance: int, x: int, z: int, y: int, *, climb_tag: int = FIRST_CLIMB_TAG,
578
+ face: int = 0, climb_anim: int = CLIMB_ANIM,
579
+ player_uid: int = PLAYER_UID, activate: bool = True):
580
+ """Make the field spawn the player ON THE VINE *already climbing* when entered via ``entrance`` --
581
+ the return from a ladder-top ``Field()`` gateway, so you hold Down to climb off (706's re-entry).
582
+
583
+ Faithful to field 706 (``EVT_GIZ_TO_WORLD``), which uses NO warm-up timer -- it does two things:
584
+ - its **player Init** places you on the vine for the re-entry entrance (``if (entrance==9999)``:
585
+ AddCharacterAttribute + MoveInstantXZY + SetPathing(0) + climb anim) -- so you are positioned
586
+ as the player object is created, *before the first render*, and never flash the base spawn;
587
+ - its **Main_Loop** runs ``RunScriptSync(player, climb)`` so you are in the climb loop from spawn
588
+ (then re-enables control once you dismount).
589
+
590
+ We replicate BOTH halves. The placement is SPLICED INTO the player Init right after
591
+ ``DefinePlayerCharacter`` (jump-safe -- matches 706's position; the Init's tail jumps + their RETURN
592
+ target shift together), and the climb is run from a code entry armed at field load (706 uses
593
+ Main_Loop)::
594
+
595
+ # spliced into the player Init, after DefinePlayerCharacter:
596
+ if (D8:2 == entrance) { <on-vine placement> }
597
+ # code entry:
598
+ if (D8:2 == entrance) { Wait; DisableMove; RunScriptSync(player, climb); EnableMove }
599
+
600
+ The earlier base-flash bug was placing you from a *post-Init* code entry (the Init had already
601
+ spawned you at the base, so any wait showed it). Placing in the Init kills the flash with no timer;
602
+ the small climb-run Wait is invisible (you are already on the vine). The climb's mount-gate sees you
603
+ high on the vine and skips the jump-on mount, dropping into the loop -> hold Down to climb off.
604
+ Returns ``(new_bytes, code_slot)``. ``entrance`` must match what the return gateway sets (D8:2);
605
+ ``climb_tag`` is the player's climb function for this ladder."""
606
+ eb = EbScript.from_bytes(data)
607
+ pe = find_player_entry(eb)
608
+ init = eb.entry(pe).func_by_tag(0)
609
+ if init is None:
610
+ raise ValueError("player entry has no Init (tag 0)")
611
+ dpc = next((i for i in eb.instrs(init) if i.op == 0x2C), None) # DefinePlayerCharacter
612
+ if dpc is None:
613
+ raise ValueError("player Init has no DefinePlayerCharacter (0x2C); cannot place re-entry spawn")
614
+ rel = dpc.end - init.abs_start # right after DefinePlayerCharacter
615
+ placement = _region.if_block(
616
+ _region.cond_eq(_region.GLOB_INT16, _region.FIELD_ENTRANCE_IDX, int(entrance)),
617
+ reentry_spawn_block(int(x), int(z), int(y), face=face, climb_anim=climb_anim)) # no RETURN
618
+ data = edit.insert_in_function(data, pe, 0, rel, placement)
619
+ # run the climb from a code entry (706 runs it from Main_Loop): post-Init -> you are already placed
620
+ body = (_region.if_block(
621
+ _region.cond_eq(_region.GLOB_INT16, _region.FIELD_ENTRANCE_IDX, int(entrance)),
622
+ opcodes.wait(REENTRY_WARMUP)
623
+ + opcodes.disable_move()
624
+ + opcodes.run_script_sync(RUNSCRIPT_LEVEL, player_uid, int(climb_tag)) # run the climb loop
625
+ + opcodes.enable_move())
626
+ + opcodes.RETURN)
627
+ code_entry = bytes([0, 1]) + struct.pack("<HH", 0, 4) + body # type-0 entry, 1 func (tag 0)
628
+ eb = EbScript.from_bytes(data)
629
+ slot = eb.first_free_slot()
630
+ data = edit.append_entry(data, slot, code_entry)
631
+ if activate:
632
+ data = edit.activate(data, opcodes.init_code(slot, 0))
633
+ return data, slot