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,138 @@
1
+ """Auto-pathing: route a blocked cutscene walk AROUND obstacles into clear straight legs.
2
+
3
+ A FF9 field walk is straight-line + synchronous, so it can't round a corner (off the walkmesh) or pass
4
+ a standing character (its collision box) on its own -- it presses into the obstacle and stalls. This
5
+ finds a route over the walkmesh that avoids both, then string-pulls it down to a few waypoints (which
6
+ the builder emits as a ``path``).
7
+
8
+ Grid A* over the walkmesh bounds: a cell is FREE if its centre is on the walkmesh, at least
9
+ ``clearance`` from any wall (the player's controller radius), and at least ``obstacle_r`` from every
10
+ other character's centre (= the collision distance, so the actor never enters a box). Pure stdlib;
11
+ operates on a :class:`ff9mapkit.scene.bgi.BgiWalkmesh`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import heapq
17
+
18
+ from ..scene import cam
19
+
20
+ _NEIGHBORS = [(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (-1, 1), (1, -1), (1, 1)]
21
+
22
+
23
+ def _free(wmesh, x, z, obstacles, clearance, obstacle_r) -> bool:
24
+ if wmesh.point_on_walkmesh(int(round(x)), int(round(z))) is None:
25
+ return False
26
+ if clearance > 0:
27
+ d = wmesh.distance_to_boundary(int(round(x)), int(round(z)))
28
+ if d is not None and d < clearance:
29
+ return False
30
+ r2 = obstacle_r * obstacle_r
31
+ for ox, oz in obstacles:
32
+ if (x - ox) ** 2 + (z - oz) ** 2 < r2:
33
+ return False
34
+ return True
35
+
36
+
37
+ def _clear(wmesh, a, b, obstacles, clearance, obstacle_r) -> bool:
38
+ """Is the straight leg a->b fully free (sampled ~every clearance)?"""
39
+ dx, dz = b[0] - a[0], b[1] - a[1]
40
+ dist = (dx * dx + dz * dz) ** 0.5
41
+ n = max(1, int(dist / max(8.0, clearance)))
42
+ for k in range(n + 1):
43
+ t = k / n
44
+ if not _free(wmesh, a[0] + dx * t, a[1] + dz * t, obstacles, clearance, obstacle_r):
45
+ return False
46
+ return True
47
+
48
+
49
+ def _simplify(wmesh, pts, obstacles, clearance, obstacle_r) -> list:
50
+ """String-pull a dense point list to a few waypoints (drop a point when you can see past it).
51
+ Returns the waypoints AFTER the start, always ending at the exact goal (pts[-1])."""
52
+ out, i = [], 0
53
+ while i < len(pts) - 1:
54
+ j = len(pts) - 1
55
+ while j > i + 1 and not _clear(wmesh, pts[i], pts[j], obstacles, clearance, obstacle_r):
56
+ j -= 1
57
+ out.append(pts[j])
58
+ i = j
59
+ return out
60
+
61
+
62
+ def _nearest_free_cell(free_cell, gi, gj, span=6):
63
+ """A free grid cell near (gi, gj) (the exact goal may sit within a margin of a wall/obstacle)."""
64
+ if free_cell(gi, gj):
65
+ return (gi, gj)
66
+ for ring in range(1, span + 1):
67
+ best = None
68
+ for di in range(-ring, ring + 1):
69
+ for dj in range(-ring, ring + 1):
70
+ if max(abs(di), abs(dj)) != ring:
71
+ continue
72
+ if free_cell(gi + di, gj + dj):
73
+ d = di * di + dj * dj
74
+ if best is None or d < best[0]:
75
+ best = (d, (gi + di, gj + dj))
76
+ if best:
77
+ return best[1]
78
+ return None
79
+
80
+
81
+ def route(wmesh, start, goal, obstacles=(), *, cell=64.0, clearance=None, obstacle_r=None,
82
+ max_expand=20000):
83
+ """Waypoints routing ``start``->``goal`` around walls + obstacles, or ``None`` if unreachable.
84
+
85
+ Returns the interior waypoints + the exact goal (EXCLUDING start), suitable as a ``path``. Stays on
86
+ the walkmesh, >= ``clearance`` from walls, >= ``obstacle_r`` from each obstacle centre. ``obstacles``
87
+ is a list of (x, z) character centres."""
88
+ clearance = cam.COLLISION_RADIUS_W if clearance is None else clearance
89
+ obstacle_r = 2 * cam.OBJECT_COLLISION_W if obstacle_r is None else obstacle_r
90
+ sx, sz = float(start[0]), float(start[1])
91
+ gx, gz = float(goal[0]), float(goal[1])
92
+
93
+ def cell_xz(i, j):
94
+ return (sx + i * cell, sz + j * cell)
95
+
96
+ def free_cell(i, j):
97
+ x, z = cell_xz(i, j)
98
+ return _free(wmesh, x, z, obstacles, clearance, obstacle_r)
99
+
100
+ start_c = (0, 0)
101
+ goal_c = _nearest_free_cell(free_cell, round((gx - sx) / cell), round((gz - sz) / cell))
102
+ if goal_c is None:
103
+ return None
104
+ if start_c == goal_c:
105
+ return [(int(round(gx)), int(round(gz)))]
106
+
107
+ open_h = [(0.0, start_c)]
108
+ came = {}
109
+ g = {start_c: 0.0}
110
+ expand = 0
111
+ while open_h:
112
+ _, c = heapq.heappop(open_h)
113
+ if c == goal_c:
114
+ break
115
+ expand += 1
116
+ if expand > max_expand:
117
+ return None
118
+ for di, dj in _NEIGHBORS:
119
+ n = (c[0] + di, c[1] + dj)
120
+ if n != goal_c and not free_cell(*n): # start/goal cells are taken as given
121
+ continue
122
+ step = cell * (1.41421356 if di and dj else 1.0)
123
+ ng = g[c] + step
124
+ if ng < g.get(n, 1e18):
125
+ g[n] = ng
126
+ came[n] = c
127
+ h = ((n[0] - goal_c[0]) ** 2 + (n[1] - goal_c[1]) ** 2) ** 0.5 * cell
128
+ heapq.heappush(open_h, (ng + h, n))
129
+ if goal_c not in came:
130
+ return None
131
+
132
+ chain = [goal_c]
133
+ while chain[-1] in came:
134
+ chain.append(came[chain[-1]])
135
+ chain.reverse() # start_c .. goal_c
136
+ pts = [(sx, sz)] + [cell_xz(i, j) for (i, j) in chain[1:-1]] + [(gx, gz)] # exact start..exact goal
137
+ wps = _simplify(wmesh, pts, obstacles, clearance, obstacle_r)
138
+ return [(int(round(x)), int(round(z))) for (x, z) in wps]
@@ -0,0 +1,314 @@
1
+ """Carry-platform primitive -- a rideable lift/elevator that physically CARRIES the player within one
2
+ field (no Field() re-entry warp), recreating FF9's Pandemonium-elevator mechanism.
3
+
4
+ Decoded from Pandemonium/Elevator (fields 2712 `pd_elv` / 2713 `pd_evd`). The real ride is a fully
5
+ SCRIPTED, control-locked carry -- NOT an engine attach (there is ZERO MoveTileLoop/AttachTile/SIM in
6
+ either field). The boarding region disables control and `RunScriptSync`s a function grafted onto the
7
+ PLAYER object (UID 250); that function moves the player frame-by-frame with `MoveInstantXZY` until he
8
+ reaches the destination height, then control returns:
9
+
10
+ - region (tag 2 tread "!" / tag 3 action): ``DisableMove ; RunScriptSync(2, 250, ride_tag) ; EnableMove``
11
+ - the ride func (``ride_tag``, on the PLAYER): a per-frame loop
12
+ ``{ scratch = selfY +/- step ; MoveInstantXZY(line_x, scratch, line_z) ; Wait(1) ;
13
+ while (selfY hasn't reached arrive) }`` then SetPathing(1) (or the optional fade+Field tail).
14
+
15
+ This is the kit's navigable ladder climb (:func:`ladder.navigable_climb_body`) MINUS the d-pad input
16
+ and the mount/dismount arcs: instead of reading the held direction each frame, the carry advances a
17
+ FIXED step toward the destination, so it is auto-driven and always terminates (a linear ride of
18
+ ~``duration`` frames). Every byte is emitted by the same proven primitives the climb uses (the ``_Asm``
19
+ label assembler, the ``0xA1`` expression-arg snap, the ``line()`` interpolation, the ladder flag).
20
+
21
+ selfY = -worldY (op78 field 1): a HIGHER physical position is a MORE-NEGATIVE selfY, so a lift going UP
22
+ (arrive above board) advances selfY in the NEGATIVE direction. The carry derives the step sign + the
23
+ arrival test from ``arrive.y`` vs ``board.y`` (they MUST differ -- a zero-height carry never moves).
24
+
25
+ v1 emits the CARRY only; the visible platform is the human's (paint a ride surface, or drive a placed
26
+ GEO model in lockstep -- a follow-up). docs: project memory `project-ff9-moving-platforms-elevators`.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import math
31
+ import struct
32
+
33
+ from ..eb import EbScript, edit, opcodes
34
+ from . import region as _region
35
+ from . import cutscene as _cutscene
36
+ from .ladder import _Asm, _arg, _const, _selfv, _stmt, F_Y, LADDER_FLAG, find_player_entry, square_zone
37
+
38
+ PLAYER_UID = 250 # the controlled player's runtime UID (standard across FF9 fields)
39
+ FIRST_PLATFORM_TAG = 56 # player ride funcs start here -- clear of ladder (17+) / jump (40+) climb tags,
40
+ # below the object-carry player band (64+); one tag per platform
41
+ RUNSCRIPT_LEVEL = 2 # the script level RunScriptSync uses (matches the real ladder/jump triggers)
42
+ PLATFORM_SCRATCH = 3 # MAP.I16[3]: this frame's stepped selfY target (transient per-field)
43
+ PLATFORM_START = 4 # MAP.I16[4]: the captured boarding selfY (the height the player rides FROM)
44
+ PLATFORM_START_X = 5 # MAP.I16[5]: the captured boarding world-X
45
+ PLATFORM_START_Z = 6 # MAP.I16[6]: the captured boarding world-Z
46
+ DEFAULT_DURATION = 32 # ride frames (for the relative `rise` mode -- linear, always terminates)
47
+ DEFAULT_SPEED = 30 # world-units/frame for the absolute `land` mode (ride duration = distance/speed)
48
+
49
+
50
+ def _scratch() -> bytes:
51
+ """This frame's stepped selfY target (MAP.I16[PLATFORM_SCRATCH]); transient per-field."""
52
+ return _region._push_var(_region.MAP_INT16, PLATFORM_SCRATCH)
53
+
54
+
55
+ def _scratch_start() -> bytes:
56
+ """The boarding selfY var (MAP.I16[PLATFORM_START]); captured from the player at ride start."""
57
+ return _region._push_var(_region.MAP_INT16, PLATFORM_START)
58
+
59
+
60
+ def _scratch_x() -> bytes:
61
+ """The boarding world-X var (MAP.I16[PLATFORM_START_X]); captured at ride start (`land` mode)."""
62
+ return _region._push_var(_region.MAP_INT16, PLATFORM_START_X)
63
+
64
+
65
+ def _scratch_z() -> bytes:
66
+ """The boarding world-Z var (MAP.I16[PLATFORM_START_Z]); captured at ride start (`land` mode)."""
67
+ return _region._push_var(_region.MAP_INT16, PLATFORM_START_Z)
68
+
69
+
70
+ def _carry_land_body(land, *, speed: int, animation: int | None,
71
+ warp_to: int | None, warp_entrance: int) -> bytes:
72
+ """Ride the player from WHEREVER he boards to the absolute landing point ``land`` = ``(x, z, y)``, at
73
+ ``speed`` world-units/frame, then re-attach to the walkmesh -- landing cleanly on the floor AT
74
+ ``land`` (no end-of-ride floor-snap warp). RELATIVE start (captures the boarding position, no
75
+ teleport-in) + ABSOLUTE end (the landing is a real floor). ``land`` must be ABOVE the boarding point
76
+ (the elevator rides UP -> selfY decreases). Used for inter-field-style lifts where you board at the
77
+ bottom and step off onto a higher floor elsewhere in the room."""
78
+ lx, lz = int(land[0]), int(land[1])
79
+ ly = int(land[2]) if len(land) > 2 else 0
80
+ lsy = -ly # landing selfY (= -worldY)
81
+ speed = max(1, int(speed))
82
+
83
+ def selfx(): return _selfv(0)
84
+ def selfz(): return _selfv(2)
85
+ def selfy(): return _selfv(F_Y)
86
+
87
+ def interp(c_start: bytes, target: int) -> bytes:
88
+ # c_start + (target - c_start) * (cur - csy) / (lsy - csy) -- linear, parameterised by selfY
89
+ return _arg(c_start,
90
+ _const(target), c_start, bytes([_region.T_MINUS]), # (target - c_start)
91
+ _scratch(), _scratch_start(), bytes([_region.T_MINUS]), # (cur - csy)
92
+ bytes([_region.T_MULT]),
93
+ _const(lsy), _scratch_start(), bytes([_region.T_MINUS]), # (lsy - csy)
94
+ bytes([_region.T_DIV]), bytes([_region.T_PLUS]))
95
+
96
+ a = _Asm()
97
+ a.raw(opcodes.add_character_attribute(LADDER_FLAG) + opcodes.set_pathing(0)) # grip + detach; NO teleport
98
+ if animation is not None:
99
+ a.raw(opcodes.run_animation(int(animation)))
100
+ # capture the boarding position (x, z, selfY) -- the ride interpolates FROM here
101
+ a.raw(_stmt(_scratch_x(), selfx(), bytes([_region.T_ASSIGN])))
102
+ a.raw(_stmt(_scratch_z(), selfz(), bytes([_region.T_ASSIGN])))
103
+ a.raw(_stmt(_scratch_start(), selfy(), bytes([_region.T_ASSIGN])))
104
+ a.label("LOOP")
105
+ a.raw(_stmt(_scratch(), selfy(), _const(speed), bytes([_region.T_MINUS]), bytes([_region.T_ASSIGN]))) # step UP
106
+ a.raw(opcodes.encode(0xA1, interp(_scratch_x(), lx), _arg(_scratch()), interp(_scratch_z(), lz), arg_flags=0b111))
107
+ a.raw(opcodes.wait(1))
108
+ a.raw(_stmt(selfy(), _const(lsy), bytes([_region.T_GT]))) # selfY still above the landing?
109
+ a.jmp(_region.JMP_TRUE, "LOOP")
110
+ a.raw(opcodes.encode(0xA1, _arg(_const(lx)), _arg(_const(lsy)), _arg(_const(lz)), arg_flags=0b111)) # exact landing
111
+ if warp_to is not None:
112
+ a.raw(opcodes.fade_filter(6, 24, 0, 255, 255, 255) + opcodes.wait(25)
113
+ + _region.set_field_entrance(int(warp_entrance))
114
+ + opcodes.field(int(warp_to)) + opcodes.terminate_entry(255))
115
+ else:
116
+ a.raw(opcodes.remove_character_attribute(LADDER_FLAG) + opcodes.set_pathing(1))
117
+ a.raw(opcodes.RETURN)
118
+ return a.assemble()
119
+
120
+
121
+ def _assemble_entry(funcs) -> bytes:
122
+ """Assemble a type-1 (region) entry from ``[(tag, body), ...]`` (the ladder/jump region layout):
123
+ the func table (``<tag:u16><fpos:u16>`` x N) then the concatenated bodies."""
124
+ table = b""
125
+ pos = len(funcs) * 4
126
+ for tag, body in funcs:
127
+ table += struct.pack("<HH", tag, pos)
128
+ pos += len(body)
129
+ return bytes([_region.REGION_ENTRY_TYPE, len(funcs)]) + table + b"".join(b for _, b in funcs)
130
+
131
+
132
+ def carry_body(*, rise: int | None = None, land=None, speed: int = DEFAULT_SPEED,
133
+ duration: int = DEFAULT_DURATION, animation: int | None = None,
134
+ warp_to: int | None = None, warp_entrance: int = 0) -> bytes:
135
+ """The player's ride function, run in the player's context (the region RunScriptSync's it) so the
136
+ moves move the PLAYER. Two modes:
137
+
138
+ * ``land = (x, z, y)`` -- ride from WHEREVER he boards to that absolute landing point (a real floor),
139
+ so he steps off cleanly (no end-of-ride floor-snap). For inter-field-style lifts: board at the
140
+ bottom, ride up, let off on a higher floor elsewhere. ``land`` must be ABOVE the boarding point.
141
+ * ``rise = <units>`` -- lift him ``rise`` world-units vertically (positive = up) from his current
142
+ height, keeping x/z. A pure in-place vertical lift (needs a real floor at the top, else he
143
+ floor-snaps back down when collision re-enables).
144
+
145
+ Both are RELATIVE at the start (capture the boarding position, no teleport-in -- an earlier absolute
146
+ board-snap warped him under a platform model). VISIBILITY is governed by the FIELD CAMERA, not the
147
+ carry: a vertical rise stays rendered only when the camera's depthOffset + shallow pitch map it into
148
+ screen-Y (not depth -- the [100,3996] psxDepth cull) and its vrp band is tall enough to scroll up;
149
+ fork an elevator-style scene+camera (e.g. Pandemonium 2713). With ``warp_to`` the ride ENDS in a
150
+ fade + ``Field()`` re-entry (an inter-floor elevator)."""
151
+ if land is not None:
152
+ return _carry_land_body(land, speed=speed, animation=animation,
153
+ warp_to=warp_to, warp_entrance=warp_entrance)
154
+ if rise is None:
155
+ raise ValueError("carry_body needs land=[x,z,y] (ride to a floor) or rise=<units> (vertical lift)")
156
+ rise = int(rise)
157
+ if rise == 0:
158
+ raise ValueError("carry_body: rise must be non-zero (positive = up) -- a zero ride never moves")
159
+ duration = max(1, int(duration))
160
+ smag = max(1, math.ceil(abs(rise) / duration)) # per-frame selfY step (terminates in ~duration frames)
161
+ up = rise > 0 # UP => selfY (= -worldY) DECREASES
162
+ step_tok = _region.T_MINUS if up else _region.T_PLUS
163
+ test_tok = _region.T_GT if up else _region.T_LT # loop while selfY hasn't reached the target
164
+
165
+ def selfx(): return _selfv(0) # obj field 0 = world X
166
+ def selfz(): return _selfv(2) # obj field 2 = world Z
167
+ def selfy(): return _selfv(F_Y) # obj field 1 = worldY-up (= -pos.y)
168
+
169
+ a = _Asm()
170
+ a.raw(opcodes.add_character_attribute(LADDER_FLAG) + opcodes.set_pathing(0)) # grip + detach; NO teleport
171
+ if animation is not None:
172
+ a.raw(opcodes.run_animation(int(animation))) # optional ride gesture (cosmetic; off by default)
173
+ # capture the boarding selfY, then the destination = start - rise (UP decreases selfY). Ride from there.
174
+ a.raw(_stmt(_scratch_start(), selfy(), bytes([_region.T_ASSIGN])))
175
+ a.raw(_stmt(_scratch(), _scratch_start(), _const(rise), bytes([_region.T_MINUS]), bytes([_region.T_ASSIGN])))
176
+ a.label("LOOP")
177
+ # step selfY one notch toward the target, keeping the player's current x/z (read live, written back)
178
+ a.raw(opcodes.encode(0xA1, _arg(selfx()),
179
+ _arg(selfy(), _const(smag), bytes([step_tok])),
180
+ _arg(selfz()), arg_flags=0b111))
181
+ a.raw(opcodes.wait(1)) # one ride frame (deterministic timing)
182
+ a.raw(_stmt(selfy(), _scratch(), bytes([test_tok]))) # selfY not yet at the target?
183
+ a.jmp(_region.JMP_TRUE, "LOOP")
184
+ a.raw(opcodes.encode(0xA1, _arg(selfx()), _arg(_scratch()), _arg(selfz()), arg_flags=0b111)) # exact final snap
185
+ if warp_to is not None: # ELEVATOR: ride then re-enter the destination floor
186
+ a.raw(opcodes.fade_filter(6, 24, 0, 255, 255, 255) + opcodes.wait(25)
187
+ + _region.set_field_entrance(int(warp_entrance))
188
+ + opcodes.field(int(warp_to)) + opcodes.terminate_entry(255))
189
+ else: # in-screen ride: land + hand control back
190
+ a.raw(opcodes.remove_character_attribute(LADDER_FLAG) + opcodes.set_pathing(1))
191
+ a.raw(opcodes.RETURN)
192
+ return a.assemble()
193
+
194
+
195
+ def platform_region(zone, ride_tag: int, *, trigger: str = "action", bubble: bool = True,
196
+ player_uid: int = PLAYER_UID) -> bytes:
197
+ """A type-1 region entry that boards the player onto the ride (func ``ride_tag`` on the player).
198
+
199
+ ``trigger="action"`` (default): Init ``SetRegion`` / tread ``Bubble(1)`` (if ``bubble``) / action
200
+ ``DisableMove; RunScriptSync(player, ride_tag); EnableMove`` -- press to board. ``trigger="tread"``:
201
+ the dispatch is on the tread func (auto-board on walk-in). Control is held for the whole ride
202
+ (synchronous ``RunScriptSync``) -- the same proven shape as the ladder/jump trigger."""
203
+ init = _region.set_region(zone) + opcodes.RETURN
204
+ dispatch = (opcodes.DISABLE_MOVE
205
+ + opcodes.run_script_sync(RUNSCRIPT_LEVEL, player_uid, ride_tag)
206
+ + opcodes.ENABLE_MOVE + opcodes.RETURN)
207
+ if trigger == "tread":
208
+ body = _region.MOVEMENT_GATE + (opcodes.bubble(1) if bubble else b"") + dispatch
209
+ funcs = [(0, init), (_region.RANGE_TAG, body)]
210
+ else: # "action" -- press-to-board (+ "!" prompt)
211
+ tread = _region.MOVEMENT_GATE + (opcodes.bubble(1) if bubble else b"") + opcodes.RETURN
212
+ action = _region.MOVEMENT_GATE + dispatch
213
+ funcs = [(0, init), (_region.RANGE_TAG, tread), (_region.INTERACT_TAG, action)]
214
+ return _assemble_entry(funcs)
215
+
216
+
217
+ def inject_platform(data, zone, *, rise: int | None = None, land=None, speed: int = DEFAULT_SPEED,
218
+ ride_tag: int = FIRST_PLATFORM_TAG,
219
+ duration: int = DEFAULT_DURATION, animation: int | None = None,
220
+ trigger: str = "action", bubble: bool = True, warp_to: int | None = None,
221
+ warp_entrance: int = 0, player_uid: int = PLAYER_UID, activate: bool = True):
222
+ """Inject one carry platform: graft the ride function (``ride_tag``) onto the player entry, append a
223
+ boarding region that fires it, and arm the region. Pass ``land=[x,z,y]`` (ride from the boarding spot
224
+ to that landing floor) or ``rise=<units>`` (vertical lift). Returns ``(new_bytes, region_slot)``. For
225
+ multiple platforms pass a distinct ``ride_tag`` each (start at :data:`FIRST_PLATFORM_TAG`)."""
226
+ body = carry_body(rise=rise, land=land, speed=speed, duration=duration, animation=animation,
227
+ warp_to=warp_to, warp_entrance=warp_entrance)
228
+ eb = EbScript.from_bytes(data)
229
+ pe = find_player_entry(eb)
230
+ data = edit.add_function(data, pe, ride_tag, body)
231
+ eb = EbScript.from_bytes(data)
232
+ slot = eb.first_free_slot()
233
+ data = edit.append_entry(data, slot, platform_region([tuple(p) for p in zone], ride_tag,
234
+ trigger=trigger, bubble=bubble, player_uid=player_uid))
235
+ if activate:
236
+ data = edit.activate(data, opcodes.init_region(slot, 0))
237
+ return data, slot
238
+
239
+
240
+ RIDE_WARMUP = 2 # tiny warm-up after control is handed; the rise then OVERLAPS the fade-in so the
241
+ # camera pans up AS the map fades in (the player is occluded down the hole until
242
+ # he emerges near the top -- the floor occlusion, not the fade, hides him)
243
+
244
+
245
+ def entry_rise_body(*, land, rise: int, duration: int = DEFAULT_DURATION, animation: int | None = None) -> bytes:
246
+ """The on-arrival elevator ride, ENTIRELY in the player's post-fade ride function (RunScriptSync
247
+ controls the player reliably here -- a drop spliced into the player Init does NOT stick, the engine
248
+ re-places him on the floor). One self-contained function:
249
+
250
+ detach -> MoveInstantXZY to the EXACT hole bottom (lx, -ly+rise, lz) -> per-frame rise loop that
251
+ pins x/z to (lx, lz) CONSTANTS and only decreases selfY toward the floor -> exact floor snap ->
252
+ re-attach (land flush).
253
+
254
+ Everything is ABSOLUTE (no stale-selfY capture -> no overshoot) and the rise is a plain decrement (NO
255
+ division -> the land-mode divide-by-(land-start) garbage that flung the player sideways cannot happen).
256
+ x/z are re-asserted every frame so he can never drift left/right."""
257
+ lx, lz = int(land[0]), int(land[1])
258
+ ly = int(land[2]) if len(land) > 2 else 0
259
+ rise = abs(int(rise))
260
+ floor_selfy = -ly # the let-off floor selfY (= -worldY)
261
+ bottom_selfy = -ly + rise # the hole bottom, `rise` below the floor
262
+ duration = max(1, int(duration))
263
+ smag = max(1, math.ceil(rise / duration)) # per-frame UP step (selfY decreases)
264
+
265
+ def selfy(): return _selfv(F_Y)
266
+
267
+ a = _Asm()
268
+ a.raw(opcodes.add_character_attribute(LADDER_FLAG) + opcodes.set_pathing(0)) # detach; don't floor-snap
269
+ a.raw(opcodes.encode(0xA1, _arg(_const(lx)), _arg(_const(bottom_selfy)), _arg(_const(lz)),
270
+ arg_flags=0b111)) # drop to the hole bottom (abs)
271
+ if animation is not None:
272
+ a.raw(opcodes.run_animation(int(animation)))
273
+ a.label("LOOP")
274
+ a.raw(opcodes.encode(0xA1, _arg(_const(lx)), # x pinned (no drift)
275
+ _arg(selfy(), _const(smag), bytes([_region.T_MINUS])), # selfY - step (rise up)
276
+ _arg(_const(lz)), arg_flags=0b111)) # z pinned
277
+ a.raw(opcodes.wait(1))
278
+ a.raw(_stmt(selfy(), _const(floor_selfy), bytes([_region.T_GT]))) # still below the floor?
279
+ a.jmp(_region.JMP_TRUE, "LOOP")
280
+ a.raw(opcodes.encode(0xA1, _arg(_const(lx)), _arg(_const(floor_selfy)), _arg(_const(lz)),
281
+ arg_flags=0b111)) # exact floor (abs)
282
+ a.raw(opcodes.remove_character_attribute(LADDER_FLAG) + opcodes.set_pathing(1)) # land flush on the floor
283
+ a.raw(opcodes.RETURN)
284
+ return a.assemble()
285
+
286
+
287
+ def inject_entry_rise(data, *, land, rise: int, ride_tag: int = FIRST_PLATFORM_TAG,
288
+ duration: int = DEFAULT_DURATION, animation: int | None = None,
289
+ player_uid: int = PLAYER_UID):
290
+ """The on-ARRIVAL elevator: graft the self-contained drop+rise (:func:`entry_rise_body`) onto the
291
+ player and arm a post-control ``InitCode`` trigger that spins until ``usercontrol == 1`` then runs it
292
+ synchronously. NO Init-drop splice (it doesn't stick) and NO dividing land-mode (it flung the player
293
+ sideways) -- the whole ride is the absolute, division-free, x/z-pinned function. Returns new bytes."""
294
+ lx, lz = int(land[0]), int(land[1])
295
+ ly = int(land[2]) if len(land) > 2 else 0
296
+ out = data if isinstance(data, (bytes, bytearray)) else data.to_bytes()
297
+ pe = find_player_entry(EbScript.from_bytes(out))
298
+ out = edit.add_function(out, pe, ride_tag,
299
+ entry_rise_body(land=(lx, lz, ly), rise=rise, duration=duration, animation=animation))
300
+ # post-control trigger: spin until usercontrol==1, a tiny warm-up, then run the ride synchronously
301
+ a = _Asm()
302
+ a.label("WAITCTL")
303
+ a.raw(opcodes.wait(1))
304
+ a.raw(_region.cond_sysvar_eq(2, 0)) # usercontrol still 0 (no control yet)?
305
+ a.jmp(_region.JMP_TRUE, "WAITCTL") # yes -> keep spinning (op_03 = backward-safe)
306
+ a.raw(opcodes.wait(RIDE_WARMUP)
307
+ + opcodes.DISABLE_MOVE
308
+ + opcodes.run_script_sync(RUNSCRIPT_LEVEL, player_uid, ride_tag)
309
+ + opcodes.ENABLE_MOVE + opcodes.RETURN)
310
+ entry = bytes([0x00, 0x01]) + struct.pack("<HH", 0, 4) + a.assemble()
311
+ slot = EbScript.from_bytes(out).first_free_slot()
312
+ out = edit.append_entry(out, slot, entry)
313
+ out = edit.activate(out, opcodes.init_code(slot, 0))
314
+ return out
@@ -0,0 +1,168 @@
1
+ """Player-function graft -- carry the donor field's player functions onto the fork player.
2
+
3
+ The next step after object carry (:mod:`content.object`): when a carried object's interactive function
4
+ ``RunScript``s a PLAYER tag >= 2 (e.g. the field-122 cask's tag-2 does ``RunScript(player, 24)`` -> the
5
+ player turns toward the cask), that tag doesn't exist on the blank fork player (tags ``[0, 1]`` only), so
6
+ the object graft drops it to ``init_only``. This module grafts those donor player functions onto the fork
7
+ player at a fresh tag band, the object's ``RunScript`` tag is remapped to the new tag (in
8
+ :func:`content.object.remap_entry_refs`), and -- the load-bearing catch -- the donor Init's animation-pack
9
+ loads are spliced so the grafted gestures' clips are actually loaded.
10
+
11
+ It GENERALIZES the one-function jump/ladder graft (:func:`content.jump.inject_jump` /
12
+ :func:`content.ladder.inject_ladder` each add ONE player function via :func:`eb.edit.add_function`) to N
13
+ functions, with:
14
+
15
+ * :class:`PlayerTagAllocator` -- a single next-free allocator across the ladder (17+), jump (40+) and
16
+ object (64+) bands, so the bands never collide regardless of count.
17
+ * :func:`remap_player_tag_calls` -- the intra-graft player->player tag remap (depth-0 in practice).
18
+ * :func:`ensure_player_anim_packs` -- the donor Init's ``RunModelCode`` pack-loads spliced into the fork
19
+ player Init.
20
+
21
+ Specs come from :func:`ff9mapkit.eventscan.scan_player_funcs` (only ``safety == "clean"`` funcs are
22
+ grafted; the rest leave their seeding object ``init_only``). Full recipe: ``docs/PLAYER_GRAFT.md``.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from .. import eventscan
27
+ from ..eb import EbScript, edit, opcodes
28
+ from ..eb.disasm import iter_code
29
+ from .jump import FIRST_JUMP_TAG
30
+ from .ladder import FIRST_CLIMB_TAG, find_player_entry
31
+ from .object import _arg_byte_offset
32
+
33
+ FIRST_OBJECT_PLAYER_TAG = 64 # the object-referenced player-func band (clear of ladder 17+, jump 40+)
34
+ DEFINE_PC_OP = 0x2C # DefinePlayerCharacter -- the splice point for the anim-pack prologue
35
+
36
+
37
+ class PlayerTagAllocator:
38
+ """Hands out fresh fork-player function tags, never colliding across the three graft bands
39
+ (ladder 17+ / jump 40+ / object 64+). Built from the fork player's existing tags (``{0, 1}`` on a
40
+ blank fork); :meth:`take` starts at the band floor and slides past any prior band's overflow, so a
41
+ field with > 24 jumps (which would push the jump band into 64) can't alias the object band. For every
42
+ in-budget field this returns exactly the fixed-counter tags the jump/ladder grafts use today (so their
43
+ in-game proofs + the hut golden are preserved); it only changes the previously-broken overflow case."""
44
+
45
+ FLOORS = {"ladder": FIRST_CLIMB_TAG, "jump": FIRST_JUMP_TAG, "object": FIRST_OBJECT_PLAYER_TAG}
46
+
47
+ def __init__(self, data):
48
+ eb = data if isinstance(data, EbScript) else EbScript.from_bytes(data)
49
+ self._used = {f.tag for f in eb.entry(find_player_entry(eb)).funcs}
50
+
51
+ def take(self, kind, n=1):
52
+ """``n`` fresh tags in ``kind``'s band ('ladder'|'jump'|'object'), none colliding with prior takes."""
53
+ t, out = self.FLOORS[kind], []
54
+ for _ in range(int(n)):
55
+ while t in self._used:
56
+ t += 1
57
+ self._used.add(t)
58
+ out.append(t)
59
+ t += 1
60
+ return out
61
+
62
+
63
+ def remap_player_tag_calls(body, tagmap) -> bytes:
64
+ """Site (b): within a grafted player function body, remap an intra-graft player->player ``RunScript``'s
65
+ tag arg (arg2) to its fork tag. Depth-0 in practice (the census found no object-path player func calls
66
+ another player tag), so this is a defensive same-length pass; function-relative jumps survive untouched."""
67
+ if not tagmap:
68
+ return bytes(body)
69
+ b = bytearray(body)
70
+ for ins in iter_code(bytes(b), 0, len(b)):
71
+ if ins.op in eventscan.RUNSCRIPT_OPS:
72
+ uid, tag = ins.imm(1), ins.imm(2)
73
+ if uid in (eventscan.UID_PLAYER, eventscan.UID_SELF) and tag in tagmap:
74
+ bo = _arg_byte_offset(ins, 2)
75
+ if bo is not None:
76
+ b[ins.off + bo] = tagmap[tag] & 0xFF
77
+ return bytes(b)
78
+
79
+
80
+ def ensure_player_anim_packs(data, packs) -> bytes:
81
+ """Splice the donor player Init's ``RunModelCode`` anim-pack loads into the fork player Init (after
82
+ ``DefinePlayerCharacter``), so a grafted gesture's clip is actually loaded -- the fork player otherwise
83
+ loads only the blank-field default pack, leaving a clip from another pack SILENTLY unloaded
84
+ (docs/PLAYER_GRAFT.md S4). ``packs`` are decoded ``RunModelCode`` arg tuples (from
85
+ :func:`ff9mapkit.eventscan.scan_player_funcs`), re-encoded byte-exact. De-duped + idempotent (skips
86
+ packs the fork Init already loads). Generalizes :func:`content.jump.ensure_jump_animation`."""
87
+ if not packs:
88
+ return data
89
+ eb = EbScript.from_bytes(data)
90
+ pe = find_player_entry(eb)
91
+ init = eb.entry(pe).func_by_tag(0)
92
+ if init is None:
93
+ return data
94
+ have = {tuple(ins.args) for ins in eb.instrs(init)
95
+ if ins.op == eventscan.RUN_MODEL_CODE_OP and not any(ins.arg_is_expr)}
96
+ block = b"".join(opcodes.encode(eventscan.RUN_MODEL_CODE_OP, *p)
97
+ for p in packs if tuple(p) not in have)
98
+ if not block:
99
+ return data
100
+ dpc = next((i for i in eb.instrs(init) if i.op == DEFINE_PC_OP), None)
101
+ rel = (dpc.end - init.abs_start) if dpc is not None else 0
102
+ return edit.insert_in_function(data, pe, 0, rel, block)
103
+
104
+
105
+ def graft_player_funcs(data, specs, tagmap, *, load=None, graftable_safeties=("clean",)) -> bytes:
106
+ """Graft each graftable player function verbatim onto the fork player at its fork tag
107
+ (``tagmap[donor_tag]``) via :func:`eb.edit.add_function` -- the N-function generalization of the
108
+ one-func jump/ladder graft. Splices the donor Init's anim packs first (so the gestures' clips load).
109
+ Non-graftable funcs are skipped (their seeding object stays ``init_only``). ``specs`` come from
110
+ :func:`ff9mapkit.eventscan.scan_player_funcs`; bodies are inline (``body``) or loaded via
111
+ ``load(spec["bin"])``. Returns the new ``.eb`` bytes (unchanged when nothing is graftable).
112
+
113
+ ``graftable_safeties`` (default ``("clean",)``) selects which safety classes are grafted; the
114
+ text-carry path passes ``("clean", "text")`` so a ``text`` player func (one whose window TXID the carry
115
+ is about to remap + ship) is also grafted -- its bytes are graft-safe once its text is carried. Default
116
+ is byte-identical to before (only ``clean`` funcs)."""
117
+ ok = set(graftable_safeties)
118
+ clean = [s for s in specs if s.get("safety") in ok and int(s["donor_tag"]) in tagmap]
119
+ if not clean:
120
+ return data
121
+ packs = []
122
+ for s in clean:
123
+ for p in s.get("donor_init_packs") or []:
124
+ if tuple(p) not in packs:
125
+ packs.append(tuple(p))
126
+ data = ensure_player_anim_packs(data, packs)
127
+ pe = find_player_entry(EbScript.from_bytes(data))
128
+ for s in clean:
129
+ body = s.get("body")
130
+ if body is None:
131
+ if load is None:
132
+ raise ValueError(f"player_func spec (tag {s.get('donor_tag')}) has no body and no loader")
133
+ body = load(s["bin"])
134
+ body = remap_player_tag_calls(bytes(body), tagmap)
135
+ data = edit.add_function(data, pe, tagmap[int(s["donor_tag"])], body)
136
+ return data
137
+
138
+
139
+ def remap_player_func_siblings(data, tagmap, slot_map) -> bytes:
140
+ """Post-graft pass: in each grafted player function, remap a CARRIED-sibling uid ref to that sibling's
141
+ fork slot. A grafted func may ``TurnTowardObject`` a carried sibling -- the save Moogle's funcs 13/14/15
142
+ each ``TurnTowardObject(<Moogle donor slot>)``; once the object graft has placed the Moogle at its fork
143
+ slot (``slot_map``: donor_idx -> fork_slot), rewrite the uid. Same-length 1-byte patch, exactly like
144
+ :func:`content.object.remap_entry_refs`. Only uids that are carried-object slots (in ``slot_map``) are
145
+ touched -- player/self/party uids are never in ``slot_map``. Runs AFTER both grafts so the map exists; a
146
+ no-op (byte-identical) without carried siblings (``slot_map`` empty / no matching ref)."""
147
+ if not slot_map or not tagmap:
148
+ return data
149
+ eb = EbScript.from_bytes(data)
150
+ pe = find_player_entry(eb)
151
+ fork_tags = set(tagmap.values())
152
+ b = bytearray(data)
153
+ for f in eb.entry(pe).funcs:
154
+ if f.tag not in fork_tags:
155
+ continue
156
+ for ins in eb.instrs(f):
157
+ spec = eventscan.REF_OPS.get(ins.op)
158
+ if not spec:
159
+ continue
160
+ for ai in spec.get("uid", ()):
161
+ if ai >= len(ins.arg_is_expr) or ins.arg_is_expr[ai]:
162
+ continue
163
+ v = ins.imm(ai)
164
+ if v is not None and int(v) in slot_map:
165
+ bo = _arg_byte_offset(ins, ai)
166
+ if bo is not None:
167
+ b[ins.off + bo] = slot_map[int(v)] & 0xFF
168
+ return bytes(b)