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
ff9mapkit/hub.py ADDED
@@ -0,0 +1,477 @@
1
+ """World-Hub generator (overworld's lane): a ``journeys.toml`` registry -> a hub ``field.toml``.
2
+
3
+ A *World Hub* is the New-Game-landing **journey selector** (memory: ``project-ff9-world-hub``): you walk as
4
+ a Moogle, talk to a narrator NPC, pick a **journey** (a complete arc = one or more chained campaign slices)
5
+ from a dialogue menu, and warp into its entry field -- optionally seeding the story beat first. The hub is
6
+ THIN: per journey it needs only ``{display name, entry field id, seed}``; HOW a journey plays internally is
7
+ the journey's own business (story_flags' campaign lane). This module is the "hardcoded MVP -> generator"
8
+ step the world-hub MVP left as a follow-up: it turns a small ``journeys.toml`` into a complete hub
9
+ ``field.toml``, built on the in-game-proven primitives -- the choice ``warp`` action
10
+ (:func:`ff9mapkit.content.event.warp`) + ``[player] model=`` (the Moogle PC).
11
+
12
+ It **emits a field.toml** (the existing build/deploy path then compiles it -- no new build path), mirroring
13
+ :func:`ff9mapkit.campaign.render_campaign_toml`'s emit-then-build pattern. The generated hub is a normal
14
+ synthesized BG-borrow field: a camera ``borrow`` + a Moogle player + a narrator NPC + one ``[[choice]]``
15
+ whose options ``warp`` to each journey's entry, plus a trailing no-warp "stay" row (the cancel target).
16
+
17
+ ``journeys.toml`` shape::
18
+
19
+ [hub]
20
+ name = "WORLD_HUB" # -> EVT_<name>.eb / FBG_N<area>_<name>
21
+ id = 4500 # the hub field id (>= 4000)
22
+ area = 21 # >= 10 (the BG-borrow loader reads 2 digits; single digits black-screen)
23
+ borrow_bg = "GRGR_MAP420_GR_CEN_0" # BG-borrow a real room for the backdrop (the MVP art path)
24
+ camera = "camera_hub.bgx" # that room's camera (you extract it from your own install; gitignored)
25
+ text_block = 8 # a real MesDB id NOT shadowed by a higher folder (1073 IS -> wrong menu)
26
+ prompt = "Kupo! Which journey will you take?"
27
+ stay_text = "Stay here, kupo..." # the trailing no-warp (cancel) row label
28
+ player_model = 220 # the Moogle PC (220 = GEO_NPC_F0_MOG, the iconic save moogle)
29
+ player_spawn = [404, 127]
30
+ narrator = "Stiltzkin"
31
+ narrator_model = 220 # default = player_model
32
+ narrator_pos = [480, 127]
33
+
34
+ # Set-dressing (author-customizable) -- dress the hub without hand-editing the generated field.toml.
35
+ [[hub.props]] # static set-dressing (the proven [[prop]] path; non-interactive)
36
+ prop = "save_point" # a prop archetype by name (save_point/chest/tent/barrel/...) OR
37
+ pos = [300, 100] # model = <GEO id> + pose = "<anim>" for a bare standing figure
38
+ [[hub.ambient_npcs]] # a flavor character (talk -> its dialogue line, if any)
39
+ archetype = "moogle" # archetype name OR model = <GEO id>
40
+ pos = [260, 150]
41
+ dialogue = "Kupo! Safe travels!" # optional; omit for a silent standing NPC
42
+
43
+ [[journey]]
44
+ id = "black_mage_village" # stable slug (hub-choice key + seed namespace; docs/JOURNEYS.md)
45
+ name = "The Black Mage Village" # the menu row label (default: humanized id)
46
+ entry = 4501 # the journey's entry field id (the warp target)
47
+ set_scenario = 2600 # optional: seed the beat hub-side before the warp
48
+
49
+ Regenerate the hub after editing the registry (``ff9mapkit gen-hub journeys.toml``); the emitted
50
+ ``hub.field.toml`` is a build artifact -- don't hand-edit it.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import os
56
+ import re
57
+ import tomllib
58
+ from dataclasses import dataclass, field
59
+ from pathlib import Path
60
+
61
+ # The iconic save Moogle (GEO_NPC_F0_MOG). NOT 199 (GEO_NPC_F5, a bat-winged variant that surprised the
62
+ # world-hub playtest -- memory project-ff9-world-hub). Both share the MOG movement clips via catalog.npc_anims.
63
+ MOOGLE_MODEL = 220
64
+ DEFAULT_AREA = 21
65
+ DEFAULT_PROMPT = "Kupo! Which journey will you take?"
66
+ DEFAULT_STAY = "Stay here, kupo..."
67
+ DEFAULT_NARRATOR = "Stiltzkin"
68
+ DEFAULT_CAMERA = "camera_hub.bgx"
69
+ # Hold the screen black this many frames before the reveal fade so the engine's smooth-camera follower
70
+ # settles UNSEEN (else warping into the hub visibly eases the camera to rest -- the borrowed room's camera
71
+ # vs the warp-in delta). ~1.5s @ 30fps; engine-independent. Tune/disable (0) via [hub] entry_settle.
72
+ DEFAULT_ENTRY_SETTLE = 45
73
+ DEFAULT_TEXT_BLOCK = 1073
74
+ SHADOWED_TEXT_BLOCK = 1073 # the block the higher-priority FF9CustomMap folder defines (shadows yours)
75
+ PAGING_SOFT_MAX = 8 # journeys + the stay row beyond ~this need menu scrolling -- verify in-game
76
+ SCENARIO_MAX = 32767
77
+
78
+ _NAME_RE = re.compile(r"^[A-Za-z0-9_]+$") # a field/journey name -> EVT_/FBG_ token + comment/subdir-safe
79
+
80
+
81
+ def name_token(s, *, fallback="HUB") -> str:
82
+ """Coerce a hub display name to an EVT_/FBG_ TOKEN: the ``[hub]`` ``name`` becomes ``EVT_<name>.eb`` +
83
+ ``FBG_N<area>_<name>`` (validated by :data:`_NAME_RE`), so spaces/punctuation must collapse to underscores
84
+ (``World Hub`` -> ``World_Hub``). Already-valid tokens pass through unchanged; empty -> ``fallback``."""
85
+ t = re.sub(r"[^A-Za-z0-9_]+", "_", str(s)).strip("_")
86
+ return t or fallback
87
+
88
+
89
+ class HubError(ValueError):
90
+ """A journeys.toml / hub-generation problem (caught + printed by the CLI)."""
91
+
92
+
93
+ @dataclass
94
+ class Journey:
95
+ """One row of the journey menu, in the ``docs/JOURNEYS.md`` schema: a stable ``id`` slug (the hub-choice
96
+ key + seed namespace), a pretty ``name`` (the menu label + the GUI breadcrumb), the ``entry`` field it
97
+ warps into, and an optional ``set_scenario`` seed applied hub-side before the warp. (A *multi-campaign*
98
+ journey -- ``campaigns`` / ``entry = {campaign, field}`` / ``[journey.seed]`` / ``[[journey.link]]`` --
99
+ is the future journey ASSEMBLER's job; gen-hub builds the single-entry form, ``entry = <field id>``.)"""
100
+ id: str
101
+ name: str
102
+ entry: int
103
+ set_scenario: "int | None" = None
104
+ entrance: "int | None" = None # arrival entrance into the entry field (D8:2) -- frames the entry camera
105
+
106
+
107
+ @dataclass
108
+ class HubSpec:
109
+ """The whole hub registry: the hub field's identity + backdrop + Moogle/narrator rig + the journeys."""
110
+ name: str
111
+ id: int
112
+ area: int = DEFAULT_AREA
113
+ borrow_bg: str = ""
114
+ borrow_field: "int | None" = None # the real field id whose camera `gen-hub --extract-camera` pulls
115
+ camera: str = DEFAULT_CAMERA
116
+ entry_settle: int = DEFAULT_ENTRY_SETTLE # frames the hub holds black on entry so the camera settles
117
+ text_block: int = DEFAULT_TEXT_BLOCK
118
+ prompt: str = DEFAULT_PROMPT
119
+ stay_text: str = DEFAULT_STAY
120
+ player_model: "int | str" = MOOGLE_MODEL
121
+ player_spawn: "list | None" = None
122
+ narrator: str = DEFAULT_NARRATOR
123
+ narrator_model: "int | str | None" = None # None -> inherit player_model
124
+ narrator_pos: "list | None" = None
125
+ # Set-dressing (author-customizable): static [[prop]]s (save point / lamp / a bare standing moogle) +
126
+ # ambient [[npc]]s (a talkable flavor character). All BG-borrow + the proven prop/npc build path.
127
+ props: "list" = field(default_factory=list) # each: {prop=<archetype>|model=N, pos, pose?, face?}
128
+ ambient_npcs: "list" = field(default_factory=list) # each: {archetype=<name>|model=N, pos, dialogue?}
129
+ journeys: "list[Journey]" = field(default_factory=list)
130
+
131
+ @property
132
+ def narrator_model_resolved(self):
133
+ return self.narrator_model if self.narrator_model is not None else self.player_model
134
+
135
+
136
+ def _humanize(name: str) -> str:
137
+ """``black_mage_village`` -> ``Black Mage Village`` -- a default menu label from a journey's token name."""
138
+ return " ".join(w.capitalize() for w in str(name).replace("_", " ").split()) or str(name)
139
+
140
+
141
+ def hubspec_from_table(h: dict, journeys: "list[Journey]") -> HubSpec:
142
+ """Build a :class:`HubSpec` from a parsed ``[hub]`` table + a resolved journey list. The single source
143
+ of truth for the ``[hub]`` presentation schema -- both :func:`load_journeys` (gen-hub's single-entry
144
+ rows) and the multi-campaign journey assembler (:mod:`ff9mapkit.journey`, which resolves campaign entries
145
+ to global ids before calling here) construct their HubSpec through this. Raises :class:`HubError` on a
146
+ missing required key; semantic checks stay in :func:`validate_hub`."""
147
+ if "name" not in h:
148
+ raise HubError("[hub] missing required key 'name' (becomes EVT_<name>.eb / FBG_N<area>_<name>)")
149
+ if "id" not in h:
150
+ raise HubError("[hub] missing required key 'id' (the hub field id, >= 4000)")
151
+ return HubSpec(
152
+ name=str(h["name"]),
153
+ id=int(h["id"]),
154
+ area=int(h.get("area", DEFAULT_AREA)),
155
+ borrow_bg=str(h.get("borrow_bg", "")),
156
+ borrow_field=int(h["borrow_field"]) if h.get("borrow_field") is not None else None,
157
+ camera=str(h.get("camera", DEFAULT_CAMERA)),
158
+ entry_settle=int(h.get("entry_settle", DEFAULT_ENTRY_SETTLE)),
159
+ text_block=int(h.get("text_block", DEFAULT_TEXT_BLOCK)),
160
+ prompt=str(h.get("prompt", DEFAULT_PROMPT)),
161
+ stay_text=str(h.get("stay_text", DEFAULT_STAY)),
162
+ player_model=h.get("player_model", MOOGLE_MODEL),
163
+ player_spawn=list(h["player_spawn"]) if "player_spawn" in h else None,
164
+ narrator=str(h.get("narrator", DEFAULT_NARRATOR)),
165
+ narrator_model=h.get("narrator_model"),
166
+ narrator_pos=list(h["narrator_pos"]) if "narrator_pos" in h else None,
167
+ props=[dict(p) for p in h.get("props", [])],
168
+ ambient_npcs=[dict(n) for n in h.get("ambient_npcs", [])],
169
+ journeys=journeys,
170
+ )
171
+
172
+
173
+ def load_journeys(path) -> HubSpec:
174
+ """Parse a ``journeys.toml`` into a :class:`HubSpec`. Raises :class:`HubError` on a STRUCTURAL problem
175
+ (no ``[hub]`` table, a ``[[journey]]`` missing ``id``/``entry``, or a multi-campaign journey that needs the
176
+ assembler); semantic checks (id band, dup ids, missing ``borrow_bg`` ...) are :func:`validate_hub`'s job so
177
+ the CLI can print them all at once."""
178
+ p = Path(path)
179
+ with open(p, "rb") as fh:
180
+ data = tomllib.load(fh)
181
+ if "hub" not in data:
182
+ raise HubError(f"{p}: not a journeys manifest (no [hub] table)")
183
+
184
+ journeys = []
185
+ for i, j in enumerate(data.get("journey", [])):
186
+ if "campaigns" in j or isinstance(j.get("entry"), dict):
187
+ raise HubError(f"[[journey]] #{i}: a multi-campaign journey (campaigns / entry = {{campaign, "
188
+ f"field}}) needs the journey ASSEMBLER (`ff9mapkit assemble-journey`, "
189
+ f"docs/JOURNEYS.md), not gen-hub. gen-hub builds the single-entry form: "
190
+ f"entry = <field id>.")
191
+ if "id" not in j:
192
+ raise HubError(f"[[journey]] #{i}: missing required key 'id' (the stable slug; docs/JOURNEYS.md)")
193
+ jid = str(j["id"])
194
+ if "entry" not in j:
195
+ raise HubError(f"[[journey]] {jid!r}: missing required key 'entry' (the journey's entry field id)")
196
+ sc = j.get("set_scenario")
197
+ ent = j.get("entrance")
198
+ journeys.append(Journey(
199
+ id=jid,
200
+ name=str(j.get("name") or _humanize(jid)),
201
+ entry=int(j["entry"]),
202
+ set_scenario=int(sc) if sc is not None else None,
203
+ entrance=int(ent) if ent is not None else None,
204
+ ))
205
+
206
+ return hubspec_from_table(data["hub"], journeys)
207
+
208
+
209
+ def validate_hub(spec: HubSpec) -> "tuple[list, list]":
210
+ """Validate a :class:`HubSpec`. Returns ``(errors, warnings)`` -- errors abort generation, warnings are
211
+ advisory (like :func:`ff9mapkit.campaign.lint_campaign`). The emitted field.toml then runs the kit's own
212
+ :func:`ff9mapkit.build.validate` at build time; this catches the hub-spec-level problems early + clearly."""
213
+ errors, warnings = [], []
214
+
215
+ if not spec.name or not _NAME_RE.match(spec.name):
216
+ errors.append(f"[hub] name {spec.name!r} must be a non-empty token (A-Z, 0-9, _) -- it becomes "
217
+ f"EVT_<name> / FBG_N<area>_<name>")
218
+ if not (4000 <= spec.id <= 32767):
219
+ errors.append(f"[hub] id {spec.id} out of range -- custom field ids are 4000-32767 (the live "
220
+ f"fldMapNo is Int16, so a higher id registers but is unreachable)")
221
+ if spec.area < 10:
222
+ errors.append(f"[hub] area {spec.area} must be >= 10 -- the BG-borrow loader builds 'FBG_N<area>' "
223
+ f"and reads 2 digits, so single-digit areas black-screen")
224
+ if not spec.borrow_bg:
225
+ errors.append("[hub] borrow_bg is required -- BG-borrow a real room (area >= 10) for the hub "
226
+ "backdrop. Authoring novel hub art is out of the generator's scope (paint layers + a "
227
+ "custom [camera]/[walkmesh] by hand, then hand-author the field.toml).")
228
+ if not spec.camera:
229
+ errors.append("[hub] camera is required -- the borrowed room's .bgx (extract it from your install; "
230
+ "it's gitignored, you supply the bytes). Or set borrow_field = <id> and run "
231
+ "`gen-hub --extract-camera` to cache it automatically.")
232
+ if spec.borrow_field is not None and not (isinstance(spec.borrow_field, int) and spec.borrow_field > 0):
233
+ errors.append(f"[hub] borrow_field {spec.borrow_field!r} must be a positive real field id (the room "
234
+ f"whose camera --extract-camera pulls into the cache)")
235
+ if not spec.journeys:
236
+ errors.append("a hub needs at least one [[journey]] -- nothing to select")
237
+
238
+ seen: set = set()
239
+ for i, j in enumerate(spec.journeys):
240
+ if not j.id or not _NAME_RE.match(j.id):
241
+ errors.append(f"[[journey]] #{i}: id {j.id!r} must be a token (A-Z, 0-9, _) -- the stable slug")
242
+ elif j.id in seen:
243
+ errors.append(f"[[journey]] id {j.id!r} is duplicated -- journey ids must be unique")
244
+ seen.add(j.id)
245
+ if not (isinstance(j.entry, int) and j.entry > 0):
246
+ errors.append(f"[[journey]] {j.id!r}: entry {j.entry!r} must be a positive field id "
247
+ f"(the warp destination)")
248
+ elif j.entry == spec.id:
249
+ warnings.append(f"[[journey]] {j.id!r}: entry {j.entry} is the hub itself -- picking it warps "
250
+ f"the hub onto itself")
251
+ if j.set_scenario is not None and not (0 <= j.set_scenario <= SCENARIO_MAX):
252
+ errors.append(f"[[journey]] {j.id!r}: set_scenario {j.set_scenario} out of range "
253
+ f"(0-{SCENARIO_MAX})")
254
+
255
+ spawn = spec.player_spawn or [0, 0] # the narrator defaults to the player spawn -> they'd overlap
256
+ npos = spec.narrator_pos if spec.narrator_pos is not None else spawn
257
+ if list(npos) == list(spawn):
258
+ warnings.append(f"[hub] narrator_pos {list(npos)} == player_spawn (or unset) -- the player spawns "
259
+ f"INSIDE the narrator. Set distinct player_spawn / narrator_pos a few units apart on "
260
+ f"the walkmesh (e.g. player [404,127], narrator [480,127] for a Gargan Roo backdrop).")
261
+
262
+ if spec.text_block == SHADOWED_TEXT_BLOCK:
263
+ warnings.append(f"[hub] text_block {SHADOWED_TEXT_BLOCK} is SHADOWED by the FF9CustomMap folder in a "
264
+ f"stacked setup -- the menu shows that folder's text, not yours. Pick a distinct real "
265
+ f"MesDB id; deploy_field's shadow check suggests free ones.")
266
+ rows = len(spec.journeys) + 1 # + the trailing stay row
267
+ if rows > PAGING_SOFT_MAX:
268
+ warnings.append(f"{rows} menu rows (journeys + stay) -- FF9 choice menus show ~4 at a time and "
269
+ f"scroll; verify the long list reads well in-game (paging / sub-hubs are a future "
270
+ f"enhancement).")
271
+
272
+ # set-dressing structural checks (unknown prop/archetype NAMES are caught by build.validate on the
273
+ # emitted field.toml -- here we just ensure each row is well-formed so the emit produces valid TOML).
274
+ def _check_pos(label, row):
275
+ if not (isinstance(row.get("pos"), (list, tuple)) and len(row["pos"]) == 2):
276
+ errors.append(f"{label}: needs pos = [x, z] (a point on the hub walkmesh)")
277
+ for k, p in enumerate(spec.props):
278
+ if p.get("prop") is None and p.get("model") is None:
279
+ errors.append(f"[[hub.props]] #{k}: needs a 'prop' (archetype name, e.g. \"save_point\") or "
280
+ f"'model' (a GEO id) + optional 'pose'")
281
+ _check_pos(f"[[hub.props]] #{k}", p)
282
+ for k, n in enumerate(spec.ambient_npcs):
283
+ if n.get("archetype") is None and n.get("model") is None:
284
+ errors.append(f"[[hub.ambient_npcs]] #{k}: needs an 'archetype' (name, e.g. \"moogle\") or "
285
+ f"'model' (a GEO id)")
286
+ _check_pos(f"[[hub.ambient_npcs]] #{k}", n)
287
+ return errors, warnings
288
+
289
+
290
+ def _q(s) -> str:
291
+ """A TOML-safe basic-string value (escape backslash + double-quote)."""
292
+ return str(s).replace("\\", "\\\\").replace('"', '\\"')
293
+
294
+
295
+ def _model_toml(v) -> str:
296
+ """Emit a model value: a numeric id (or digit string) bare, a GEO name quoted."""
297
+ if isinstance(v, int):
298
+ return str(v)
299
+ s = str(v)
300
+ return s if s.isdigit() else f'"{_q(s)}"'
301
+
302
+
303
+ def _prop_block(p: dict) -> list:
304
+ """A ``[[prop]]`` toml block from a ``[[hub.props]]`` row -- static set-dressing (a prop archetype by
305
+ name, e.g. ``save_point``/``lamp``, or a raw ``model`` + ``pose`` for a bare standing figure)."""
306
+ out = ["[[prop]]"]
307
+ if p.get("prop") is not None:
308
+ out.append(f'prop = "{_q(p["prop"])}"')
309
+ elif p.get("model") is not None:
310
+ out.append(f"model = {_model_toml(p['model'])}")
311
+ pos = p.get("pos") or [0, 0]
312
+ out.append(f"pos = [{int(pos[0])}, {int(pos[1])}]")
313
+ if p.get("pose") is not None:
314
+ out.append(f"pose = {_model_toml(p['pose'])}")
315
+ if p.get("face") is not None:
316
+ out.append(f"face = {int(p['face'])}")
317
+ out.append("")
318
+ return out
319
+
320
+
321
+ def _ambient_npc_block(n: dict, idx: int) -> list:
322
+ """A non-narrator ``[[npc]]`` block from a ``[[hub.ambient_npcs]]`` row -- a flavor character (talk -> its
323
+ ``dialogue`` line, if any). Resolved by ``archetype`` name or raw ``model``."""
324
+ out = ["[[npc]]", f'name = "{_q(n.get("name") or f"Ambient_{idx}")}"']
325
+ if n.get("archetype") is not None:
326
+ out.append(f'archetype = "{_q(n["archetype"])}"')
327
+ elif n.get("model") is not None:
328
+ out.append(f"model = {_model_toml(n['model'])}")
329
+ pos = n.get("pos") or [0, 0]
330
+ out.append(f"pos = [{int(pos[0])}, {int(pos[1])}]")
331
+ if n.get("dialogue"):
332
+ out.append(f'dialogue = "{_q(n["dialogue"])}"')
333
+ out.append("")
334
+ return out
335
+
336
+
337
+ def _area_title_lines(spec) -> list:
338
+ """Emit the area-title autohide when the hub BG-borrows a real AREA-TITLE room (Ice Cavern, Mognet
339
+ Central, ...): that room's localized title overlay is Active by default and the synthesized hub has no
340
+ donor ``.eb`` to retire it, so it would sit there statically claiming to be that place. Resolve the
341
+ overlay range OFFLINE (areatitle manifest) so the build needn't re-read resources.assets. Best-effort:
342
+ degrade to nothing if the manifest is unreachable (a non-area-title borrow returns nothing anyway)."""
343
+ try:
344
+ from . import areatitle
345
+ rng = areatitle.title_range(f"FBG_N{int(spec.area):02d}_{spec.borrow_bg}")
346
+ except Exception:
347
+ rng = None
348
+ if not rng:
349
+ return []
350
+ return [f"hide_area_title = true # borrows an area-title room but isn't that place --",
351
+ f"area_title_overlays = [{rng[0]}, {rng[1]}] # hide its title card (mapLocalizeAreaTitle.txt)"]
352
+
353
+
354
+ def render_hub_field_toml(spec: HubSpec, *, source: "str | None" = None) -> str:
355
+ """The hub ``field.toml`` text -- valid TOML the existing build/deploy path compiles. Mirrors the proven
356
+ hand-authored ``examples/world_hub/hub.field.toml`` shape (BG-borrow + Moogle PC + narrator + the journey
357
+ ``[[choice]]``). ``source`` (the journeys.toml name) is noted in the header comment."""
358
+ src = f" from {source}" if source else ""
359
+ cancel = len(spec.journeys) # the trailing stay row's 0-based index = number of journeys
360
+ spawn = spec.player_spawn or [0, 0]
361
+ npos = spec.narrator_pos or list(spawn)
362
+ L = [
363
+ "# ============================================================================",
364
+ f"# WORLD HUB -- generated by `ff9mapkit gen-hub`{src}.",
365
+ "# A journey selector: walk as the Moogle, talk to the narrator -> a menu of journeys ->",
366
+ "# each row warps you into that journey's entry field (the in-game-proven choice `warp`",
367
+ "# action + `[player] model=`). REGENERATE after editing the journeys.toml -- this file is a",
368
+ "# build artifact, hand edits are overwritten. (memory: project-ff9-world-hub)",
369
+ "# ============================================================================",
370
+ "",
371
+ "[field]",
372
+ f"id = {spec.id}",
373
+ f'name = "{_q(spec.name)}"',
374
+ f'borrow_bg = "{_q(spec.borrow_bg)}" # a real room as the backdrop (area >= 10)',
375
+ f"area = {spec.area}",
376
+ f"text_block = {spec.text_block} # a real MesDB id NOT shadowed by a higher mod folder",
377
+ *_area_title_lines(spec),
378
+ "",
379
+ "[camera]",
380
+ f'borrow = "{_q(spec.camera)}" # the borrowed room\'s camera (gitignored; extract from your install)',
381
+ *([f"entry_settle = {spec.entry_settle} # hold black on entry so the camera settles unseen "
382
+ f"(no warp-in ease); 0 = off"] if spec.entry_settle else []),
383
+ "",
384
+ "[player]",
385
+ f"spawn = [{spawn[0]}, {spawn[1]}]",
386
+ f"model = {_model_toml(spec.player_model)} # walk the hub as the Moogle ([player] model=)",
387
+ "",
388
+ "[[npc]]",
389
+ f'name = "{_q(spec.narrator)}" # the narrator -- talk to open the journey menu',
390
+ f"pos = [{npos[0]}, {npos[1]}]",
391
+ f"model = {_model_toml(spec.narrator_model_resolved)}",
392
+ "",
393
+ ]
394
+ if spec.props or spec.ambient_npcs:
395
+ L.append("# Set-dressing (author-customizable via [[hub.props]] / [[hub.ambient_npcs]]): static props")
396
+ L.append("# + ambient flavor NPCs. All BG-borrow; the proven prop/npc build path compiles them.")
397
+ L.append("")
398
+ for p in spec.props:
399
+ L += _prop_block(p)
400
+ for k, n in enumerate(spec.ambient_npcs):
401
+ L += _ambient_npc_block(n, k)
402
+ L += [
403
+ "# The journey menu: each option warps to a journey's entry field; set_scenario (optional) seeds",
404
+ "# the beat hub-side before the warp. The trailing row has no warp -- it just closes the menu.",
405
+ "[[choice]]",
406
+ f'npc = "{_q(spec.narrator)}"',
407
+ f'prompt = "{_q(spec.prompt)}"',
408
+ f"cancel = {cancel} # B / cancel -> the last row (no warp)",
409
+ "instant = true # pop the menu fully drawn ([IMME]) -- a selector, like FF9 shop menus",
410
+ "",
411
+ ]
412
+ for j in spec.journeys:
413
+ L.append("[[choice.options]]")
414
+ L.append(f'text = "{_q(j.name)}"')
415
+ L.append(f"warp = {j.entry}")
416
+ if j.set_scenario is not None:
417
+ L.append(f"set_scenario = {j.set_scenario}")
418
+ if j.entrance is not None:
419
+ L.append(f"entrance = {j.entrance} # arrival entrance -> frames the entry camera (no static frame)")
420
+ L.append("")
421
+ L.append("[[choice.options]]")
422
+ L.append(f'text = "{_q(spec.stay_text)}" # no warp -- closes the menu')
423
+ L.append("")
424
+ return "\n".join(L)
425
+
426
+
427
+ def _relpath(target, start_dir) -> str:
428
+ """A forward-slash path from ``start_dir`` to ``target`` -- repo-relative (portable across clones/OSes),
429
+ falling back to absolute only across Windows drives."""
430
+ target, start_dir = Path(target).resolve(), Path(start_dir).resolve()
431
+ try:
432
+ return Path(os.path.relpath(target, start_dir)).as_posix()
433
+ except ValueError: # different drives on Windows -> can't relativize
434
+ return target.as_posix()
435
+
436
+
437
+ def extract_camera_into_spec(spec: HubSpec, out_dir, *, game=None, force=False) -> dict:
438
+ """Pull the ``[hub] borrow_field`` room's camera into the gitignored workspace cache and point
439
+ ``spec.camera`` at that ONE central copy (a repo-relative path from ``out_dir``). Returns the
440
+ ``extract.cache_field`` result. Shared by :func:`generate` (gen-hub) and the journey assembler's hub emit
441
+ (:func:`ff9mapkit.journey.generate_hub`) so both auto-provision the borrowed camera identically. Needs the
442
+ install + UnityPy."""
443
+ if not spec.borrow_field:
444
+ raise HubError("camera extraction needs [hub] borrow_field = <real field id> (the room whose camera "
445
+ "to extract; e.g. borrow_field = 950). Or supply the [hub] camera .bgx yourself.")
446
+ from . import extract as _extract
447
+ extracted = _extract.cache_field(spec.borrow_field, game=game, force=force)
448
+ spec.camera = _relpath(extracted["camera"], Path(out_dir)) # point at the ONE central cache copy
449
+ return extracted
450
+
451
+
452
+ def generate(journeys_path, out_path=None, *, extract_camera=False, game=None, force=False) -> dict:
453
+ """Load a ``journeys.toml``, validate it, and emit the hub ``field.toml``. Returns a summary
454
+ ``{path, spec, warnings, journeys, extracted}``. Raises :class:`HubError` on a validation error.
455
+ ``out_path`` defaults to ``hub.field.toml`` beside the registry; a directory ``out_path`` writes
456
+ ``hub.field.toml`` inside it.
457
+
458
+ ``extract_camera`` (needs the install + UnityPy): pull the borrowed room's camera (``[hub]
459
+ borrow_field``) into the gitignored workspace cache once and point the emitted ``[camera] borrow`` at
460
+ that single central copy -- so ``gen-hub`` then build/deploy "just works", no manual extract step."""
461
+ journeys_path = Path(journeys_path)
462
+ spec = load_journeys(journeys_path)
463
+ errors, warnings = validate_hub(spec)
464
+ if errors:
465
+ raise HubError("journeys.toml validation failed:\n - " + "\n - ".join(errors))
466
+ out_path = Path(out_path) if out_path else (journeys_path.parent / "hub.field.toml")
467
+ if out_path.is_dir():
468
+ out_path = out_path / "hub.field.toml"
469
+
470
+ extracted = None
471
+ if extract_camera:
472
+ extracted = extract_camera_into_spec(spec, out_path.parent, game=game, force=force)
473
+
474
+ text = render_hub_field_toml(spec, source=journeys_path.name)
475
+ out_path.write_text(text, encoding="utf-8", newline="\n")
476
+ return {"path": out_path, "spec": spec, "warnings": warnings, "journeys": len(spec.journeys),
477
+ "extracted": extracted}
ff9mapkit/idgated.py ADDED
@@ -0,0 +1,101 @@
1
+ """Engine behaviors keyed on a field's real ``fldMapNo``/``fldLocNo`` that a FORK loses on a custom id --
2
+ the **lost-on-a-mint** axis of the fork-fidelity taxonomy (``docs/FORK_FIDELITY.md``), made per-field
3
+ queryable so ``fork-report`` can preview it.
4
+
5
+ When you fork a field it runs at a new custom id (>= 4000), so every engine special-case gated on the real id
6
+ silently stops firing. Most are internal (camera/position fixups); the USER-VISIBLE ones a fork loses are:
7
+
8
+ * **Walkmesh hotfix** -- a load-time/dynamic ``BGI_triSetActive`` (catalogued + sometimes auto-reproduced in
9
+ :mod:`ff9mapkit.walkmesh_hotfixes`). Referenced here so the lost-on-mint list is one place.
10
+ * **Narrow-map letterbox** -- the engine letterboxes a field narrower than widescreen (NarrowMapList, a
11
+ per-field width table); a fork defaults to width 500 (widescreen), so the side masking is lost and off-screen
12
+ party can draw over where the bars were. Widths baked in :mod:`ff9mapkit._narrowmap_data`.
13
+ * **Chocobo dig HUD** -- the live Hot&Cold timer/HUD is gated on ``fldMapNo`` 2950-2952 (``EventHUD.cs``).
14
+ * **Intro FMV** -- the field-70 opening movie is id-bound.
15
+ * **ATE achievement** -- a field's ATEs count toward the *ATE80* trophy via ``EMinigame.MappingATEID``, which
16
+ keys on ``fldLocNo`` (the field's LOCATION). The engine sets ``fldLocNo = eventIDToMESID[fldMapNo]``
17
+ (``HonoluluFieldMain.cs:19``) -- i.e. the field's registered MES/text-block id -- so we resolve it from the
18
+ baked :data:`ff9mapkit._fieldtext.EVENT_ID_TO_MES`. A mint runs at a custom id with a different text-block,
19
+ so its ATEs don't map to the trophy. The ATE itself still PLAYS; only the achievement bookkeeping is lost.
20
+
21
+ This is pure baked data (no install needed) -- safe to call from the install-free analysis path.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from . import walkmesh_hotfixes as _wh
26
+ from ._fieldtext import EVENT_ID_TO_MES as _EVENT_TO_MES
27
+ from ._narrowmap_data import FORK_DEFAULT_WIDTH, WIDTHS as _WIDTHS
28
+
29
+ # fldLocNo == the field's MES id (HonoluluFieldMain.cs:19). These LOCATIONS have ATE-seen trophy mappings
30
+ # (EMinigame.MappingATEID, lines 532-669 -- all `fldLocNo == N` cases; Memoria source, provenance-clean).
31
+ ATE_ACHIEVEMENT_LOCS = frozenset({4, 8, 32, 37, 40, 44, 47, 52, 53, 70, 88, 90,
32
+ 276, 289, 344, 358, 359, 485, 525, 595, 741, 943})
33
+
34
+ # ~16:9 of the 240px PSX height: a field narrower than this is letterboxed in-game, but a fork (width 500)
35
+ # renders widescreen, so the side letterbox masking is lost (the project-ff9-narrow-map-fork-letterbox bug).
36
+ WIDESCREEN_WIDTH = 426
37
+ CHOCOBO_HUD_FIELDS = frozenset({2950, 2951, 2952}) # EventHUD.cs: the live Chocobo Hot&Cold dig HUD
38
+ FMV_INTRO_FIELDS = frozenset({70}) # field-70 opening movie (Cinematic ops + MBG)
39
+
40
+
41
+ def _as_id(field):
42
+ try:
43
+ return int(field)
44
+ except (TypeError, ValueError):
45
+ return None
46
+
47
+
48
+ def narrow_map_width(field) -> int:
49
+ """The field's real PSX screen width (NarrowMapList), or the fork default (500) for an unlisted id."""
50
+ f = _as_id(field)
51
+ return _WIDTHS.get(f, FORK_DEFAULT_WIDTH) if f is not None else FORK_DEFAULT_WIDTH
52
+
53
+
54
+ def loses_letterbox(field) -> bool:
55
+ """True if the real field is narrower than widescreen, so a fork (default width 500) loses its letterbox."""
56
+ f = _as_id(field)
57
+ return f is not None and f in _WIDTHS and _WIDTHS[f] < WIDESCREEN_WIDTH
58
+
59
+
60
+ def field_loc_no(field):
61
+ """The field's ``fldLocNo`` (== its registered MES/text-block id, ``eventIDToMESID[fldMapNo]``), or None."""
62
+ f = _as_id(field)
63
+ return _EVENT_TO_MES.get(f) if f is not None else None
64
+
65
+
66
+ def has_ate_achievement(field) -> bool:
67
+ """True if the field's location has an ATE-seen trophy mapping (lost on a mint -- a different fldLocNo)."""
68
+ loc = field_loc_no(field)
69
+ return loc is not None and loc in ATE_ACHIEVEMENT_LOCS
70
+
71
+
72
+ def lost_on_mint(field) -> list:
73
+ """``[(label, detail), ...]`` for every USER-VISIBLE id-gated engine behavior a fork of ``field`` loses on
74
+ its custom id. Empty for most fields. The walkmesh entry notes whether the kit auto-reproduces it; the rest
75
+ steer to *fork in-place on the real id* (or accept the loss). Used by ``fork-report``."""
76
+ f = _as_id(field)
77
+ if f is None:
78
+ return []
79
+ out = []
80
+ h = _wh.info(f)
81
+ if h is not None:
82
+ if h.engine_remapped:
83
+ repro = "reproduced by the engine fork-donor remap"
84
+ elif h.auto:
85
+ repro = "auto-reproduced on fork"
86
+ else:
87
+ repro = "fork-in-place"
88
+ out.append(("walkmesh hotfix", f"{h.name} ({repro})"))
89
+ if loses_letterbox(f):
90
+ out.append(("narrow-map letterbox",
91
+ f"real width {_WIDTHS[f]} < widescreen; a fork renders widescreen "
92
+ f"(width {FORK_DEFAULT_WIDTH}) so the letterbox masking is lost"))
93
+ if f in CHOCOBO_HUD_FIELDS:
94
+ out.append(("Chocobo dig HUD", "the live Hot&Cold HUD is gated on fldMapNo 2950-2952 -> fork in-place"))
95
+ if f in FMV_INTRO_FIELDS:
96
+ out.append(("intro FMV", "the field-70 opening movie is id-bound -> retarget the stock field-70 override"))
97
+ if has_ate_achievement(f):
98
+ out.append(("ATE achievement",
99
+ f"this location (fldLocNo {field_loc_no(f)}) has an ATE-seen trophy (EMinigame.MappingATEID); "
100
+ f"a mint's different fldLocNo loses the ATE80 bookkeeping (the ATE still plays) -> fork in-place"))
101
+ return out