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,191 @@
1
+ """One-shot field EVENTS -- walk-into-a-zone triggers that fire authored logic, optionally once.
2
+
3
+ This is the conditional-region primitive (:mod:`ff9mapkit.content.region`) cashed in as authorable
4
+ content. An event is a region whose ``_Range`` runs a composed sequence -- show a message, give an
5
+ item / gil, set a story flag -- gated by a GlobBool so an ``once`` event (a looted chest, a one-time
6
+ line, an ATE) never re-fires. Same shape the real game uses for treasure (decoded from a real chest
7
+ handler: ``AddItem`` + a "received X" ``WindowSync``) and the same flag-gated ``if (!done){..;
8
+ done=1}`` the camera-switch zones use.
9
+
10
+ Compose a body from the part builders (:func:`message` / :func:`give_item` / :func:`give_gil` /
11
+ :func:`set_flag`); :func:`inject_events` adds any number of events through a SINGLE arming entry (so
12
+ they don't each consume a Main_Init ``Wait`` filler).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import struct
18
+
19
+ from .. import items as _items
20
+ from ..eb import EbScript, edit, opcodes
21
+ from . import region as _region
22
+
23
+ # 'once' flags live in the SAVE-PERSISTENT Global bool (region.GLOB_BOOL) so a looted chest / one-time
24
+ # event stays done across field reloads + saves. The base is high in gEventGlobal (byte ~1000) to stay
25
+ # clear of the base game's flags (which sit low); override per event with `flag = N`.
26
+ EVENT_FLAG_CLASS = _region.GLOB_BOOL
27
+ EVENT_FLAG_BASE = 8000
28
+
29
+
30
+ def message(text_id: int, *, window: int = 1, flags: int = 128) -> bytes:
31
+ """Body part: open a dialogue window (WindowSync) showing text ``text_id``."""
32
+ return opcodes.window_sync(window, flags, text_id)
33
+
34
+
35
+ def give_item(item_id, count: int = 1) -> bytes:
36
+ """Body part: AddItem(item, count). ``item_id`` may be a numeric id OR a name ("Potion") --
37
+ resolved via :mod:`ff9mapkit.items` so authors don't have to memorize ids. Works for ANY item,
38
+ including weapons/armor ("Excalibur") -- the engine renders the name in the "Received X" box."""
39
+ return opcodes.add_item(_items.resolve(item_id), count)
40
+
41
+
42
+ def take_item(item_id, count: int = 1) -> bytes:
43
+ """Body part: RemoveItem(item, count) -- the symmetric counterpart of :func:`give_item` (a trade,
44
+ a quest-item consume). ``item_id`` may be a numeric id OR a name; the engine clamps to what's held."""
45
+ return opcodes.remove_item(_items.resolve(item_id), count)
46
+
47
+
48
+ def give_gil(amount: int) -> bytes:
49
+ """Body part: change the party's gil by ``amount`` -- positive ADDS (AddGil), negative SUBTRACTS
50
+ (RemoveGil). The two opcodes both take an unsigned amount, so we pick by sign here (a negative
51
+ ``amount`` would otherwise wrap to a huge ADD and max out gil)."""
52
+ return opcodes.add_gil(amount) if amount >= 0 else opcodes.remove_gil(-amount)
53
+
54
+
55
+ def set_flag(flag_idx: int, value: int = 1, *, flag_class=EVENT_FLAG_CLASS) -> bytes:
56
+ """Body part: set a GlobBool story flag (gate other content on it)."""
57
+ return _region.set_var(flag_class, flag_idx, value)
58
+
59
+
60
+ def reveal_object(slot: int) -> bytes:
61
+ """Body part: re-run an object's Init (``InitObject``). Used after :func:`set_flag` to make a
62
+ flag-gated NPC appear (or vanish) LIVE in the same room -- its Init re-evaluates the gate with the
63
+ flag's new value (without this, a gated NPC only updates on field re-entry, since Init runs once
64
+ at spawn)."""
65
+ return opcodes.init_object(slot, 0)
66
+
67
+
68
+ # RunSoundCode(265, 65535): the field-transition whoosh present byte-identically in EVERY real warp
69
+ # (verified in the airship 455<->457, the Dali innkeeper 351->352, Gargan Roo 950->914 talk handlers).
70
+ WARP_SOUND = (265, 65535)
71
+
72
+
73
+ # The proven field-transition fade-out, byte-identical to what ladders + gateways emit (and what the
74
+ # engine itself does on a worldmap entry): FadeFilter(mode 6 = SUB, 24f, target colour white) drives the
75
+ # screen to BLACK (SUB shows `screen - colour`, so colour=white => black), then Wait(25) holds until the
76
+ # fade has fully finished. Confirmed against SceneDirector.InitFade/ServiceFade + content.ladder.
77
+ WARP_FADE = (6, 24, 0, 255, 255, 255)
78
+
79
+
80
+ def warp(target: int, entrance: "int | None" = None, *, fade: bool = False) -> bytes:
81
+ """Body part: warp the player to ``Field(target)``. Grounded in real *talk-handler* warps -- a Field
82
+ op TRANSITIONS directly from an event / tag-3 _SpeakBTN context (unlike a bare Field in Main_Init,
83
+ which no-ops -- see memory project_ff9_field_warp_pattern); 14+ shipping fields warp the player from a
84
+ tag-3 handler this way (the Dali innkeeper, the airship, Gargan Roo...). Plays the transition sound,
85
+ then warps. The warp TRANSITIONS AWAY, so this MUST be the LAST part of a body (anything after is
86
+ unreachable).
87
+
88
+ ``fade`` (default False) prepends the proven transition FADE-OUT (:data:`WARP_FADE` + ``Wait(25)``).
89
+ This is what fixes the World-Hub "static screen on spawn": a bare ``Field()`` warp transitions with the
90
+ SOURCE field still fully drawn, so the destination loads *in the clear* and you SEE its camera-init
91
+ frames (the smooth-cam needs ~0.8s to wire up player-tracking -- ``FieldMap.SceneService3DScroll`` --
92
+ during which the camera sits on the bare scene centre with the player off in a corner). Every real
93
+ gateway/ladder/worldmap entry fades to black FIRST, so the destination loads black and its own reveal
94
+ fade (and any ``[camera] entry_settle``) hides the wire-up. ``fade=True`` makes the choice-warp do the
95
+ same. (The destination's reveal LERPs the SUB layer from white->black; if the source never set it to
96
+ white, that reveal is a no-op and nothing is hidden -- which is exactly the bug.)
97
+
98
+ ``entrance`` (when set) writes the ARRIVAL-ENTRANCE var (D8:2) before the warp, like a gateway
99
+ (:func:`ff9mapkit.content.region.set_field_entrance`) -- the destination's player-init may switch on it
100
+ to place the player. NOTE it is NOT a camera fix and is silently overridden by destinations whose
101
+ Main_Init rewrites D8:2 on entry (e.g. the forked Ice Cavern sets D8:2=10000 up front); the fade is the
102
+ camera fix. Default None = no entrance write."""
103
+ pre = opcodes.fade_filter(*WARP_FADE) + opcodes.wait(25) if fade else b""
104
+ if entrance is not None:
105
+ pre += _region.set_field_entrance(int(entrance))
106
+ return pre + opcodes.run_sound_code(*WARP_SOUND) + opcodes.field(int(target))
107
+
108
+
109
+ def set_scenario(value: int) -> bytes:
110
+ """Body part: set the ScenarioCounter (the story beat) -- the save-backed gEventGlobal UInt16 @0.
111
+ Reuses :func:`ff9mapkit.content.startup.startup_body` (story_flags' lever) so an event/choice can
112
+ advance the beat (e.g. a hub journey-pick seeds the destination's beat before the warp)."""
113
+ from . import startup as _startup
114
+ return _startup.startup_body([], scenario=int(value))
115
+
116
+
117
+ def event_range_body(body: bytes, once_flag: int | None, flag_class=EVENT_FLAG_CLASS,
118
+ requires_flag: int | None = None, requires_set: bool = True,
119
+ space_item: int | None = None) -> bytes:
120
+ """The region ``_Range`` body for an event: a movement gate, an optional ``requires_flag`` story
121
+ gate (the event only fires when that flag is in-state), then ``body`` -- gated
122
+ ``if (!flag) { flag = 1; body }`` when ``once_flag`` is set, so it fires once. The once-flag is set
123
+ BEFORE the body, matching FF9's treasure-chest convention (``if(!opened){ opened=1; reward; msg }``):
124
+ the dedup flag lands the instant the event fires, before any (movement-unblocking) reward message --
125
+ so it can't double-fire even if the reward window is left open.
126
+
127
+ ``space_item`` (a chest nicety) wraps everything in ``if (GetItemCount(item) < 99) { ... }`` so the
128
+ reward is skipped (and the once-flag NOT set -> retryable) when the bag is full -- exactly FF9's
129
+ item-chest guard, with the space check OUTERMOST."""
130
+ parts = [_region.MOVEMENT_GATE]
131
+ if requires_flag is not None:
132
+ parts.append(_region.flag_gate(flag_class, requires_flag, require_set=requires_set))
133
+ if once_flag is not None:
134
+ core = _region.if_block(_region.cond_not(flag_class, once_flag),
135
+ _region.set_var(flag_class, once_flag, 1) + body)
136
+ else:
137
+ # No once flag = the raw region trigger: tag 2 is LEVEL-triggered (the engine fires it every
138
+ # frame the player treads the quad -- TreadQuad is a pure position test, no edge detection), so
139
+ # a `once=false` message re-fires as soon as it closes while still inside. Correct for a
140
+ # continuous effect; edge-triggered "once per visit" would need a leave-detecting re-arm zone.
141
+ core = body
142
+ if space_item is not None: # FF9 chest: only give if there's room (space outermost)
143
+ core = _region.if_block(_region.cond_item_count_lt(space_item), core)
144
+ parts.append(core)
145
+ parts.append(opcodes.RETURN)
146
+ return b"".join(parts)
147
+
148
+
149
+ def inject_event(data, *, zone, body: bytes, once_flag: int | None = None,
150
+ requires_flag: int | None = None, requires_set: bool = True,
151
+ flag_class=EVENT_FLAG_CLASS, slot=None, spawn_wait_n: int = 2,
152
+ spawn_wait_occurrence: int = 0, reserve_party_band: bool = False):
153
+ """Inject ONE walk-in event region (armed at load via InitRegion-over-Wait). Returns
154
+ ``(new_bytes, slot)``. For several events prefer :func:`inject_events` (one shared arm entry)."""
155
+ range_body = event_range_body(body, once_flag, flag_class, requires_flag, requires_set)
156
+ return _region.inject_region(data, zone, range_body, slot=slot, activate=True,
157
+ spawn_wait_n=spawn_wait_n, spawn_wait_occurrence=spawn_wait_occurrence,
158
+ reserve_party_band=reserve_party_band)
159
+
160
+
161
+ def inject_events(data, events, *, flag_class=EVENT_FLAG_CLASS, spawn_wait_n: int = 2,
162
+ spawn_wait_occurrence: int = 0, reserve_party_band: bool = False) -> bytes:
163
+ """Inject many events through a single arming entry. ``events`` is a list of dicts with keys
164
+ ``zone`` (corners), ``body`` (composed action bytes), ``once_flag`` (int or None).
165
+
166
+ Each event becomes a region (appended, not auto-armed); one type-0 code entry then ``InitRegion``s
167
+ them all and is activated once via ``InitCode`` over a Main_Init ``Wait`` filler -- so N events
168
+ cost ONE filler, not N. ``reserve_party_band`` (the VERBATIM-fork path): every region AND the arm
169
+ entry are seated BELOW the reserved party-character band. The arm's ``InitRegion`` targets the region
170
+ SLOTS, which sit below the band and so are never shifted by a later below-band insert -> stay valid.
171
+ Returns new .eb bytes."""
172
+ events = list(events)
173
+ if not events:
174
+ return data if isinstance(data, (bytes, bytearray)) else data.to_bytes()
175
+ out = data if isinstance(data, (bytes, bytearray)) else data.to_bytes()
176
+ region_slots = []
177
+ for ev in events:
178
+ rb = event_range_body(ev["body"], ev.get("once_flag"), flag_class,
179
+ ev.get("requires_flag"), ev.get("requires_set", True),
180
+ space_item=ev.get("space_item"))
181
+ out, slot = _region.inject_region(out, ev["zone"], rb, activate=False,
182
+ reserve_party_band=reserve_party_band)
183
+ region_slots.append(slot)
184
+
185
+ arm = b"".join(opcodes.init_region(s, 0) for s in region_slots) + opcodes.RETURN
186
+ arm_entry = bytes([0x00, 0x01]) + struct.pack("<HH", 0, 4) + arm
187
+ from . import object as _object # local: object imports region -> avoid the top-level cycle
188
+ out, arm_slot = _object.seat_entry(out, arm_entry, reserve_party_band=reserve_party_band)
189
+ out = edit.activate(out, opcodes.init_code(arm_slot, 0), spawn_wait_n=spawn_wait_n,
190
+ spawn_wait_occurrence=spawn_wait_occurrence)
191
+ return out
@@ -0,0 +1,101 @@
1
+ """Inject a field-exit gateway (a region trigger that warps to another field).
2
+
3
+ Clones the proven field-109 exit-region template (a SetRegion polygon ->
4
+ CalculateExitPosition/ExitField -> PreloadField -> FadeFilter -> set FieldEntrance ->
5
+ Field(target)), patches its trigger polygon + target field + arrival entrance, appends it
6
+ into a free entry slot, and activates it by overwriting a Main_Init ``Wait(2)`` filler with
7
+ ``InitRegion`` (shift-free).
8
+
9
+ Zone gotchas (baked into :func:`quad_zone`): the engine's IsInQuad tests a *fan* of
10
+ consecutive vertex triplets, so three collinear points make a dead zone — use a convex quad
11
+ with the **last vertex doubled** (5 points). Point order matters: q0->q1 is the edge the
12
+ player walks out across, so put the front edge first for a natural forward exit.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import struct
18
+
19
+ from .. import data
20
+ from ..eb import EbScript, edit, opcodes
21
+ from . import region as _region
22
+
23
+ # offsets within the 272-byte region template
24
+ REL_PTS, REL_ENTRANCE, REL_FIELD = 13, 263, 269
25
+
26
+
27
+ def quad_zone(corners) -> list:
28
+ """Make a 5-point IsInQuad-safe zone from 4 (x, z) corners (doubles the last vertex)."""
29
+ pts = [tuple(c) for c in corners]
30
+ if len(pts) != 4:
31
+ raise ValueError("quad_zone needs 4 corners")
32
+ return pts + [pts[-1]]
33
+
34
+
35
+ def inject_gateway(eb_bytes, target: int, *, entrance: int = 0, zone, slot: int | None = None,
36
+ spawn_wait_n: int = 2, spawn_wait_occurrence: int = 0,
37
+ gate_flag: int | None = None, gate_require_set: bool = True,
38
+ on_exit_body: bytes = b"", reserve_party_band: bool = False) -> bytes:
39
+ """Inject an exit gateway to ``Field(target)`` arriving at ``entrance``. Returns new bytes.
40
+
41
+ ``gate_flag`` (a GlobBool index) locks the exit behind a story flag: the region's trigger returns
42
+ early unless the flag is in the required state (``gate_require_set`` True = open when SET, e.g. a
43
+ door that unlocks once a switch flag is set; False = open when CLEAR).
44
+
45
+ ``on_exit_body`` (raw ``set_var`` bytes -- e.g. from :func:`ff9mapkit.content.startup.startup_body`)
46
+ ADVANCES story state when the player TAKES this exit: it is prepended to the Range trigger behind a
47
+ ``usercontrol`` guard, so the writes fire only on an actual walk-out (not while the player is puppeted
48
+ through with control disabled) and -- when the exit is also ``gate_flag``-locked -- only when the gate
49
+ passes (the flag gate sits ahead of the writes). The byte sequence runs just before the template's own
50
+ warp path, so the ScenarioCounter / story bits commit to the save-backed gEventGlobal before the
51
+ transition. Empty -> no change (the gateway builds byte-identically to before)."""
52
+ zone = list(zone)
53
+ if len(zone) != 5:
54
+ raise ValueError("zone must be 5 points (convex quad + doubled last vertex); see quad_zone()")
55
+ tpl = bytearray(data.region_template())
56
+ for i, (x, z) in enumerate(zone):
57
+ struct.pack_into("<hh", tpl, REL_PTS + i * 4, int(x), int(z))
58
+ struct.pack_into("<H", tpl, REL_ENTRANCE, entrance)
59
+ struct.pack_into("<H", tpl, REL_FIELD, target)
60
+
61
+ from . import object as _object # local: object imports region -> avoid the top-level cycle
62
+ out, slot = _object.seat_entry(eb_bytes, bytes(tpl), reserve_party_band=reserve_party_band, slot=slot)
63
+ out = edit.activate(out, opcodes.init_region(slot, 0), spawn_wait_n=spawn_wait_n,
64
+ spawn_wait_occurrence=spawn_wait_occurrence)
65
+ # Order matters: prepend the on-exit writes first, then the flag gate, so the final Range reads
66
+ # [flag gate] -> [usercontrol guard + writes] -> [template warp]. (Each prepend goes to Range's start.)
67
+ if on_exit_body:
68
+ out = _region.prepend_range_gate(out, slot, _region.MOVEMENT_GATE + on_exit_body)
69
+ if gate_flag is not None:
70
+ out = _region.prepend_range_gate(out, slot, _region.flag_gate(
71
+ _region.GLOB_BOOL, gate_flag, require_set=gate_require_set))
72
+ return out
73
+
74
+
75
+ def graft_gateway_entry(eb_bytes, entry_bytes, *, retarget=None, slot=None):
76
+ """Graft a story-gated door's region entry VERBATIM (preserving its whole conditional state machine), then
77
+ arm it -- the faithful counterpart to :func:`inject_gateway`'s re-synthesis. A real story-gated door
78
+ (``eventscan.scan_gateway_entries`` ``story_gated``) checks GLOB save flags in a complex conditional the
79
+ declarative rebuild can't reproduce; carrying the entry whole keeps that logic, and its GLOB conditions
80
+ then read the ``[startup]``-preset story state (docs/FORK_FIDELITY.md #2b). Mirrors the object carry.
81
+
82
+ ``retarget`` maps a real destination field id -> a new id; each ``Field(id)`` literal whose id is in the
83
+ map is patched in place (ids NOT in the map are left as live seams, like the import's live-seam doors).
84
+ ``slot`` defaults to the first free entry. Returns ``(new_bytes, slot)``.
85
+
86
+ LIMIT: a door-only carry does NOT reconstruct MAP/transient vars the field's *main* logic sets on entry,
87
+ so a door whose firing also depends on those may still mis-evaluate (documented; ~30% of gated entries
88
+ also reference other entries and aren't carried by this path at all)."""
89
+ eb = EbScript.from_bytes(eb_bytes)
90
+ if slot is None:
91
+ slot = eb.first_free_slot()
92
+ out = edit.append_entry(eb_bytes, slot, bytes(entry_bytes))
93
+ if retarget:
94
+ ge = EbScript.from_bytes(out)
95
+ buf = bytearray(out)
96
+ for f in ge.entry(slot).funcs:
97
+ for i in ge.instrs(f):
98
+ if i.op == 0x2B and i.imm(0) in retarget: # Field(id) -> retarget[id] (2-byte literal @ +2)
99
+ struct.pack_into("<H", buf, i.off + 2, int(retarget[i.imm(0)]) & 0xFFFF)
100
+ out = bytes(buf)
101
+ return edit.activate(out, opcodes.init_region(slot, 0)), slot
@@ -0,0 +1,59 @@
1
+ """``[start_inventory]`` -- author the NEW-GAME starting bag (the items the player begins a New Game with).
2
+
3
+ Writes ``<mod>/StreamingAssets/Data/Items/InitialItems.csv``. ★ The engine reads this
4
+ **HIGHEST-PRIORITY-WINS** (NOT merged -- ``ff9item.LoadInitialItems`` via ``GetCsvWithHighestPriority``), so
5
+ this file **REPLACES the base starting bag entirely**: list the COMPLETE intended inventory. A stacked mod
6
+ folder that also defines ``InitialItems.csv`` SHADOWS this one (the ``text_block`` trap) -> the build lints.
7
+
8
+ Read ONCE at new-game init, so it only affects a true **New Game** (not an F6 / campaign mid-game entry).
9
+ It is mod-global (one bag per mod) and lives on the ENTRY field's ``field.toml`` -- emitted at the mod-write
10
+ stage, not into any field's ``.eb``. (memory project-ff9-items-equipment / project-ff9-branch-lanes.)
11
+
12
+ [start_inventory]
13
+ items = [["Potion", 20], ["Phoenix Down", 5], ["Tent", 3], ["Ether", 10]]
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from .. import items as _items
18
+
19
+ NO_ITEM = 255 # the empty sentinel -- never a real starting item
20
+ MAX_COUNT = 99 # the per-item inventory cap (UInt8 column; the engine clamps, we clamp too)
21
+
22
+
23
+ def inventory_rows(items) -> list:
24
+ """``[[name, count], ...]`` (or bare names) -> sorted ``[(item_id, count), ...]`` -- names resolved,
25
+ dup ids summed, counts clamped 1..99, NoItem dropped. Raises ValueError (via :func:`items.resolve`) on an
26
+ unknown name."""
27
+ by_id: dict = {}
28
+ for entry in items:
29
+ if isinstance(entry, (list, tuple)):
30
+ name = entry[0]
31
+ count = int(entry[1]) if len(entry) > 1 else 1
32
+ else:
33
+ name, count = entry, 1
34
+ iid = _items.resolve(name)
35
+ if iid == NO_ITEM:
36
+ continue
37
+ by_id[iid] = min(MAX_COUNT, by_id.get(iid, 0) + max(1, count))
38
+ return sorted(by_id.items())
39
+
40
+
41
+ def render_initial_items(items) -> str:
42
+ """The FULL ``InitialItems.csv`` text (header + ``id;count;# name`` rows). Replaces the base bag entirely
43
+ (highest-priority-wins), so this is the complete starting inventory."""
44
+ lines = [
45
+ "# ff9mapkit [start_inventory] -- the FULL new-game starting bag (REPLACES the base; highest-priority-wins).",
46
+ "# ItemID;Count",
47
+ "# Int32;UInt8",
48
+ ]
49
+ for iid, count in inventory_rows(items):
50
+ nm = _items.name_of(iid)
51
+ lines.append(f"{iid};{count};" + (f"# {nm}" if nm else ""))
52
+ return "\n".join(lines) + "\n"
53
+
54
+
55
+ def write_initial_items(layout, items) -> None:
56
+ """Pure writer: emit the starting-bag CSV into ``layout``'s mod root (``Data/Items/InitialItems.csv``)."""
57
+ path = layout.initial_items_csv
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ path.write_text(render_initial_items(items), encoding="utf-8", newline="\n")