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,290 @@
1
+ """Cutscenes -- ordered, control-locked scripted sequences.
2
+
3
+ This is the one thing the declarative content (NPCs / events / flags) can't express: a SEQUENCE that
4
+ runs in order. A cutscene runs its actions in order with the player's control disabled for the
5
+ duration, optionally once (flag-gated), triggered on field entry.
6
+
7
+ There are two flavours, by whether the cutscene names an ``actor``:
8
+
9
+ * **Narration (v1, no actor)** -- a standalone code entry whose function steps through *controller-
10
+ level* actions that need no per-actor targeting: ``say`` (a dialogue/narration window),
11
+ ``wait`` (pause N frames), ``set_flag`` (set a story flag). Triggered on load via ``InitCode``.
12
+
13
+ * **Actor cutscene (v2, ``actor = "<npc>"``)** -- the sequence is spliced into THAT NPC's Init (see
14
+ :func:`build_choreography`, used by :func:`ff9mapkit.content.npc.inject_npc` via its ``intro=``),
15
+ so it runs in the NPC's own object context (``gExec`` == the NPC). That lets the *actor* steps work
16
+ with plain base opcodes that act on the executing object: ``walk`` / ``teleport`` (MoveInstantXZY)
17
+ / ``animation`` (RunAnimation+WaitAnimation) / ``turn`` (TimedTurn+WaitTurn) / ``face_player``
18
+ (TurnTowardObject 250). ``say`` / ``wait`` / ``set_flag`` work there too (they're global). No
19
+ cross-entry RunScript or UID targeting is needed -- and ``Walk`` self-blocks until arrival, so the
20
+ steps stay ordered. The block is ``if (!once) { DisableMove; <steps>; EnableMove; once=1 }``.
21
+
22
+ Both grounded in the standard FF9 pattern -- ``DisableMove`` (0x2D) ... actions ... ``EnableMove``
23
+ (0x2E), flag-gated so a one-time scene doesn't replay -- and in real walk cutscenes (e.g. Gargan
24
+ Roo's Kuja walk function: SetWalkSpeed -> RunAnimation -> WaitAnimation -> InitWalk -> Walk).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import struct
30
+
31
+ from ..eb import EbScript, edit, opcodes
32
+ from . import region as _region
33
+ from . import event as _event
34
+
35
+ # Default flag for a "play once" cutscene: the SAVE-PERSISTENT Global bool (survives reloads), high in
36
+ # gEventGlobal and clear of the event auto-once band (8000+).
37
+ CUTSCENE_FLAG_CLASS = _region.GLOB_BOOL
38
+ DEFAULT_CUTSCENE_FLAG = 8100 # GLOB (save-persistent) once-flag: plays once EVER
39
+ DEFAULT_CUTSCENE_MAP_FLAG = 80 # MAP-bit (transient, byte 10 -- clear of the field's init bits 144-159
40
+ # and the camera Map-byte 24): replays each visit, still once per visit
41
+
42
+ PLAYER_UID = 250 # GetObjUID(250) resolves to the player's control character (engine convention)
43
+
44
+ # A field's Main_Init enables control then runs a ~16-frame entry FadeFilter; for the first frames
45
+ # the field is still fading + the smooth-frame-updater is settling actor positions. Issuing an actor
46
+ # Walk during that window makes the actor circle and never converge (its synchronous Walk then hangs
47
+ # -> softlock). So an ACTOR cutscene waits a warm-up before commanding the actor -- exactly what real
48
+ # entry cutscenes do (Main_Loop `Wait(...)` before RunScript). Tunable via `[cutscene] warmup = N`.
49
+ DEFAULT_WARMUP = 30 # frames (~1s @ 30fps); generous margin over the 16-frame entry fade
50
+
51
+
52
+ # A compulsory / auto-advance ATE (FF9's FORCED "Active Time Event" cutscene -- no menu, plays at a story
53
+ # beat, e.g. field 956 Gargant / the Festival-of-the-Hunt cluster) is an ordinary cutscene with two cosmetic
54
+ # ATE flourishes, both grounded byte-for-byte in the real grey mode-6 fields (`docs/ATE_SYSTEM.md` Flavor A):
55
+ # * its dialogue windows carry the winATE caption flag (64) -> the "Active Time Event" header. This flag
56
+ # is ALSO what makes the engine tag the closed dialog `isCompulsory` (ETb.ProcessATEDialog) -- the
57
+ # defining, engine-recognized marker of a compulsory ATE.
58
+ # * the body is bracketed `ATE(6) ... ATE(0)` (0xD7) -- the grey-unskippable HUD-icon arm (Gray+force; the
59
+ # mode arg is a 3-bit flag word, not an enum -- &3==2 Gray, &4 force; see EIcon.cs:416-454 / ATE_SYSTEM.md).
60
+ # (NB field 1901's Eiko ATE is the OPTIONAL Blue mode-1 menu hub, NOT this forced flavor -- don't mirror it.)
61
+ # TWO real templates for an auto-playing ATE (676-field byte sweep + the grey-unskippable re-classification,
62
+ # docs/ATE_SYSTEM.md):
63
+ # * ate_mode = 6 (GREY + force-show) = the AUTHENTIC UNSKIPPABLE ATE and the DEFAULT -- the real game's forced
64
+ # ATEs (field 956, the Festival-of-the-Hunt cluster) use ATE(6): a grey, force-shown icon that renders even
65
+ # under the control-lock. It drives the bottom-left "ACTIVE TIME EVENT" HUD banner (ActiveTimeEvent.cs), whose
66
+ # grey "ATE" sprite blinks 1s on / 1s off (DisplayGrayATEText) and shows NO press glyph. ★ in-game proven @30008.
67
+ # * ate_mode = 1 (Blue, no force) = the opt-in quiet no-icon variant: mode 1's render gate
68
+ # (`mode>0 && ((mode&4) || GetUserControl())`) FAILS under the control-lock, so no HUD banner shows -- the
69
+ # winATE CAPTION window is the only marker (also proven @30008, before the mode-6 switch).
70
+ # AVOID ate_mode = 5 (Blue + force): a force-shown Blue icon re-flashes the "Press SELECT" glyph (the Blue
71
+ # coroutine), wrongly inviting a press during an auto-play. mode 2 is unused in the real game; the only grey is 6.
72
+ # (NB the kit holds ATE(6) armed across the whole body so the grey banner blinks throughout -- more legible than
73
+ # real 956, which clears it behind a white fade-in; this matches what players remember seeing.)
74
+ # Seen-state + the ATE80 trophy register only on a REAL field id (MappingATEID keyed on fldMapNo/SC) -- the wall.
75
+ # Mirrors `ate.WIN_ATE`; kept local to avoid importing the ate module (which imports choice -> region).
76
+ ATE_CAPTION_FLAG = 64
77
+ ATE_DEFAULT_MODE = 6 # ATE(mode) HUD arm. 6 = the authentic GREY UNSKIPPABLE banner (default, in-game proven
78
+ # @30008); 1 = opt-in quiet no-icon variant. Avoid 5 (Blue force-show re-flashes press glyph)
79
+
80
+
81
+ def say(text_id: int, *, window: int = 1, flags: int = 128) -> bytes:
82
+ """Step: open a dialogue/narration window showing ``text_id`` (blocks until the player dismisses).
83
+ ``flags = 64`` (winATE) renders it with the "Active Time Event" caption (a compulsory-ATE window)."""
84
+ return opcodes.window_sync(window, flags, text_id)
85
+
86
+
87
+ def wait(frames: int) -> bytes:
88
+ """Step: pause for ``frames`` frames."""
89
+ return opcodes.wait(frames)
90
+
91
+
92
+ def set_flag(idx: int, value: int = 1, *, flag_class=CUTSCENE_FLAG_CLASS) -> bytes:
93
+ """Step: set a GlobBool story flag (advance/record state from within the scene)."""
94
+ return _region.set_var(flag_class, idx, value)
95
+
96
+
97
+ # --- actor-context steps (v2) -- only valid inside an `actor` cutscene (run in the NPC's entry) ---
98
+ # How fast the actor rotates toward its destination while walking (omega, 0..255). High = the
99
+ # turn-while-walk arc shrinks to ~nothing, so a walk to a point BEHIND the actor turns and goes
100
+ # straight instead of orbiting it forever. This replaces a separate animated pre-turn
101
+ # (TurnTowardPosition/TimedTurn + WaitTurn), which can HANG at ~180deg (the animated big-turn path
102
+ # never completing -> WaitTurn stuck -> softlock). Self-converging + deterministic at exactly 180.
103
+ WALK_TURN_SPEED = 255
104
+
105
+
106
+ def actor_walk(x: int, z: int, speed: int | None = None) -> bytes:
107
+ """Step: the actor walks to world (x, z).
108
+
109
+ Sets a high walk-turn-speed first so the Walk rotates tightly toward the destination and walks
110
+ straight (no arc), converging even when the target is directly BEHIND the actor -- without the
111
+ animated pre-turn that hangs at ~180deg. ``StopAnimation`` clears the anim flags first so the
112
+ engine actually swaps idle->walk while moving (else a player-cloned NPC glides in its idle pose).
113
+ ``Walk`` blocks until arrival. Optional ``speed`` sets the walk movement speed. Uses the NPC's
114
+ walk animation (set in its Init)."""
115
+ pre = opcodes.set_walk_speed(int(speed)) if speed is not None else b""
116
+ return (pre + opcodes.set_walk_turn_speed(WALK_TURN_SPEED) + opcodes.stop_animation()
117
+ + opcodes.init_walk() + opcodes.walk(int(x), int(z)))
118
+
119
+
120
+ def actor_teleport(x: int, z: int) -> bytes:
121
+ """Step: instantly move the actor to world (x, z) -- no walk animation -- then re-enable its
122
+ walkmesh pathing (MoveInstantXZY disables it). Use it as a cutscene's FIRST step to place the
123
+ actor off-screen for a walk-in (the kit handles the engine's POS3 Z-negation; a leading teleport
124
+ runs before the warm-up so the actor settles off-screen rather than flashing at its spawn)."""
125
+ return opcodes.move_instant_xzy(int(x), int(z), 0) + opcodes.set_pathing(1)
126
+
127
+
128
+ # Cutscene steps are NON-BLOCKING on the animation system: we never use WaitAnimation/WaitTurn,
129
+ # because they HANG if the actor's anim playback doesn't drive them to completion (a player-cloned
130
+ # NPC's walk/turn anims don't always engage -> WaitTurn/WaitAnimation never return -> softlock). A
131
+ # turn is done INSTANTLY (no turn anim needed); an animation is played then given a fixed hold.
132
+ ANIM_HOLD = 40 # frames to let a played animation run before the next step (~1.3s)
133
+
134
+
135
+ def actor_animation(anim: int, hold: int = ANIM_HOLD) -> bytes:
136
+ """Step: play animation ``anim`` on the actor, then hold ``hold`` frames (RunAnimation + a fixed
137
+ Wait -- NOT WaitAnimation, which hangs if the anim doesn't complete)."""
138
+ return opcodes.run_animation(int(anim)) + opcodes.wait(int(hold))
139
+
140
+
141
+ def actor_turn(angle: int) -> bytes:
142
+ """Step: face ``angle`` INSTANTLY (0=south, 64=west, 128=north, 192=east). Instant (TurnInstant) so
143
+ it works without a turn animation and never hangs."""
144
+ return opcodes.turn_instant(int(angle))
145
+
146
+
147
+ def actor_face(uid: int = PLAYER_UID, speed: int = 16) -> bytes:
148
+ """Step: turn the actor to face an object by UID (default 250 = the player), animated, non-blocking
149
+ (no WaitTurn). Visible only if the turn anim engages; for a guaranteed instant facing use ``turn``."""
150
+ return opcodes.turn_toward_object(int(uid), int(speed))
151
+
152
+
153
+ def compile_steps(steps, txids, *, say_flags: int = 128) -> bytes:
154
+ """Compile ordered cutscene step dicts to bytes. Handles global steps (``say`` / ``wait`` /
155
+ ``set_flag``) and actor-context steps (``walk`` / ``path`` / ``teleport`` / ``animation`` /
156
+ ``turn`` / ``face_player``). ``say`` steps consume ``txids`` (a list of resolved text ids) in order.
157
+
158
+ Actor steps are only meaningful inside an ``actor`` cutscene (they act on the executing object);
159
+ :func:`ff9mapkit.build.validate` enforces that. ``say_flags`` is the window flag for every ``say``
160
+ step -- pass ``ATE_CAPTION_FLAG`` (64) to render a compulsory ATE's windows with the ATE caption.
161
+ Same encoders the round-trip tests cover."""
162
+ out, ti = [], 0
163
+ for s in steps:
164
+ if "say" in s:
165
+ out.append(say(txids[ti], flags=say_flags)); ti += 1
166
+ elif "wait" in s:
167
+ out.append(wait(int(s["wait"])))
168
+ elif "set_flag" in s:
169
+ sf = s["set_flag"]
170
+ out.append(set_flag(int(sf[0]), int(sf[1]) if len(sf) > 1 else 1))
171
+ elif "walk" in s:
172
+ out.append(actor_walk(s["walk"][0], s["walk"][1], s.get("speed")))
173
+ elif "path" in s: # a multi-waypoint route = consecutive straight walks
174
+ for pt in s["path"]:
175
+ out.append(actor_walk(int(pt[0]), int(pt[1])))
176
+ elif "teleport" in s:
177
+ out.append(actor_teleport(s["teleport"][0], s["teleport"][1]))
178
+ elif "animation" in s:
179
+ out.append(actor_animation(s["animation"]))
180
+ elif "turn" in s:
181
+ out.append(actor_turn(s["turn"]))
182
+ elif "face_player" in s:
183
+ out.append(actor_face())
184
+ else:
185
+ raise ValueError(f"unknown cutscene step: {s!r}")
186
+ return b"".join(out)
187
+
188
+
189
+ def once_flag_for(cs: dict):
190
+ """(flag_class, flag_idx) for a cutscene's gate. ``once=true`` -> a SAVE-PERSISTENT Global bool
191
+ (plays once ever); ``once=false`` -> a TRANSIENT Map bool (replays each visit -- the Map var resets
192
+ on field load -- but still runs once per visit). An explicit ``flag = N`` overrides the index."""
193
+ if cs.get("once", True):
194
+ return _region.GLOB_BOOL, int(cs.get("flag", DEFAULT_CUTSCENE_FLAG))
195
+ return _region.MAP_BOOL, int(cs.get("flag", DEFAULT_CUTSCENE_MAP_FLAG))
196
+
197
+
198
+ def build_choreography(steps, txids, flag_idx: int, *, flag_class=CUTSCENE_FLAG_CLASS,
199
+ warmup: int = DEFAULT_WARMUP, ate_mode: int | None = None,
200
+ say_flags: int = 128) -> bytes:
201
+ """The gated choreography block, PREPENDED to the actor NPC's LOOP (tag 1) -- NOT its Init -- by
202
+ :func:`ff9mapkit.content.npc.inject_npc`. Runs in the NPC's own context (so the actor steps target
203
+ it) AND while the object is 'running' (engine state 1), where the engine ADVANCES animation frames.
204
+ (An Init runs at state 2, where ProcessAnime is skipped -> the model glides frozen; confirmed by an
205
+ in-engine probe -- so the choreography must live in the loop, like real FF9 cutscenes.)
206
+
207
+ Shape: ``if (!flag) { DisableMove; Wait(warmup); <steps>; EnableMove; flag=1 }`` (no trailing RETURN
208
+ -- the loop body + its RETURN follow). ALWAYS gated -- the loop runs every frame, so an ungated
209
+ block would re-fire endlessly; the flag makes it run once per visit. The ``warmup`` Wait (after the
210
+ lock, so the player can't wander) lets the field's entry fade settle before the actor moves.
211
+
212
+ ``ate_mode`` (not None) styles it as a compulsory ATE: brackets the steps ``ATE(mode) ... ATE(0)``
213
+ and (with ``say_flags=ATE_CAPTION_FLAG``) gives its windows the ATE caption -- mirrors the real grey
214
+ mode-6 fields (e.g. 956 Gargant), NOT field 1901 (which is the optional Blue mode-1 menu hub)."""
215
+ inner = opcodes.DISABLE_MOVE
216
+ if warmup > 0:
217
+ inner += opcodes.wait(int(warmup))
218
+ if ate_mode is not None:
219
+ inner += opcodes.ate(int(ate_mode)) # arm the blinking "Active Time Event" prompt
220
+ inner += compile_steps(steps, txids, say_flags=say_flags)
221
+ if ate_mode is not None:
222
+ inner += opcodes.ate(0) # disarm before control returns (close the bracket)
223
+ inner += opcodes.ENABLE_MOVE + _region.set_var(flag_class, flag_idx, 1)
224
+ return _region.if_block(_region.cond_not(flag_class, flag_idx), inner)
225
+
226
+
227
+ # A narration cutscene runs in a SEPARATE code entry armed by `InitCode` in Main_Init -- but Main_Init
228
+ # itself calls `EnableMove` (and a fade) AFTER that InitCode. If the director's `DisableMove` ran first
229
+ # it would be immediately overridden by Main_Init's `EnableMove`, so the player keeps control during the
230
+ # text. Yielding a couple of frames first lets Main_Init reach its `EnableMove` (it does so in the first
231
+ # frame), so the director's `DisableMove` is the LAST control-setter and the lock sticks. (An ACTOR
232
+ # cutscene avoids this by living in the NPC's LOOP, which only runs after Init completes.) ~2 frames is
233
+ # imperceptible (<100ms) and the window only shows during the entry fade.
234
+ REORDER_WAIT = 2
235
+
236
+
237
+ def build_body(steps, once_flag: int | None, flag_class=CUTSCENE_FLAG_CLASS,
238
+ reorder: int = REORDER_WAIT, *, ate_mode: int | None = None,
239
+ then_warp: int | None = None) -> bytes:
240
+ """The cutscene function body: a brief reorder ``Wait`` (so the lock outlives Main_Init's EnableMove)
241
+ then ``DisableMove`` + the ordered ``steps`` + ``EnableMove``, all gated ``if (!once_flag) { ...;
242
+ once_flag = 1 }`` when ``once_flag`` is set (so it plays once).
243
+
244
+ ``ate_mode`` (not None) brackets the steps ``ATE(mode) ... ATE(0)`` -- a compulsory ATE's HUD prompt
245
+ (the winATE caption on its windows is set by the caller via ``compile_steps(say_flags=...)``).
246
+
247
+ ``then_warp`` (a field id) makes the scene AUTO-RETURN: it ends with a FADE-TO-BLACK then
248
+ ``Field(then_warp)`` instead of restoring control -- exactly how real grey ATEs end (field 956 ->
249
+ ``Field(2054)``). The warp sits OUTSIDE the once-gate so it ALWAYS fires (even on a re-entry that skips
250
+ a once'd cutscene, the player still warps back); it transitions away, so it's the last op (no
251
+ ``EnableMove`` -- the destination's Main_Init restores control). It fades out first (``warp(fade=True)``)
252
+ so the destination doesn't load in the clear (the static-screen bug). Field() transitions from this
253
+ InitCode'd entry just like the World-Hub menu-row warp does (same code-entry context -- NOT the
254
+ Main_Init no-op case)."""
255
+ pre = opcodes.wait(int(reorder)) if reorder and reorder > 0 else b""
256
+ inner = pre + opcodes.DISABLE_MOVE
257
+ if ate_mode is not None:
258
+ inner += opcodes.ate(int(ate_mode))
259
+ inner += b"".join(steps)
260
+ if ate_mode is not None:
261
+ inner += opcodes.ate(0)
262
+ if then_warp is None:
263
+ inner += opcodes.ENABLE_MOVE # restore control (a normal cutscene stays put)
264
+ if once_flag is not None:
265
+ inner += _region.set_var(flag_class, once_flag, 1)
266
+ body = _region.if_block(_region.cond_not(flag_class, once_flag), inner)
267
+ else:
268
+ body = inner
269
+ if then_warp is not None:
270
+ # fade=True: fade to black BEFORE the warp, like every field transition (gateway/ladder/choice).
271
+ # Without it the destination loads in the clear and you see its camera-init frames (the static-
272
+ # screen bug). A real grey-ATE return may already be black behind the ATE banner, but the kit
273
+ # doesn't reproduce that, so an explicit source-side fade is the safe default. See event.warp.
274
+ body += _event.warp(int(then_warp), fade=True) # AUTO-RETURN: fade to black, then transition
275
+ return body + opcodes.RETURN
276
+
277
+
278
+ def inject_cutscene(data, steps, *, once_flag: int | None = None, flag_class=CUTSCENE_FLAG_CLASS,
279
+ spawn_wait_n: int = 2, spawn_wait_occurrence: int = 0,
280
+ ate_mode: int | None = None, then_warp: int | None = None) -> bytes:
281
+ """Append a cutscene code entry (the sequence in :func:`build_body`) and run it on field load via
282
+ an ``InitCode`` (over a Wait filler, or inserted into Main_Init). Returns new .eb bytes.
283
+ ``ate_mode`` (not None) styles it as a compulsory ATE (the ``ATE(mode)`` HUD bracket); ``then_warp``
284
+ (a field id) makes it auto-return with ``Field(then_warp)`` at the end."""
285
+ body = build_body(steps, once_flag, flag_class, ate_mode=ate_mode, then_warp=then_warp)
286
+ entry = bytes([0x00, 0x01]) + struct.pack("<HH", 0, 4) + body
287
+ slot = EbScript.from_bytes(data).first_free_slot()
288
+ out = edit.append_entry(data, slot, entry)
289
+ return edit.activate(out, opcodes.init_code(slot, 0), spawn_wait_n=spawn_wait_n,
290
+ spawn_wait_occurrence=spawn_wait_occurrence)
@@ -0,0 +1,41 @@
1
+ """Add random-battle encounters to a field.
2
+
3
+ Appends a type-0 "code" entry whose function runs ``SetRandomBattles`` +
4
+ ``SetRandomBattleFrequency``, and activates it from Main_Init via ``InitCode`` written over a
5
+ ``Wait`` filler (shift-free). The battle scene id selects which encounter table is used (e.g.
6
+ 67 = Evil Forest / the first, weakest battles). Frequency 0..255 (higher = more frequent).
7
+
8
+ NOTE: a field that hosts encounters also needs an after-battle reinit handler or the player
9
+ freezes on return — see :mod:`ff9mapkit.content.reinit`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import struct
15
+
16
+ from ..eb import EbScript, edit, opcodes
17
+
18
+
19
+ def _battle_entry(pattern: int, scenes, freq: int) -> bytes:
20
+ scenes = list(scenes)
21
+ if len(scenes) != 4:
22
+ raise ValueError("need exactly 4 battle scene ids")
23
+ code = opcodes.set_random_battles(pattern, *scenes) + opcodes.set_random_battle_frequency(freq) \
24
+ + opcodes.RETURN
25
+ # entry: type 0, funcCount 1, funcTable[(tag 0, fpos 4)], then code
26
+ return bytes([0x00, 0x01]) + struct.pack("<HH", 0, 4) + code
27
+
28
+
29
+ def inject_encounter(eb_bytes, *, scene: int, freq: int = 255, pattern: int = 1, scenes=None,
30
+ slot: int | None = None, spawn_wait_n: int = 2,
31
+ spawn_wait_occurrence: int = 0) -> bytes:
32
+ """Add encounters of ``scene`` (or an explicit 4-tuple ``scenes``) at ``freq``."""
33
+ if scenes is None:
34
+ scenes = (scene,) * 4
35
+ eb = EbScript.from_bytes(eb_bytes)
36
+ if slot is None:
37
+ slot = eb.first_free_slot()
38
+ out = edit.append_entry(eb_bytes, slot, _battle_entry(pattern, scenes, freq))
39
+ out = edit.activate(out, opcodes.init_code(slot, 0), spawn_wait_n=spawn_wait_n,
40
+ spawn_wait_occurrence=spawn_wait_occurrence)
41
+ return out
@@ -0,0 +1,50 @@
1
+ """Hold the screen black briefly on entry so the camera SETTLES before it is revealed.
2
+
3
+ The Memoria engine runs a per-frame smooth-camera follower (``FieldMap.CenterCameraOnPlayer``, scaled by
4
+ ``Memoria.ini``'s ``CameraStabilizer``) for EVERY field. On a warp-in it eases the camera from its
5
+ carried-over position to the spawn-centred target over many frames. Real fields hide this because the
6
+ warp's fade-out blacks the screen while the camera settles; the kit's synthesized ``Main_Init`` reveals
7
+ immediately (its FadeFilter fires right after ``EnableMove``), so on a large-delta entry -- e.g. the World
8
+ Hub entered via a New-Game / F6 warp -- you SEE the camera drift to rest over a few seconds.
9
+
10
+ Fix (engine-independent, ships on stock Memoria -- no DLL, no ``SmoothCamExcludeMaps`` edit): insert
11
+ ``DisableMove ; Wait(n) ; EnableMove`` immediately BEFORE Main_Init's reveal fade. The screen is still
12
+ black at that point (the field loads black; the reveal fade is what brings it in), so the smooth-cam
13
+ converges UNSEEN during the wait; the existing fade then reveals the already-settled camera. Control is
14
+ locked during the wait so the player can't wander blind. (memory ``project-ff9-world-hub``;
15
+ ``FieldMap.cs`` ``CenterCameraOnPlayer`` / ``SmoothCamExcludeMaps`` / ``CameraStabilizer``.)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from ..eb import EbScript, edit, opcodes
21
+
22
+ FADE_FILTER = 0xEC # WIPERGB / "FadeFilter"; arg0 & 2 set => SUB == a fade-IN (reveal)
23
+
24
+
25
+ def add_entry_settle(eb_bytes, wait_frames: int = 45) -> bytes:
26
+ """Insert ``DisableMove ; Wait(wait_frames) ; EnableMove`` just before Main_Init's reveal fade so the
27
+ smooth-camera settles behind the black screen. Returns the input unchanged when ``wait_frames <= 0`` or
28
+ Main_Init has no reveal fade (nothing to hide behind)."""
29
+ if wait_frames <= 0:
30
+ return eb_bytes
31
+ eb = EbScript.from_bytes(eb_bytes)
32
+ e0 = eb.entry(0)
33
+ f0 = e0.func_by_tag(0) if e0 is not None else None
34
+ if f0 is None:
35
+ return eb_bytes
36
+ fade = None
37
+ for i in eb.instrs(f0):
38
+ if i.op == FADE_FILTER and i.args:
39
+ try:
40
+ mode = int(i.args[0])
41
+ except (TypeError, ValueError):
42
+ continue # an expression-mode fade: not the template reveal -- skip
43
+ if mode & 2: # SUB == fade-IN (reveal); ADD (fade-out) would not help
44
+ fade = i
45
+ break
46
+ if fade is None:
47
+ return eb_bytes
48
+ rel = fade.off - f0.abs_start
49
+ body = opcodes.DISABLE_MOVE + opcodes.wait(wait_frames) + opcodes.ENABLE_MOVE
50
+ return edit.insert_in_function(eb_bytes, 0, 0, rel, body)
@@ -0,0 +1,93 @@
1
+ """``[[equipment]]`` -- author a character's STARTING equipment (its new-game default loadout).
2
+
3
+ Writes a PARTIAL ``<mod>/StreamingAssets/Data/Characters/DefaultEquipment.csv`` delta -- only the characters
4
+ you specify. The engine MERGES DefaultEquipment low->high (``ff9play.LoadCharacterDefaultEquipment``), so a
5
+ partial file overrides just those characters' default sets and unspecified characters keep the base game's.
6
+
7
+ ★ A slot you OMIT starts EMPTY. The row REPLACES that character's whole default set (it is not a per-slot
8
+ patch), so list the full intended loadout. Per-character default equipment is applied at new-game / when a
9
+ character JOINS (``FF9Play_SetDefEquips``), so it composes with story_flags' ``[party]`` -- an added member
10
+ joins wearing its DefaultEquipment gear. New-game scope (no mid-game retro-apply). Lives on the ENTRY field's
11
+ ``field.toml``, emitted at the mod-write stage. (memory project-ff9-items-equipment / project-ff9-branch-lanes.)
12
+
13
+ [[equipment]]
14
+ character = "steiner"
15
+ weapon = "Excalibur"
16
+ head = "Genji Helmet"
17
+ armor = "Genji Armor"
18
+ # head/wrist/armor/accessory omitted -> that slot starts empty
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from .. import items as _items
23
+
24
+ # Character name -> EquipmentSetId (Memoria.Data.Characters.EquipmentSetId enum; names/ids only -> provenance-clean).
25
+ EQUIP_SET_ID = {
26
+ "zidane": 0, "vivi": 1, "garnet": 2, "steiner": 3, "freya": 4, "quina": 5,
27
+ "eiko": 6, "amarant": 7, "cinna": 8, "marcus": 9, "blank": 10, "beatrix": 11,
28
+ "marcus2": 12, "beatrix2": 13, "blank2": 14,
29
+ "dagger": 2, "salamander": 7, # aliases (Garnet's alias, Amarant's nickname)
30
+ }
31
+ SET_NAME = {0: "Zidane", 1: "Vivi", 2: "Garnet", 3: "Steiner", 4: "Freya", 5: "Quina", 6: "Eiko",
32
+ 7: "Amarant", 8: "Cinna", 9: "Marcus", 10: "Blank", 11: "Beatrix",
33
+ 12: "Marcus2", 13: "Beatrix2", 14: "Blank2"}
34
+ SLOTS = ("weapon", "head", "wrist", "armor", "accessory") # the 5 DefaultEquipment.csv equip columns, in order
35
+ MAX_SET_ID = 14
36
+
37
+
38
+ def resolve_set_id(name) -> int:
39
+ """A character name/alias (or a bare 0-14 set id) -> EquipmentSetId. Raises ValueError on unknown/out-of-range."""
40
+ if isinstance(name, bool) or name is None:
41
+ raise ValueError("[[equipment]] needs a 'character' (zidane..beatrix, marcus2/beatrix2/blank2)")
42
+ if isinstance(name, int) or (isinstance(name, str) and name.strip().lstrip("-").isdigit()):
43
+ i = int(name)
44
+ if not 0 <= i <= MAX_SET_ID:
45
+ raise ValueError(f"equipment character id {i} out of range (0-{MAX_SET_ID})")
46
+ return i
47
+ key = "".join(c for c in str(name).lower() if c.isalnum())
48
+ if key not in EQUIP_SET_ID:
49
+ raise ValueError(f"unknown equipment character {name!r} (zidane..beatrix, marcus2/beatrix2/blank2)")
50
+ return EQUIP_SET_ID[key]
51
+
52
+
53
+ def _slot_id(val) -> int:
54
+ """An equip-slot value -> item id, or -1 for empty (None / 'none' / '' / -1). Resolves names via items."""
55
+ if val is None:
56
+ return -1
57
+ if isinstance(val, str) and val.strip().lower() in ("", "none", "-1"):
58
+ return -1
59
+ if isinstance(val, int) and val < 0:
60
+ return -1
61
+ return _items.resolve(val)
62
+
63
+
64
+ def equipment_rows(entries) -> list:
65
+ """``[[equipment]]`` dicts -> sorted ``[(set_id, [weapon, head, wrist, armor, accessory]), ...]`` -- one
66
+ COMPLETE row per character (an omitted slot = -1 empty). De-dups by set id (last wins). Resolves item +
67
+ character names; raises ValueError on an unknown name."""
68
+ by_id: dict = {}
69
+ for e in entries:
70
+ sid = resolve_set_id(e.get("character"))
71
+ by_id[sid] = [_slot_id(e.get(s)) for s in SLOTS]
72
+ return sorted(by_id.items())
73
+
74
+
75
+ def render_default_equipment(entries) -> str:
76
+ """The PARTIAL ``DefaultEquipment.csv`` text (header + one row per authored character). Merged over the
77
+ base by the engine, so it overrides only these characters' default sets."""
78
+ lines = [
79
+ "# ff9mapkit [[equipment]] -- a partial starting-equipment delta (merged over the base by the engine).",
80
+ "# Comment;Id;Weapon;Head;Wrist;Armor;Accessory",
81
+ "# ;Int32;Int32;Int32;Int32;Int32;Int32",
82
+ ]
83
+ for sid, slots in equipment_rows(entries):
84
+ cmt = SET_NAME.get(sid, f"set{sid}")
85
+ lines.append(f"{cmt};{sid};" + ";".join(str(x) for x in slots))
86
+ return "\n".join(lines) + "\n"
87
+
88
+
89
+ def write_default_equipment(layout, entries) -> None:
90
+ """Pure writer: emit the equipment delta into ``layout``'s mod root (``Data/Characters/DefaultEquipment.csv``)."""
91
+ path = layout.default_equipment_csv
92
+ path.parent.mkdir(parents=True, exist_ok=True)
93
+ path.write_text(render_default_equipment(entries), encoding="utf-8", newline="\n")