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/catalog.py ADDED
@@ -0,0 +1,316 @@
1
+ """The Info Hub: a unified, read-only view over the kit's baked FF9 reference catalogs.
2
+
3
+ Where the two authoring pillars are *spatial* (Blender -> ``scene.toml``) and *logic* (the editor ->
4
+ ``field.toml``), this is the *library* pillar -- the shared game-object data that lives outside any one
5
+ field: which **models**, **animations**, **items**, **battle scenes**, and **fields** the engine knows
6
+ about. It is pure-Python identifier data baked from Memoria's open-source tables (no game bytes, no
7
+ install needed); see ``docs/PROVENANCE.md``.
8
+
9
+ The headline feature is the **model -> animations** join. FF9 has no standalone "NPC" object: an NPC
10
+ is a model id + animation ids placed inline in a field. A model name ``GEO_<group>_<form>_<token>`` and
11
+ an animation name ``ANH_<group>_<form>_<token>_<action>`` share a (group, token); so a model's gestures
12
+ are the anims with the same (group, token). Verified end-to-end: model id 8 = ``GEO_MAIN_F0_VIV``, and
13
+ ``animations_for_model(8)`` yields idle=148 / walk=571 / run=419 / turn_l=917 / turn_r=918 -- exactly
14
+ the kit's built-in ``vivi`` preset.
15
+
16
+ Usage::
17
+
18
+ from ff9mapkit import catalog
19
+ catalog.models("npc", group="NPC") # browse townsfolk models
20
+ m = catalog.model(8) # Model(id=8, name='GEO_MAIN_F0_VIV', token='VIV', ...)
21
+ catalog.animations_for_model("GEO_NPC_F0_BAR") # {action: anim_id} a model can play
22
+ catalog.battle_scenes("alex") # encounter ids by region
23
+ catalog.search("vivi") # cross-kind discovery
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import difflib
29
+ from typing import NamedTuple, Optional
30
+
31
+ from ._animdb_all import ANIMATIONS
32
+ from ._fieldtable import FBG_TO_EVT, FIELD_BY_ID
33
+ from ._itemdb import ITEMS
34
+ from ._modeldb import MODELS
35
+ from ._scenedb import SCENES
36
+ from .animations import TOKENS as _CHAR_ALIAS # friendly playable name -> token (vivi -> VIV)
37
+
38
+ # GEO/ANH group code -> human label (the model's role).
39
+ GROUP_KIND = {
40
+ "MAIN": "playable",
41
+ "NPC": "npc",
42
+ "MON": "monster",
43
+ "ACC": "object",
44
+ "SUB": "sub-character",
45
+ "WEP": "weapon",
46
+ }
47
+ # form-code first letter -> the pose family the model belongs to.
48
+ FORM_KIND = {"F": "field", "B": "battle", "W": "world"}
49
+
50
+
51
+ class Model(NamedTuple):
52
+ """One actor/field model. ``id`` is what ``SetModel()`` takes; ``token`` ties it to its anims."""
53
+ id: int
54
+ name: str # GEO_<group>_<form>_<token>
55
+ group: str # MAIN / NPC / MON / ACC / SUB / WEP
56
+ form: str # F0 / B3 / W0 ...
57
+ token: str # VIV / BAR / EGG / 000 ...
58
+ kind: str # playable / npc / monster / object / sub-character / weapon
59
+ field: bool # True for field-form (F*) models -- the ones you place as a field NPC
60
+
61
+
62
+ def _parse_geo(name: str):
63
+ p = name.split("_") # GEO ACC F0 EGG
64
+ grp = p[1] if len(p) > 1 else ""
65
+ form = p[2] if len(p) > 2 else ""
66
+ token = p[3] if len(p) > 3 else ""
67
+ return grp, form, token
68
+
69
+
70
+ def _model_info(mid: int, name: str) -> Model:
71
+ grp, form, token = _parse_geo(name)
72
+ return Model(mid, name, grp, form, token,
73
+ GROUP_KIND.get(grp, "other"), form[:1] == "F")
74
+
75
+
76
+ # ---------------------------------------------------------------- models -----
77
+ def all_models() -> list:
78
+ """Every model as a :class:`Model`, sorted by name (so groups cluster)."""
79
+ return [_model_info(mid, MODELS[mid]) for mid in sorted(MODELS, key=lambda i: (MODELS[i], i))]
80
+
81
+
82
+ def model(name_or_id) -> Optional[Model]:
83
+ """Look up one model by id (int / digit-string) or exact GEO name (case-insensitive). None if
84
+ unknown. (For a name with a typo, use :func:`resolve_model`, which suggests near-misses.)"""
85
+ if isinstance(name_or_id, bool):
86
+ return None
87
+ if isinstance(name_or_id, int) or (isinstance(name_or_id, str) and name_or_id.strip().isdigit()):
88
+ mid = int(name_or_id)
89
+ return _model_info(mid, MODELS[mid]) if mid in MODELS else None
90
+ key = str(name_or_id).strip().upper()
91
+ for mid, nm in MODELS.items():
92
+ if nm.upper() == key:
93
+ return _model_info(mid, nm)
94
+ return None
95
+
96
+
97
+ def models(query=None, *, group=None, field_only=False) -> list:
98
+ """Filtered model list. ``query`` = substring of the GEO name or token, OR a friendly playable name
99
+ ('vivi'/'dagger' -> its token); ``group`` = a group code ('NPC') or kind label ('npc');
100
+ ``field_only`` keeps field-form models (the ones you place as a field NPC)."""
101
+ grp = (group or "").upper()
102
+ grp_kind = (group or "").lower()
103
+ q = (query or "").lower()
104
+ alias = _CHAR_ALIAS.get(q) # 'vivi' -> 'VIV' so a friendly name finds the model
105
+ out = []
106
+ for m in all_models():
107
+ if field_only and not m.field:
108
+ continue
109
+ if group and m.group != grp and m.kind != grp_kind:
110
+ continue
111
+ if q and q not in m.name.lower() and q not in m.token.lower() and not (alias and m.token == alias):
112
+ continue
113
+ out.append(m)
114
+ return out
115
+
116
+
117
+ def resolve_model(name_or_id) -> int:
118
+ """Resolve a model NAME or id to its numeric id (what ``SetModel`` wants). Raises ValueError with
119
+ near-miss suggestions on an unknown name / out-of-table id."""
120
+ if isinstance(name_or_id, bool):
121
+ raise ValueError("model cannot be a boolean")
122
+ m = model(name_or_id)
123
+ if m:
124
+ return m.id
125
+ if isinstance(name_or_id, int) or str(name_or_id).strip().isdigit():
126
+ raise ValueError(f"model id {int(name_or_id)} not in the GEO table")
127
+ names = {nm.upper(): nm for nm in MODELS.values()}
128
+ hints = difflib.get_close_matches(str(name_or_id).strip().upper(), list(names), n=6, cutoff=0.4)
129
+ extra = f" Did you mean: {', '.join(names[h] for h in hints)}?" if hints else \
130
+ " Run `ff9mapkit models` to browse them."
131
+ raise ValueError(f"unknown model {name_or_id!r}.{extra}")
132
+
133
+
134
+ # ------------------------------------------------------------ animations -----
135
+ def animation_name(anim_id) -> Optional[str]:
136
+ """The full anim name for an id (7302 -> 'ANH_MAIN_F0_VIV_TALK_3_1'), or None for an unknown
137
+ or non-numeric id (honors the 'or None' contract instead of raising on e.g. a bad string)."""
138
+ try:
139
+ return ANIMATIONS.get(int(anim_id))
140
+ except (TypeError, ValueError):
141
+ return None
142
+
143
+
144
+ def _split_anh(name: str):
145
+ """(group, form, token, action_lower) for an ``ANH_..`` name, or None."""
146
+ p = name.split("_") # ANH MAIN F0 VIV TALK 3 1
147
+ if len(p) < 5 or p[0] != "ANH":
148
+ return None
149
+ return p[1], p[2], p[3], "_".join(p[4:]).lower()
150
+
151
+
152
+ def _form_rank(form: str):
153
+ """Sort key preferring field forms (F*, by number) over battle/world -- the field gesture wins."""
154
+ if form[:1] == "F" and form[1:].isdigit():
155
+ return (0, int(form[1:]))
156
+ return (1, 0)
157
+
158
+
159
+ def animations_for_model(name_or_id) -> dict:
160
+ """``{action_label: anim_id}`` -- the gestures a model can play, by the (group, token) join.
161
+
162
+ Field forms are preferred when an action exists in more than one form (the field clip is what an
163
+ on-field NPC uses); ties break to the smaller id. Returns ``{}`` for a model with no matching anims
164
+ (e.g. a numbered battle-only token). Standard movement actions appear as idle/walk/run/turn_l/turn_r.
165
+ """
166
+ m = model(name_or_id)
167
+ if not m or not m.token:
168
+ return {}
169
+ best = {} # action -> (form_rank, id)
170
+ for aid, nm in ANIMATIONS.items():
171
+ s = _split_anh(nm)
172
+ if not s or s[0] != m.group or s[2] != m.token:
173
+ continue
174
+ rank = (_form_rank(s[1]), aid)
175
+ if s[3] not in best or rank < best[s[3]]:
176
+ best[s[3]] = rank
177
+ return {action: rank_id[1] for action, rank_id in best.items()}
178
+
179
+
180
+ def animation_actions(name_or_id) -> list:
181
+ """Sorted ``[(action_label, anim_id), ...]`` for a model (for display / the CLI)."""
182
+ return sorted(animations_for_model(name_or_id).items())
183
+
184
+
185
+ # the five field-NPC animation slots the injector drives (``content.npc.ANIM_ORDER``) and the join
186
+ # action each is resolved from. The engine plays a clip by NAME, so any id naming the right clip works.
187
+ NPC_SLOT_ACTION = {"stand": "idle", "walk": "walk", "run": "run", "left": "turn_l", "right": "turn_r"}
188
+
189
+
190
+ def npc_anims(name_or_id, *, use_catalog: bool = True) -> dict:
191
+ """``{stand, walk, run, left, right}`` animation ids to place a model as a field NPC -- the Info
192
+ Hub's payoff: ANY model becomes ready to drop in.
193
+
194
+ Each movement slot the field engine drives is resolved from the model's OWN gestures
195
+ (:func:`animations_for_model`) with graceful fallbacks (missing run -> walk, missing turn -> idle),
196
+ so a slot never holds a foreign clip. For ``GEO_MAIN_F0_VIV`` this reproduces the built-in ``vivi``
197
+ preset (by clip name). Returns ``{}`` for a model with no field gestures (a battle-only / effect
198
+ model) -- give explicit ``anims`` for those.
199
+
200
+ For a model in the baked per-model catalog (:data:`ff9mapkit._npcparams.NPC_PARAMS` -- 156 GEO_NPC/MON
201
+ rigs), the REAL clips that rig uses as an NPC are returned verbatim (the most faithful set -- e.g. the
202
+ moogle's exact 2904/2927/2907/2923/2911, not the by-name join's near-miss). Off-catalog models (incl.
203
+ party GEO_MAIN, so the vivi preset stays) keep the gesture-name resolution below. ``use_catalog=False``
204
+ forces the by-NAME gesture resolution (used by the archetype-gallery completeness guard, which asks
205
+ "does this model auto-resolve by GESTURE NAME" -- a stricter bar than "has real clips in the catalog")."""
206
+ if use_catalog:
207
+ from ._npcparams import NPC_PARAMS
208
+ try:
209
+ mid = resolve_model(name_or_id)
210
+ except (ValueError, TypeError): # not a known model -> fall through to by-name (-> {})
211
+ mid = None
212
+ if mid is not None and mid in NPC_PARAMS:
213
+ return dict(NPC_PARAMS[mid]["anims"])
214
+ a = animations_for_model(name_or_id)
215
+ if not a:
216
+ return {}
217
+
218
+ def pick(*actions):
219
+ for act in actions:
220
+ if act in a:
221
+ return a[act]
222
+ return None
223
+
224
+ stand = pick("idle", "walk", "run")
225
+ if stand is None: # nothing standable -> not a usable field-NPC model
226
+ return {}
227
+ return {
228
+ "stand": stand,
229
+ "walk": pick("walk", "run", "idle") or stand,
230
+ "run": pick("run", "walk", "idle") or stand,
231
+ "left": pick("turn_l", "turn_r", "idle") or stand,
232
+ "right": pick("turn_r", "turn_l", "idle") or stand,
233
+ }
234
+
235
+
236
+ # ----------------------------------------------------------- battle scenes ---
237
+ def _is_model_bucket(name) -> bool:
238
+ """A ``BSC_B3_*`` name is the MODEL bucket (176 entries) -- a model holder, NOT a fightable encounter.
239
+ Picking one as a field's random-battle scene crashes in-game (``InitBattleScene`` null-ref; see the
240
+ ``scene_name`` note + ``eb/opcodes.py``). The two scene namespaces are disjoint (this published BSC_ table
241
+ vs the install's region-coded loadable set), so there's no install cross-ref -- this name rule IS the guard."""
242
+ return bool(name) and str(name).upper().startswith("BSC_B3_")
243
+
244
+
245
+ def battle_scenes(query=None, *, include_model_bucket=False) -> list:
246
+ """``[(name, id), ...]`` sorted by name; ``query`` filters by name substring (case-insensitive). The
247
+ ``BSC_B3_*`` MODEL bucket (176 non-fightable model holders that crash as an encounter) is EXCLUDED by
248
+ default -- it's not a pickable encounter; pass ``include_model_bucket=True`` for the raw table."""
249
+ q = (query or "").lower()
250
+ return sorted((nm, sid) for nm, sid in SCENES.items()
251
+ if (include_model_bucket or not _is_model_bucket(nm)) and (not q or q in nm.lower()))
252
+
253
+
254
+ def is_model_bucket_scene(scene_id) -> bool:
255
+ """True if an encounter id resolves to a ``BSC_B3_*`` model-bucket name (not fightable) -- the build/Check
256
+ lint uses this to flag a hand-authored or mis-picked ``[encounter] scene``."""
257
+ return _is_model_bucket(scene_name(scene_id))
258
+
259
+
260
+ _SCENE_BY_ID = None
261
+
262
+
263
+ def scene_name(scene_id) -> "str | None":
264
+ """The published BSC_ name for an encounter id, or None if the id isn't in the table (the table is the
265
+ full published name<->id map, NOT a guarantee the scene's DATA loads -- e.g. the BSC_B3_* model bucket)."""
266
+ global _SCENE_BY_ID
267
+ if _SCENE_BY_ID is None:
268
+ _SCENE_BY_ID = {}
269
+ for nm, sid in SCENES.items():
270
+ _SCENE_BY_ID.setdefault(sid, nm) # first name wins if an id has aliases
271
+ return _SCENE_BY_ID.get(int(scene_id))
272
+
273
+
274
+ def resolve_scene(name_or_id) -> int:
275
+ """Resolve a battle-scene NAME (BSC_..) or id to its numeric encounter id. A raw id passes through
276
+ unchanged (the table isn't exhaustive of every valid id). Raises ValueError on an unknown name."""
277
+ if isinstance(name_or_id, bool):
278
+ raise ValueError("scene cannot be a boolean")
279
+ if isinstance(name_or_id, int) or str(name_or_id).strip().isdigit():
280
+ return int(name_or_id)
281
+ key = str(name_or_id).strip().upper()
282
+ by_name = {nm.upper(): sid for nm, sid in SCENES.items()}
283
+ if key in by_name:
284
+ return by_name[key]
285
+ hints = difflib.get_close_matches(key, list(by_name), n=6, cutoff=0.4)
286
+ extra = f" Did you mean: {', '.join(hints)}?" if hints else " Run `ff9mapkit scenes` to browse them."
287
+ raise ValueError(f"unknown battle scene {name_or_id!r}.{extra}")
288
+
289
+
290
+ # -------------------------------------------------- items / fields (thin) ----
291
+ def items(query=None) -> list:
292
+ """``[(id, name), ...]`` (excludes the NoItem sentinel); ``query`` filters by name substring."""
293
+ q = (query or "").lower()
294
+ return sorted((i, n) for i, n in ITEMS.items() if n != "NoItem" and (not q or q in n.lower()))
295
+
296
+
297
+ def fields(query=None) -> list:
298
+ """``[(fbg_folder, field_id, evt_name), ...]`` for EVERY field (id-keyed, so the ~142 fields that SHARE a
299
+ background folder with another -- the same room at a different story beat -- BOTH appear, each with its own
300
+ event); ``query`` filters by fbg/evt substring."""
301
+ q = (query or "").lower()
302
+ out = [(fbg, fid, evt) for fid, (fbg, evt) in FIELD_BY_ID.items()
303
+ if not q or q in fbg.lower() or q in evt.lower()]
304
+ return sorted(out, key=lambda r: (r[0], r[1]))
305
+
306
+
307
+ # ----------------------------------------------------------- cross-kind ------
308
+ def search(query: str) -> dict:
309
+ """Discovery across every catalog: ``{'models': [...], 'items': [...], 'scenes': [...],
310
+ 'fields': [...]}`` matching ``query`` (substring). The Info Hub's "grab anything by name"."""
311
+ return {
312
+ "models": models(query),
313
+ "items": items(query),
314
+ "scenes": battle_scenes(query),
315
+ "fields": fields(query),
316
+ }
ff9mapkit/chain.py ADDED
@@ -0,0 +1,358 @@
1
+ """import-chain (P1, read-only): walk FF9's walkable-door graph out from a seed field, across zones.
2
+
3
+ Single-field ``import`` extracts one field's gateway edges but leaves each ``to`` pointing back into the
4
+ live game. ``import-chain`` follows those edges: from a seed it walks the connected region of real fields,
5
+ classifies every connection (walk-in gateway / scripted teleport / overworld exit -- see
6
+ :func:`ff9mapkit.eventscan.scan_all_warps`), bounds the walk (zones, hops, a hard field cap), and renders
7
+ the graph for scoping. Emitting ``campaign.toml`` + the per-field forks (with edges retargeted among the
8
+ chain's own new ids) is P2; this module is P1: discovery + ``--dry-run``.
9
+
10
+ The walk is PURE: it takes ``scan_fn(id)`` and ``zone_fn(id)`` callbacks, so it unit-tests on a synthetic
11
+ graph with no game install. The CLI wires the real :class:`ff9mapkit.extract.EventBundle`-backed scan.
12
+
13
+ Grounded in a live byte-survey (2026-06-09): ~41% of real connectivity is scripted (not walk-in), every
14
+ warp target is a literal (FF9 never computes a warp id), WorldMap operands are overworld LOCATION ids
15
+ (9000-9012) not fields, and only ~2.9% of region exits are story-conditional (stacked same-zone doors)."""
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections import OrderedDict, deque
20
+ from dataclasses import dataclass
21
+
22
+ WALK_IN = "walk_in"
23
+ SCRIPTED = "scripted"
24
+ OVERWORLD = "overworld_exit"
25
+
26
+ DEFAULT_DENYLIST = frozenset({100}) # field 100 (Alexandria) crashes in this setup -- CLAUDE.md §5
27
+ DEFAULT_MAX_FIELDS = 25
28
+ # Generous: within a --zones scope, zones + max_fields are the real bound; a tight hop cap would sever a
29
+ # long linear dungeon (Ice Cavern is 13 screens deep). Lower it for an unscoped --cross-zones sweep.
30
+ DEFAULT_MAX_HOPS = 20
31
+
32
+
33
+ def zone_label(folder) -> str:
34
+ """Zone token of an FBG folder: ``'fbg_n05_iccv_map085_ic_ent_0'`` -> ``'iccv'``. None/odd -> ``'?'``."""
35
+ if not folder:
36
+ return "?"
37
+ parts = str(folder).split("_")
38
+ return parts[2] if len(parts) >= 3 else str(folder)
39
+
40
+
41
+ # --------------------------------------------------------------------------- field-id clustering / ranges
42
+ # FF9 stores "same place, different story state" as SEPARATE field ids that share the background art -- a
43
+ # revisited zone's ids cluster by visit, separated by big id gaps (Alexandria town: 100-117 opening, 1850-1865
44
+ # return, 2450-2457 ruined, 3000 ending). These helpers split a zone by those gaps (so a region fork can scope
45
+ # to ONE visit instead of the whole 48-screen zone) and parse/format the compact id-range string the catalog
46
+ # stores in `members` and import-chain reads from `--ids`.
47
+ DEFAULT_CLUSTER_GAP = 120 # sits in the dead band (98, 135) measured from ID_TO_FBG: the largest WITHIN-visit
48
+ # gap is 98 (evft 152->250, an orphaned cutscene screen) and the smallest BETWEEN-
49
+ # visit gap is 135 (alxc 1866->2001) -- so 120 keeps each visit whole AND splits all
50
+ # distinct visits (margins ~22/15 ids; widen/narrow via --gap if fields are added)
51
+
52
+
53
+ def id_clusters(ids, gap: int = DEFAULT_CLUSTER_GAP) -> list:
54
+ """Split a sorted-unique list of field ids into clusters wherever consecutive ids jump by more than
55
+ ``gap`` -- each cluster is one story-state visit of a revisited zone. ``[]`` for no ids. PURE."""
56
+ seq = sorted({int(x) for x in ids})
57
+ if not seq:
58
+ return []
59
+ out, cur = [], [seq[0]]
60
+ for a, b in zip(seq, seq[1:]):
61
+ if b - a > gap:
62
+ out.append(cur)
63
+ cur = [b]
64
+ else:
65
+ cur.append(b)
66
+ out.append(cur)
67
+ return out
68
+
69
+
70
+ def format_id_ranges(ids) -> str:
71
+ """Render a set of ids as a compact, sorted range string: ``[100..117, 150, 200..202]`` ->
72
+ ``'100-117,150,200-202'``. ``''`` for empty. The inverse of :func:`parse_id_ranges`."""
73
+ seq = sorted({int(x) for x in ids})
74
+ if not seq:
75
+ return ""
76
+ spans, start, prev = [], seq[0], seq[0]
77
+ for n in seq[1:]:
78
+ if n == prev + 1:
79
+ prev = n
80
+ continue
81
+ spans.append((start, prev))
82
+ start = prev = n
83
+ spans.append((start, prev))
84
+ return ",".join(f"{a}-{b}" if a != b else f"{a}" for a, b in spans)
85
+
86
+
87
+ def parse_id_ranges(spec) -> list:
88
+ """Parse a compact id-range string (``'100-117,150,200-202'``) -> a sorted-unique ``list[int]``. Accepts
89
+ spaces, an already-list/tuple of ints, or an empty value (-> ``[]``). Raises ``ValueError`` on a malformed
90
+ token or a reversed range. The inverse of :func:`format_id_ranges`."""
91
+ if spec is None or spec == "":
92
+ return []
93
+ if isinstance(spec, (list, tuple, set)):
94
+ return sorted({int(x) for x in spec})
95
+ out: set = set()
96
+ for tok in str(spec).replace(" ", "").split(","):
97
+ if not tok:
98
+ continue
99
+ if "-" in tok.lstrip("-"): # a range 'A-B' (lstrip guards a leading '-' that isn't a sep)
100
+ a, _, b = tok.partition("-")
101
+ lo, hi = int(a), int(b)
102
+ if hi < lo:
103
+ raise ValueError(f"reversed id range {tok!r} (low-high)")
104
+ out.update(range(lo, hi + 1))
105
+ else:
106
+ out.add(int(tok))
107
+ return sorted(out)
108
+
109
+
110
+ @dataclass
111
+ class GraphResult:
112
+ """Outcome of a walk. ``nodes`` is an insertion-ordered (BFS discovery order) id -> info map; that
113
+ order is also the id-assignment order P2 will use (``campaign_id_base + i``)."""
114
+
115
+ nodes: "OrderedDict" # id -> {zone, found, edges, overworld_exits, encounter, music, hop}
116
+ portals: list # edges NOT followed (zone boundary / stop-at / denylist / max-hops)
117
+ seams: list # scripted teleport edges not followed (author by hand)
118
+ unforkable: list # edges to targets with no FBG/background (shop/menu, variant, cutscene-only)
119
+ seeds: list
120
+ allowed_zones: object # set | None (None = any zone)
121
+ truncated: bool # hit max_fields with more queued
122
+ remaining: int # fields still queued when truncated
123
+ bounds: dict
124
+
125
+
126
+ def walk(seed_ids, scan_fn, zone_fn, *, forkable_fn=None, max_hops=DEFAULT_MAX_HOPS, zones=None,
127
+ stop_at=None, max_fields=DEFAULT_MAX_FIELDS, follow_scripted=False, denylist=DEFAULT_DENYLIST,
128
+ stop_at_zone_boundary=True, restrict_ids=None) -> GraphResult:
129
+ """Bounded BFS over the door graph.
130
+
131
+ ``scan_fn(field_id)`` -> ``{found: bool, edges: [{to, kind, entrance?, zone?, story_conditional?,
132
+ trigger?}], overworld_exits: [...], encounter, music}`` (``found=False`` for an id with no scannable
133
+ ``.eb`` -- world/special field -- terminating that branch). ``zone_fn(field_id)`` -> zone label.
134
+ ``forkable_fn(field_id)`` -> True if the target is a real WALKABLE field (has a background in the
135
+ FBG table); False for a shop/menu / variant / cutscene-only id that has no room to fork. A non-forkable
136
+ target is recorded in ``unforkable`` and not followed -- BEFORE the zone test, so a shop door reads as
137
+ 'menu/no-bg', not a bogus zone-boundary portal. Defaults to always-forkable (pure-graph unit tests).
138
+
139
+ Scope: if ``zones`` is given, only targets in those zones are followed; otherwise, with
140
+ ``stop_at_zone_boundary`` (default) the walk stays within the seed's own zone(s). ``restrict_ids`` (an
141
+ EXPLICIT id set, e.g. import-chain ``--ids``) bounds the walk to exactly those fields regardless of zone --
142
+ an edge to a field OUTSIDE the set is recorded as a portal, never followed (so one story-state cluster
143
+ doesn't leak its same-zone sibling visits). Either way an edge rejected SOLELY for being out of scope is
144
+ recorded as a PORTAL (so you see where the region connects). Scripted edges are recorded as SEAMS and not
145
+ followed unless ``follow_scripted``. ``max_fields`` is a hard cap with a loud ``truncated`` flag rather
146
+ than silently forking the whole game."""
147
+ forkable_fn = forkable_fn or (lambda fid: True)
148
+ seeds = [int(s) for s in (seed_ids if isinstance(seed_ids, (list, tuple, set)) else [seed_ids])]
149
+ stop_at = {int(x) for x in (stop_at or ())}
150
+ deny = {int(x) for x in (denylist or ())}
151
+ restrict = {int(x) for x in restrict_ids} if restrict_ids is not None else None
152
+ if zones is not None:
153
+ allowed = set(zones)
154
+ elif stop_at_zone_boundary:
155
+ allowed = {zone_fn(s) for s in seeds}
156
+ else:
157
+ allowed = None
158
+
159
+ nodes: "OrderedDict" = OrderedDict()
160
+ portals: list = []
161
+ seams: list = []
162
+ unforkable: list = []
163
+ visited: set = set()
164
+ q: deque = deque()
165
+ for s in seeds:
166
+ if s not in visited:
167
+ visited.add(s)
168
+ q.append((s, 0))
169
+
170
+ truncated = False
171
+ while q:
172
+ fid, hop = q.popleft()
173
+ if len(nodes) >= max_fields:
174
+ truncated = True
175
+ break
176
+ node = scan_fn(fid)
177
+ info = {
178
+ "zone": zone_fn(fid), "found": bool(node.get("found")),
179
+ "edges": node.get("edges", []), "overworld_exits": node.get("overworld_exits", []),
180
+ "encounter": node.get("encounter"), "music": node.get("music"), "hop": hop,
181
+ }
182
+ nodes[fid] = info
183
+ if not info["found"]:
184
+ continue
185
+ walkin_targets = {int(e["to"]) for e in info["edges"] if e["kind"] == WALK_IN}
186
+ for e in info["edges"]:
187
+ to = int(e["to"])
188
+ kind = e["kind"]
189
+ if kind == OVERWORLD:
190
+ continue # never a graph edge (it's an overworld loc id)
191
+ tzone = zone_fn(to)
192
+ if kind == SCRIPTED and not follow_scripted:
193
+ if to not in walkin_targets: # don't double-report a connection that's
194
+ seams.append({"from": fid, "to": to, "entrance": e.get("entrance"), # also a real door
195
+ "trigger": e.get("trigger"), "to_zone": tzone})
196
+ continue
197
+ if to in visited:
198
+ continue
199
+ if not forkable_fn(to): # shop/menu / variant / no-background id:
200
+ unforkable.append({"from": fid, "to": to}) # can't fork a room -- classify before zone test
201
+ continue
202
+ reason = None
203
+ if restrict is not None and to not in restrict:
204
+ reason = "not-in-set" # --ids: outside the explicit cluster -> a portal
205
+ elif to in stop_at:
206
+ reason = "stop-at"
207
+ elif allowed is not None and tzone not in allowed:
208
+ reason = f"zone:{tzone}"
209
+ elif to in deny:
210
+ reason = "denylist"
211
+ elif hop + 1 > max_hops:
212
+ reason = "max-hops"
213
+ if reason:
214
+ portals.append({"from": fid, "to": to, "kind": kind, "to_zone": tzone, "reason": reason})
215
+ continue
216
+ visited.add(to)
217
+ q.append((to, hop + 1))
218
+
219
+ # when truncated we broke right after popping an unprocessed field, so it counts as unexplored too
220
+ return GraphResult(
221
+ nodes=nodes, portals=portals, seams=seams, unforkable=unforkable, seeds=seeds,
222
+ allowed_zones=allowed, truncated=truncated, remaining=(len(q) + 1 if truncated else 0),
223
+ bounds={"max_hops": max_hops, "max_fields": max_fields, "zones": list(zones) if zones else None,
224
+ "follow_scripted": follow_scripted, "stop_at_zone_boundary": stop_at_zone_boundary,
225
+ "restrict_ids": sorted(restrict) if restrict is not None else None},
226
+ )
227
+
228
+
229
+ def zone_coverage(result: GraphResult, zone_members_fn) -> dict:
230
+ """How much of each touched zone the walk actually forked: ``{zone: (reached, total, [unreached_ids])}``.
231
+ ``zone_members_fn(zone)`` -> the set of ALL forkable field ids in that zone (the static FBG table). A low
232
+ ratio flags an ISOLATED seed -- e.g. Evil Forest seeded at 152 forks 1 of 13 (the rest aren't door-reachable
233
+ from that screen). PURE: the membership comes from a callback so :mod:`chain` stays game-free. Only zones the
234
+ walk forked at least one field in are reported (the seed's own zone(s) + any followed neighbour)."""
235
+ forked_by_zone: dict = {}
236
+ for fid, info in result.nodes.items():
237
+ if info.get("found"):
238
+ forked_by_zone.setdefault(info["zone"], set()).add(int(fid))
239
+ out: dict = {}
240
+ for zone, forked in forked_by_zone.items():
241
+ total = {int(x) for x in (zone_members_fn(zone) or ())}
242
+ unreached = sorted(total - forked)
243
+ out[zone] = (len(forked & total) if total else len(forked), len(total), unreached)
244
+ return out
245
+
246
+
247
+ def render_coverage(coverage: dict) -> list:
248
+ """Lines flagging under-forked zones (reached < total) -- the 'isolated seed' hint. ``[]`` if every touched
249
+ zone is fully covered (e.g. a --whole-zone fork)."""
250
+ lines: list = []
251
+ for zone, (reached, total, unreached) in sorted(coverage.items()):
252
+ if total and reached < total:
253
+ shown = ", ".join(map(str, unreached[:12])) + (" ..." if len(unreached) > 12 else "")
254
+ lines.append(f" zone {zone}: forked {reached} of {total} forkable fields -- {len(unreached)} "
255
+ f"NOT door-reachable from the seed ({shown}). Try --whole-zone (or a connected seed).")
256
+ return lines
257
+
258
+
259
+ def _walkin_summary(info):
260
+ """('->' destinations with x{n} stacking, story_conditional?) for a node's walk-in edges."""
261
+ counts: "OrderedDict" = OrderedDict()
262
+ cond = False
263
+ for e in info["edges"]:
264
+ if e["kind"] != WALK_IN:
265
+ continue
266
+ counts[e["to"]] = counts.get(e["to"], 0) + 1
267
+ cond = cond or bool(e.get("story_conditional"))
268
+ parts = [f"{to}(x{n})" if n > 1 else str(to) for to, n in counts.items()]
269
+ return parts, cond
270
+
271
+
272
+ def _dedup(rows, keys):
273
+ seen, out = set(), []
274
+ for r in rows:
275
+ k = tuple(r.get(x) for x in keys)
276
+ if k not in seen:
277
+ seen.add(k)
278
+ out.append(r)
279
+ return out
280
+
281
+
282
+ def render(result: GraphResult, label_fn=None, coverage=None) -> str:
283
+ """A human-readable scoping report for ``--dry-run``: per-zone node lists, inter-zone portals,
284
+ scripted seams, overworld exits, and a blast-radius line. ``label_fn(id)`` -> a display name. ``coverage``
285
+ (from :func:`zone_coverage`) adds an 'isolated seed' hint when a touched zone is under-forked."""
286
+ label_fn = label_fn or (lambda i: "")
287
+ b = result.bounds
288
+ zstr = ",".join(b["zones"]) if b["zones"] else ("seed-zone" if b["stop_at_zone_boundary"] else "any")
289
+ out = [f"import-chain from {', '.join(map(str, result.seeds))} zones={zstr} "
290
+ f"max-hops={b['max_hops']} max-fields={b['max_fields']} follow-scripted={b['follow_scripted']}",
291
+ ""]
292
+
293
+ by_zone: "OrderedDict" = OrderedDict()
294
+ for fid, info in result.nodes.items():
295
+ by_zone.setdefault(info["zone"], []).append((fid, info))
296
+ for z, items in by_zone.items():
297
+ out.append(f"ZONE {z} - {len(items)} field(s)")
298
+ for fid, info in items:
299
+ lbl = label_fn(fid) or ""
300
+ if not info["found"]:
301
+ out.append(f" {fid:<5} {lbl} [no script / world field]")
302
+ continue
303
+ parts, cond = _walkin_summary(info)
304
+ arrow = ("-> " + ", ".join(parts)) if parts else "(no walk-in exits)"
305
+ tags = []
306
+ if info.get("encounter"):
307
+ tags.append(f"enc:{info['encounter']['scenes'][0]}")
308
+ if info.get("music") is not None:
309
+ tags.append(f"music:{info['music']}")
310
+ if info.get("overworld_exits"):
311
+ tags.append(f"wm:{len(info['overworld_exits'])}")
312
+ if cond:
313
+ tags.append("STORY-COND")
314
+ out.append(f" {fid:<5} {lbl:<30} {arrow}" + ((" " + " ".join(tags)) if tags else ""))
315
+ out.append("")
316
+
317
+ portals = _dedup(result.portals, ("from", "to", "reason"))
318
+ if portals:
319
+ out.append("PORTALS (edges out of scope -- where this region connects onward):")
320
+ for p in portals:
321
+ out.append(f" {p['from']} -> {p['to']:<5} [{p.get('to_zone','?')}] ({p['kind']}; {p['reason']})")
322
+ out.append("")
323
+
324
+ seams = _dedup(result.seams, ("from", "to"))
325
+ if seams:
326
+ out.append("SCRIPTED SEAMS (teleports -- not followed; author by hand):")
327
+ for s in seams:
328
+ ent = s.get("entrance")
329
+ ent_str = str(ent) if isinstance(ent, int) and 0 <= ent <= 999 else "?" # best-effort in cutscenes
330
+ out.append(f" {s['from']} -> {s['to']:<5} [{s.get('to_zone','?')}] "
331
+ f"trigger:{s['trigger']} entrance:{ent_str}")
332
+ out.append("")
333
+
334
+ unfork = _dedup(result.unforkable, ("to",))
335
+ if unfork:
336
+ out.append("MENU / NON-FIELD TARGETS (no background in the FBG table -- shops/menus, variants, "
337
+ "cutscene-only; not forkable as rooms):")
338
+ for u in unfork:
339
+ out.append(f" {u['from']} -> {u['to']:<5} {label_fn(u['to']) or ''}")
340
+ out.append("")
341
+
342
+ wm = [fid for fid, info in result.nodes.items() if info.get("overworld_exits")]
343
+ if wm:
344
+ out.append("OVERWORLD EXITS (screens that leave to the world map): " + ", ".join(map(str, wm)))
345
+ out.append("")
346
+
347
+ cov_lines = render_coverage(coverage) if coverage else []
348
+ if cov_lines:
349
+ out.append("UNDER-FORKED ZONES (fields in the zone the seed can't door-reach -- the bytes are there):")
350
+ out.extend(cov_lines)
351
+ out.append("")
352
+
353
+ nwalk = sum(1 for info in result.nodes.values() for e in info["edges"] if e["kind"] == WALK_IN)
354
+ status = (f"TRUNCATED at max-fields={b['max_fields']} ({result.remaining}+ more queued -- "
355
+ f"raise --max-fields or narrow --zones)") if result.truncated else "complete"
356
+ out.append(f"BLAST RADIUS: {len(result.nodes)} fields, {len(by_zone)} zone(s), {nwalk} walk-in edges, "
357
+ f"{len(seams)} scripted seams, {len(unfork)} menu/non-field, {len(portals)} portals. [{status}]")
358
+ return "\n".join(out)