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,168 @@
1
+ r"""``[[item_text]]`` -- author an item's menu NAME + help/battle DESCRIPTION (the last items_equipment
2
+ piece). NOT a CSV: the text lives in the engine's localized DisplayBatch, set at startup from the text
3
+ bundles. The drop-in override channel is a **``TextPatch.txt``** at the mod-folder root -- the same
4
+ per-folder patch-file mechanism the kit already emits for ``DictionaryPatch.txt`` / ``BattlePatch.txt``.
5
+
6
+ THE ENGINE FORMAT (``Memoria.TextPatcher.PatchTexts`` + ``DataPatchers.Initialize``, both fully read):
7
+ ``TextPatch.txt`` is a line list of *patcher blocks* in four scopes by a leader line -- ``>DIALOG`` /
8
+ ``>BATTLE`` / ``>INTERFACE`` / **``>DATABASE``** (item name/desc = ``>DATABASE``). Within a block:
9
+ * ``[code=Condition] <NCalc> [/code]`` BEFORE any modifier -> the patcher-level Condition (which entry).
10
+ * ``FindAndReplace`` + ``Find: <regex>`` + ``Replace: <text>`` -> a find/replace modifier.
11
+ * ``// `` lines are skipped (comments) -> our ``//`` sentinel markers are inert (same as BattlePatch).
12
+ * ★ ``ApplyPatch`` applies only the FIRST matching modifier then ``return true`` -> emit ONE ``>DATABASE``
13
+ block per (item, field-kind) -- never stack modifiers.
14
+
15
+ THE CONDITION (``TextPatcher.PatchDatabaseString(str, databaseName, id, isName, isHelp)`` -> NCalc params):
16
+ ``Database`` (string), ``EntryId`` (int), ``IsNameEntry`` (bool), ``IsHelpEntry`` (bool). For ITEMS the
17
+ engine setters are (``FF9TextTool.cs:776-789``, VERIFIED):
18
+ * ``SetItemName(id,v)`` -> ``(v, "RegularItem", id, true, false)`` -> ``IsNameEntry``.
19
+ * ``SetItemHelpDesc(id,v)`` -> ``(v, "RegularItem", id, false, true )`` -> ``IsHelpEntry``.
20
+ * ``SetItemBattleDesc(id,v)``-> ``(v, "RegularItem", id, false, true )`` -> **also ``IsHelpEntry``**.
21
+ ★★ CONSTRAINT: the help-desc and the battle-desc are flagged IDENTICALLY -> via TextPatch they CANNOT be
22
+ targeted separately; one ``IsHelpEntry`` condition replaces BOTH. So v1 exposes ``display_name`` (the menu
23
+ name) + ``description`` (the help + battle text, together).
24
+
25
+ VALUE ENCODING (``Replace:`` is a .NET ``Regex.Replace`` replacement string, ``TextPatcher.cs:84,337``):
26
+ * Full-replace = ``Find: \A[\s\S]*\z`` (absolute start/end anchors; matches the whole base string ONCE,
27
+ Multiline-immune -- ``^[\s\S]*$`` would multi-match under ``RegexOptions.Multiline``).
28
+ * ★ ``$`` -> ``$$`` in the Replace text (``Regex.Replace`` reads ``$N``/``$&`` as group refs).
29
+ * The engine does ``Replace.Replace("\n", <newline>)`` -> a real newline in the author's text becomes the
30
+ two chars ``\n`` on the (single) ``Replace:`` line, which the engine turns back into a newline.
31
+
32
+ PROVENANCE: the kit writes ONLY the author's strings + the resolved item id -- it reads NOTHING from the
33
+ game bundles (unlike the CSV deltas, which carry a base row). RELAUNCH to apply (``DataPatchers.Initialize``
34
+ runs once at AssetManager bring-up, before the item text is imported -- F6 Reload won't re-read it).
35
+
36
+ [[item_text]]
37
+ name = "Potion" # the item to rename/redescribe (name or id; RegularItem space)
38
+ display_name = "Mega Potion" # optional: the menu name (IsNameEntry)
39
+ description = "Restores 15 HP." # optional: the help + battle description (IsHelpEntry, both)
40
+ # at least one of display_name / description is required.
41
+ """
42
+ from __future__ import annotations
43
+
44
+ from .. import items as _items
45
+
46
+ DATABASE_NAME = "RegularItem" # typeof(RegularItem).Name -- the Database for a normal item
47
+ FULL_REPLACE_FIND = r"\A[\s\S]*\z" # .NET absolute anchors: replace the WHOLE base string, once
48
+ NO_ITEM = 255 # RegularItem.NoItem -- the empty-slot sentinel, never a real item
49
+
50
+
51
+ class ItemTextError(ValueError):
52
+ pass
53
+
54
+
55
+ def _escape_replace(text) -> str:
56
+ """Sanitise one author string into a single ``Replace:`` value: ``$`` -> ``$$`` (else ``Regex.Replace``
57
+ reads it as a group ref), real newlines -> the literal two chars ``\\n`` (the engine reads the patch
58
+ line-by-line then converts ``\\n`` back to a newline -> a multi-line description survives on one line).
59
+
60
+ Rejects a LITERAL backslash-n in the author text: this channel reserves ``\\n`` for a line break (the
61
+ engine unconditionally rewrites ``\\n`` -> newline, with no escape and no ``\\\\`` un-doubling), so a
62
+ literal ``\\n`` cannot be represented and would silently become an unintended line break in-game. Better
63
+ to fail the build than to corrupt the text the human can't see (the kit's offline-fail rule)."""
64
+ if not isinstance(text, str):
65
+ raise ItemTextError(f"text must be a string, got {type(text).__name__}")
66
+ if "\\n" in text:
67
+ raise ItemTextError(
68
+ "text contains a literal backslash-n (\\n) -- this channel reserves \\n for a line break (the "
69
+ "engine rewrites \\n to a newline) and cannot show it literally. Use a real line break for a new "
70
+ "line, and remove the literal \\n.")
71
+ out = text.replace("$", "$$")
72
+ return out.replace("\r\n", "\\n").replace("\r", "\\n").replace("\n", "\\n")
73
+
74
+
75
+ def _condition(item_id: int, flag: str) -> str:
76
+ """The NCalc patcher condition for one item entry. ``flag`` is ``IsNameEntry`` (the menu name) or
77
+ ``IsHelpEntry`` (the help+battle description)."""
78
+ return f"Database == '{DATABASE_NAME}' && EntryId == {item_id} && {flag} == true"
79
+
80
+
81
+ def _block(item_id: int, flag: str, text) -> list[str]:
82
+ """One ``>DATABASE`` patcher: a condition (which entry) + a full-replace of its text. The ``[code=Condition]``
83
+ precedes ``FindAndReplace`` so it binds at the PATCHER level (gates the whole block)."""
84
+ return [
85
+ ">DATABASE",
86
+ f"[code=Condition] {_condition(item_id, flag)} [/code]",
87
+ "FindAndReplace",
88
+ f"Find: {FULL_REPLACE_FIND}",
89
+ f"Replace: {_escape_replace(text)}",
90
+ ]
91
+
92
+
93
+ def render_block_lines(text_blocks) -> list[str]:
94
+ """``[[item_text]]`` blocks -> the ``>DATABASE`` patch lines (no markers). Pure + offline: the item name
95
+ resolves via :func:`items.resolve` (RegularItem space); the strings are the author's. Raises
96
+ :class:`ItemTextError` so a bad block fails the lint/build, never the running game.
97
+
98
+ Per item: a ``display_name`` -> an ``IsNameEntry`` block; a ``description`` -> an ``IsHelpEntry`` block
99
+ (which the engine applies to BOTH the menu-help and the battle description -- they share the flag)."""
100
+ lines: list[str] = []
101
+ for n, b in enumerate(text_blocks or []):
102
+ if not isinstance(b, dict):
103
+ raise ItemTextError(f"[[item_text]] #{n} must be a table (e.g. {{ name = \"Potion\", "
104
+ f"display_name = \"Mega Potion\" }})")
105
+ name = b.get("name")
106
+ if name is None:
107
+ raise ItemTextError(f"[[item_text]] #{n} needs a `name` (the item to rename/redescribe)")
108
+ try:
109
+ item_id = _items.resolve(name)
110
+ except (ValueError, TypeError) as ex:
111
+ raise ItemTextError(f"[[item_text]] {name!r}: {ex}") from ex
112
+ if item_id == NO_ITEM: # mirror the [[shop]]/[[synthesis]] NoItem guards
113
+ raise ItemTextError(f"[[item_text]] {name!r} resolves to NoItem ({NO_ITEM}), the empty-slot "
114
+ f"sentinel -- it is not a real item, pick one to retext")
115
+ disp, desc = b.get("display_name"), b.get("description")
116
+ if disp is None and desc is None:
117
+ raise ItemTextError(f"[[item_text]] {name!r} sets neither display_name nor description "
118
+ f"(give at least one)")
119
+ try:
120
+ if disp is not None:
121
+ lines += _block(item_id, "IsNameEntry", disp)
122
+ if desc is not None:
123
+ lines += _block(item_id, "IsHelpEntry", desc)
124
+ except ItemTextError as ex:
125
+ raise ItemTextError(f"[[item_text]] {name!r} {ex}") from ex
126
+ return lines
127
+
128
+
129
+ def validate_blocks(text_blocks) -> list[str]:
130
+ """Offline structural validation (for ``lint`` -- no install needed): re-run the emission and surface the
131
+ first :class:`ItemTextError` as a message (empty list => OK)."""
132
+ try:
133
+ render_block_lines(text_blocks)
134
+ except ItemTextError as ex:
135
+ return [str(ex)]
136
+ return []
137
+
138
+
139
+ # ---- non-clobbering merge into a live TextPatch.txt (deploy) -----------------------------------------
140
+ def _markers(field_id):
141
+ return (f"// >>> ff9mapkit field {field_id} TextPatch (auto -- edit the field.toml, not here)",
142
+ f"// <<< ff9mapkit field {field_id}")
143
+
144
+
145
+ def merge_text_patch(live_text: str, block_lines, field_id) -> str:
146
+ """Splice ``block_lines`` into ``live_text`` between this field's ``//`` sentinel markers, REPLACING any
147
+ prior block for the same id and PRESERVING every other line (another field's item text, a stacked
148
+ worktree's lines). The engine skips ``//`` lines, so the markers are inert. Empty ``block_lines`` just
149
+ strips our prior block (a redeploy after the toml's [[item_text]] was removed). Idempotent. Mirrors
150
+ :func:`ff9mapkit.battle.battlepatch.merge_battle_patch`."""
151
+ begin, end = _markers(field_id)
152
+ kept, skip = [], False
153
+ for ln in live_text.splitlines():
154
+ if ln.strip() == begin:
155
+ skip = True
156
+ continue
157
+ if ln.strip() == end:
158
+ skip = False
159
+ continue
160
+ if not skip:
161
+ kept.append(ln)
162
+ while kept and not kept[-1].strip(): # trim trailing blank lines before re-appending
163
+ kept.pop()
164
+ block = [ln for ln in (block_lines or []) if ln.strip()]
165
+ out = list(kept)
166
+ if block:
167
+ out += [begin, *block, end]
168
+ return ("\n".join(out) + "\n") if out else ""
@@ -0,0 +1,114 @@
1
+ """Jump-navigation primitive -- FF9's ledge/gap jumps (Ice Cavern, etc.), the navigable cousin of the
2
+ ladder. Decoded byte-for-byte from Ice Cavern/Hall (field 301):
3
+
4
+ - a JUMP REGION entry (one per ledge): Init ``SetRegion(zone)`` / tread (tag 2) ``Bubble(1)`` (the
5
+ floating "!" prompt) / action (tag 3) ``DisableMove; RunScriptSync(player, <jump_tag>); EnableMove``.
6
+ - the player's JUMP-ARC function (``jump_tag``): runs in the player's OWN context (so it moves the
7
+ PLAYER), a verbatim ``TurnTowardPosition; RunJumpAnimation; SetupJump(x,y,z,steps); Jump;
8
+ RunLandAnimation; ...; SetPathing(1)`` arc -- the EXACT, perspective-tuned world coords (they trace
9
+ the painted ledge through the fixed camera, so they can only be COPIED, never regenerated -- same
10
+ truth as ladder climb arcs).
11
+
12
+ Two trigger styles exist in the real game and both are supported:
13
+ * ``action`` (Ice Cavern 301): walk to the ledge -> "!" prompt -> press the action button to jump.
14
+ * ``tread`` (e.g. field 402): the jump auto-fires the moment you walk into the zone (no prompt).
15
+
16
+ Why the SAME region/RunScriptSync shape as a ladder (not a region->flag->player-loop scheme): while
17
+ ``usercontrol == 1`` the controlled player's script loop is NOT stepped, so the region must call the
18
+ player's jump arc DIRECTLY via ``RunScriptSync`` (exactly what the real game does). This module is the
19
+ ladder mechanism minus the climb semantics (no ladder flag, no hold-to-climb loop) -- a one-shot arc.
20
+
21
+ The arc's ``RunJumpAnimation`` plays whatever ``SetJumpAnimation`` last set; the blank-field player
22
+ (the fork's player) is always Zidane (model 98,93 -- same as the real jump fields), so we splice
23
+ ``SetJumpAnimation(10447, 4, 14)`` (Zidane's jump clip, from the real player Init) into the player
24
+ Init once, and every grafted arc animates correctly.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import struct
29
+
30
+ from ..eb import EbScript, edit, opcodes
31
+ from . import region as _region
32
+ from .ladder import find_player_entry
33
+
34
+ PLAYER_UID = 250 # the controlled player's runtime UID (referenced regardless of entry index)
35
+ FIRST_JUMP_TAG = 40 # player jump-arc funcs start here -- clear of the ladder climb tags (17+)
36
+ RUNSCRIPT_LEVEL = 2 # the script level RunScriptSync uses (matches the real jump/ladder triggers)
37
+ SET_JUMP_ANIM_OP = 0x94 # SetJumpAnimation(anim, a, b)
38
+ JUMP_ANIM_DEFAULT = (10447, 4, 14) # Zidane's jump clip + in/out frames (real Ice Cavern player Init)
39
+
40
+
41
+ def _assemble_entry(funcs) -> bytes:
42
+ """Assemble a type-1 (region) entry from ``[(tag, body), ...]`` -- the func table (4 bytes/func:
43
+ ``<tag:u16><fpos:u16>``) then the concatenated bodies. Same layout as ladder_region."""
44
+ table = b""
45
+ pos = len(funcs) * 4
46
+ for tag, body in funcs:
47
+ table += struct.pack("<HH", tag, pos)
48
+ pos += len(body)
49
+ return bytes([_region.REGION_ENTRY_TYPE, len(funcs)]) + table + b"".join(b for _, b in funcs)
50
+
51
+
52
+ def jump_region(zone, jump_tag: int, *, trigger: str = "action", bubble: bool = True,
53
+ player_uid: int = PLAYER_UID) -> bytes:
54
+ """A type-1 region entry that fires the player's jump arc (func ``jump_tag``).
55
+
56
+ ``trigger="action"`` (default, Ice Cavern style): Init ``SetRegion`` / tread ``Bubble(1)`` (if
57
+ ``bubble``) / action ``DisableMove; RunScriptSync(player, jump_tag); EnableMove`` -- press to jump.
58
+ ``trigger="tread"``: the dispatch is on the tread func (auto-jump on walk-in); an optional ``!``.
59
+ The dispatch is SYNCHRONOUS (``RunScriptSync``) so player control is held for the duration of the
60
+ arc, then restored."""
61
+ init = _region.set_region(zone) + opcodes.RETURN
62
+ dispatch = (opcodes.DISABLE_MOVE
63
+ + opcodes.run_script_sync(RUNSCRIPT_LEVEL, player_uid, jump_tag)
64
+ + opcodes.ENABLE_MOVE + opcodes.RETURN)
65
+ if trigger == "tread":
66
+ body = _region.MOVEMENT_GATE
67
+ if bubble:
68
+ body += opcodes.bubble(1)
69
+ body += dispatch
70
+ funcs = [(0, init), (_region.RANGE_TAG, body)]
71
+ else: # "action" -- press-to-jump (+ "!" prompt)
72
+ tread = _region.MOVEMENT_GATE + (opcodes.bubble(1) if bubble else b"") + opcodes.RETURN
73
+ action = _region.MOVEMENT_GATE + dispatch
74
+ funcs = [(0, init), (_region.RANGE_TAG, tread), (_region.INTERACT_TAG, action)]
75
+ return _assemble_entry(funcs)
76
+
77
+
78
+ def ensure_jump_animation(data, anim=JUMP_ANIM_DEFAULT):
79
+ """Splice ``SetJumpAnimation(*anim)`` into the player Init (once), so the grafted arcs'
80
+ ``RunJumpAnimation`` plays the right clip. No-op if the player Init already sets a jump animation
81
+ (e.g. a field that carries its own). Spliced right after ``DefinePlayerCharacter`` (jump-safe, the
82
+ proven re-entry-spawn splice point)."""
83
+ eb = EbScript.from_bytes(data)
84
+ pe = find_player_entry(eb)
85
+ init = eb.entry(pe).func_by_tag(0)
86
+ if init is None:
87
+ raise ValueError("player entry has no Init (tag 0); cannot set the jump animation")
88
+ if any(ins.op == SET_JUMP_ANIM_OP for ins in eb.instrs(init)):
89
+ return data # already sets a jump anim -- leave it
90
+ dpc = next((i for i in eb.instrs(init) if i.op == 0x2C), None) # DefinePlayerCharacter
91
+ rel = (dpc.end - init.abs_start) if dpc is not None else 0 # after DPC, else prepend
92
+ return edit.insert_in_function(data, pe, 0, rel, opcodes.set_jump_animation(*anim))
93
+
94
+
95
+ def inject_jump(data, zone, jump_bytes: bytes, *, jump_tag: int = FIRST_JUMP_TAG,
96
+ trigger: str = "action", bubble: bool = True, player_uid: int = PLAYER_UID,
97
+ activate: bool = True):
98
+ """Inject one navigable jump: graft the verbatim jump-arc ``jump_bytes`` onto the player entry as
99
+ func ``jump_tag``, append a jump region that fires it, and arm the region. Returns
100
+ ``(new_bytes, region_slot)``. For multiple jumps pass a distinct ``jump_tag`` each.
101
+
102
+ ``jump_bytes`` is a real jump arc extracted verbatim by ``eventscan.scan_jumps`` (exact,
103
+ perspective-correct world coords); it's grafted as-is. Pair with :func:`ensure_jump_animation`
104
+ once per field so the arc's ``RunJumpAnimation`` has a clip."""
105
+ eb = EbScript.from_bytes(data)
106
+ pe = find_player_entry(eb)
107
+ data = edit.add_function(data, pe, jump_tag, bytes(jump_bytes))
108
+ eb = EbScript.from_bytes(data)
109
+ slot = eb.first_free_slot()
110
+ data = edit.append_entry(data, slot, jump_region([tuple(p) for p in zone], jump_tag,
111
+ trigger=trigger, bubble=bubble, player_uid=player_uid))
112
+ if activate:
113
+ data = edit.activate(data, opcodes.init_region(slot, 0))
114
+ return data, slot