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/infohub.py ADDED
@@ -0,0 +1,580 @@
1
+ """The Info Hub spine -- a UI-agnostic discovery API over the kit's reference catalogs + named archetypes.
2
+
3
+ This is the reusable CORE every Info Hub frontend sits on -- a standalone viewer today, the Campaign
4
+ Editor suite tomorrow, even a Blender panel -- so the expensive part (the data logic) is written and
5
+ tested once, independent of any UI. It answers three authoring questions:
6
+
7
+ * ``browse(query)`` -> WHAT exists by this name? (search every catalog + archetype table at once)
8
+ * ``detail(entry)`` -> WHAT is this exactly? (model, animations, composite parts, the line to author it)
9
+ * ``snippet(entry)`` -> HOW do I drop it into a ``field.toml``? (the ``[[npc]]`` / ``[[prop]]`` block)
10
+
11
+ Pure-offline + provenance-clean: it reads only the baked catalogs (:mod:`catalog`) and the curated
12
+ archetype tables -- no game install. Game-dependent extras stay OUT of the spine and arrive via hooks:
13
+ :func:`detail` takes an optional ``usage_fn`` for "where does this appear in real FF9?", and a frontend
14
+ wires its own in-game *preview* by feeding the selection back to the gallery builders. Everything here is
15
+ a dataclass -- trivially rendered by Tkinter, a web view, or the CLI, and JSON-serializable.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import re
20
+
21
+ from dataclasses import dataclass, field as _dc_field
22
+ from typing import Callable, Optional
23
+
24
+ from . import archetypes as _arch
25
+ from . import catalog as _cat
26
+ from . import prop_archetypes as _props
27
+ from .content.npc import PRESETS as _CHAR_PRESETS # vivi / zidane -- explicit, byte-golden
28
+
29
+ # the kinds the Info Hub indexes, listed in browse priority order (curated/named first, raw + reference last)
30
+ KINDS = ("archetype", "creature", "composite", "prop", "model", "item", "scene", "storyflag", "sps_template")
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Entry:
35
+ """One browsable result -- the unit a frontend lists and acts on. ``name`` is what you type to use it;
36
+ ``model`` is the GEO model behind it (when any); ``ident`` is the numeric id for a model/item/scene."""
37
+ kind: str
38
+ name: str
39
+ model: Optional[str] = None
40
+ summary: str = ""
41
+ ident: Optional[int] = None
42
+
43
+
44
+ @dataclass
45
+ class Detail:
46
+ """The rich record for one entry -- everything an authoring detail pane shows."""
47
+ name: str
48
+ kind: str
49
+ model: Optional[str] = None
50
+ model_id: Optional[int] = None
51
+ facts: list = _dc_field(default_factory=list) # [(label, value)] -- generic key facts
52
+ movement: Optional[dict] = None # {stand,walk,run,left,right} iff NPC-ready
53
+ anims: list = _dc_field(default_factory=list) # [(action, anim_id)] -- the full gesture list
54
+ parts: list = _dc_field(default_factory=list) # composite parts [(model_name, pose, dx, dz)]
55
+ aliases: list = _dc_field(default_factory=list) # other names mapping to the same model
56
+ locations: Optional[list] = None # [(field_id, name)] iff a usage_fn was supplied
57
+ snippet: str = ""
58
+ preview_png: Optional[str] = None # a rendered preview image path (SPS effects)
59
+
60
+
61
+ # ----------------------------------------------------------------- helpers ---
62
+ def _model_of_archetype(name) -> Optional[str]:
63
+ """The GEO model NAME an archetype/creature maps to (or None). Cheap -- a direct model lookup, NOT
64
+ ``archetypes.resolve`` (which also scans every animation to build the movement set)."""
65
+ key = str(name).strip().lower()
66
+ if key in _CHAR_PRESETS:
67
+ model = _CHAR_PRESETS[key][0]
68
+ else:
69
+ spec = _arch.ARCHETYPES.get(key) or _arch.CREATURES.get(key)
70
+ model = spec["model"] if spec else None
71
+ m = _cat.model(model) if model is not None else None
72
+ return m.name if m else None
73
+
74
+
75
+ _DESC_CACHE: Optional[dict] = None
76
+ _DESC_RE = re.compile(r'^\s*"([a-z0-9_]+)"\s*:\s*[\{\[].*?#\s*(.+)$')
77
+
78
+
79
+ def _descriptions() -> dict:
80
+ """``{name: short description}`` parsed from the archetype/prop source comments (built once). The rich
81
+ "what is it" text already lives in trailing comments (shelf -> 'a Dali ... shelf / box'; cask ->
82
+ 'a "CaSK" / barrel'); this surfaces it for SEARCH + display without migrating it into the data."""
83
+ global _DESC_CACHE
84
+ if _DESC_CACHE is None:
85
+ d: dict = {}
86
+ for mod in (_arch, _props):
87
+ try:
88
+ src = open(mod.__file__, encoding="utf-8").read()
89
+ except OSError:
90
+ continue
91
+ for line in src.splitlines():
92
+ mm = _DESC_RE.match(line)
93
+ if mm:
94
+ d.setdefault(mm.group(1), mm.group(2).strip())
95
+ _DESC_CACHE = d
96
+ return _DESC_CACHE
97
+
98
+
99
+ def _build_entries() -> list:
100
+ """Every indexed :class:`Entry`, in :data:`KINDS` order (built once, then cached). Each summary folds
101
+ in the comment DESCRIPTION + (for raw models) the friendly archetype names that use it, so search
102
+ matches what a thing IS ('box' -> shelf, 'zidane' -> the ZDN model), not just its cryptic GEO token."""
103
+ desc = _descriptions()
104
+ by_model = _model_names_index()
105
+ out = []
106
+ for name in sorted(set(_CHAR_PRESETS) | set(_arch.ARCHETYPES)): # archetypes (playable + NPC types)
107
+ mname = _model_of_archetype(name)
108
+ m = _cat.model(mname) if mname else None
109
+ role = m.kind if m else "npc"
110
+ out.append(Entry("archetype", name, mname, f"{role} NPC -- {desc.get(name) or mname or '?'}",
111
+ m.id if m else None))
112
+ for name in sorted(_arch.CREATURES): # creatures (GEO_MON field objects)
113
+ mname = _arch.CREATURES[name]["model"]
114
+ m = _cat.model(mname)
115
+ out.append(Entry("creature", name, mname, f"monster -- {desc.get(name) or mname}", m.id if m else None))
116
+ for name in sorted(_props.PROP_COMPOSITES): # composites (multi-part set pieces)
117
+ d = desc.get(name) or f"{len(_props.PROP_COMPOSITES[name])} parts"
118
+ out.append(Entry("composite", name, None, f"set piece -- {d}"))
119
+ for name in sorted(_props.PROP_ARCHETYPES): # props (single static set-dressing)
120
+ spec = _props.PROP_ARCHETYPES[name]
121
+ m = _cat.model(_cat.resolve_model(spec["model"]))
122
+ gname = m.name if m else spec["model"]
123
+ out.append(Entry("prop", name, gname, f"prop -- {desc.get(name) or gname}", m.id if m else None))
124
+ for m in _cat.all_models(): # raw models (anything by GEO name)
125
+ friendly = by_model.get(m.name, [])
126
+ extra = (" -- " + ", ".join(friendly)) if friendly else ""
127
+ out.append(Entry("model", m.name, m.name, f"{m.kind} model ({m.form}){extra}", m.id))
128
+ from . import itemstats as _istats # live stats from YOUR install (or None)
129
+ for iid, nm in _cat.items(): # items
130
+ out.append(Entry("item", nm, None, _istats.summary(iid) or f"item #{iid}", iid))
131
+ for nm, sid in _cat.battle_scenes(): # battle scenes (encounters)
132
+ out.append(Entry("scene", nm, None, f"battle scene #{sid}", sid))
133
+ out += _storyflag_entries() # FF9 story-flag registry (reference)
134
+ out += _sps_template_entries() # Tier-2 [[sps]] creator presets
135
+ return out
136
+
137
+
138
+ def _sps_template_entries() -> list:
139
+ """The curated ``[[sps]]`` effect templates (``sps.templates``) as a static, install-free kind -- the
140
+ names + descriptions list with no install; the detail-pane PREVIEW renders the donor effect lazily
141
+ (install-gated). The entry's ``model`` stashes the donor ``field:sps`` for that preview."""
142
+ from .sps import templates as _tpl
143
+ return [Entry("sps_template", name, f"{field}:{sid}", f"effect template -- {desc}", sid)
144
+ for name, desc, field, sid in _tpl.list_templates()]
145
+
146
+
147
+ _ENTRY_CACHE: Optional[list] = None
148
+
149
+
150
+ def _all_entries() -> list:
151
+ """The indexed entries (lazily built once; the catalogs are static)."""
152
+ global _ENTRY_CACHE
153
+ if _ENTRY_CACHE is None:
154
+ _ENTRY_CACHE = _build_entries()
155
+ return _ENTRY_CACHE
156
+
157
+
158
+ _MODEL_NAMES_CACHE: Optional[dict] = None
159
+
160
+
161
+ def _model_names_index() -> dict:
162
+ """``{model_name: [names...]}`` -- every archetype/creature/prop name grouped by its GEO model, built
163
+ ONCE so :func:`_aliases_for` is an O(1) lookup instead of re-scanning every archetype per detail."""
164
+ global _MODEL_NAMES_CACHE
165
+ if _MODEL_NAMES_CACHE is None:
166
+ idx: dict = {}
167
+ for n in set(_CHAR_PRESETS) | set(_arch.ARCHETYPES) | set(_arch.CREATURES):
168
+ mn = _model_of_archetype(n)
169
+ if mn:
170
+ idx.setdefault(mn, []).append(n)
171
+ for n, spec in _props.PROP_ARCHETYPES.items():
172
+ m = _cat.model(_cat.resolve_model(spec["model"]))
173
+ if m:
174
+ idx.setdefault(m.name, []).append(n)
175
+ _MODEL_NAMES_CACHE = {k: sorted(v) for k, v in idx.items()}
176
+ return _MODEL_NAMES_CACHE
177
+
178
+
179
+ def _aliases_for(name, model_name) -> list:
180
+ """Other archetype/creature/prop names on the same GEO model (so a detail pane can show 'also: dagger,
181
+ garnets_mother') -- an O(1) lookup into the cached :func:`_model_names_index`."""
182
+ if not model_name:
183
+ return []
184
+ return [n for n in _model_names_index().get(model_name, []) if n != name]
185
+
186
+
187
+ # ------------------------------------------------------ story-flag registry ---
188
+ # The FF9 story-flag REGISTRY (flags.py) browsable as a reference kind: named engine vars, reserved bit
189
+ # regions, the census story clusters, the scenario-milestone table, and the safe custom band. Always
190
+ # available + install-free (flags.py is pure). Distinct from the campaign 'flag' kind (a campaign's own
191
+ # [[flag]] gates) -- this is FF9's built-in story state, for "what bit / scenario is X?".
192
+ _STORYFLAG_SUBLABEL = {"var": "story var", "RESERVED": "RESERVED region", "region": "bit region",
193
+ "story": "story cluster", "scenario": "scenario milestone", "band": "safe custom band"}
194
+ _STORYFLAG_TIER = {"a": "engine-grounded", "b": "empirical (census)", "c": "uncertain",
195
+ "a/b": "engine + census"}
196
+ _STORYFLAG_ROWS_CACHE: Optional[dict] = None
197
+
198
+
199
+ def _storyflag_rows() -> dict:
200
+ """``{display_name: (sub, raw_name, location, meaning, tier)}`` from ``flags.registry_rows()`` (built
201
+ once). Scenario rows display as 'Beat (value)' so they're unique + searchable by beat AND value."""
202
+ global _STORYFLAG_ROWS_CACHE
203
+ if _STORYFLAG_ROWS_CACHE is None:
204
+ from . import flags as _flags
205
+ rows: dict = {}
206
+ for sub, name, loc, meaning, tier in _flags.registry_rows():
207
+ disp = f"{meaning} ({name})" if sub == "scenario" else name
208
+ rows[disp] = (sub, name, loc, meaning, tier)
209
+ _STORYFLAG_ROWS_CACHE = rows
210
+ return _STORYFLAG_ROWS_CACHE
211
+
212
+
213
+ def _storyflag_entries() -> list:
214
+ out = []
215
+ for disp, (sub, name, loc, meaning, tier) in _storyflag_rows().items():
216
+ label = _STORYFLAG_SUBLABEL.get(sub, sub)
217
+ ident = int(name) if sub == "scenario" else None
218
+ out.append(Entry("storyflag", disp, None, f"{label} · {loc} · {meaning}", ident))
219
+ return out
220
+
221
+
222
+ # ----------------------------------------------------------- campaign layer ---
223
+ def _campaign_entries(plan) -> list:
224
+ """Field entries (kind='field') for the members of a campaign -- the ADDITIVE layer browse/detail
225
+ expose when a frontend passes ``campaign_context``. Lets the Info Hub search 'the fields in THIS
226
+ campaign' alongside the static catalogs. Duck-typed (anything with a ``.members`` list of objects
227
+ carrying ``name``/``new_id``/``mode``) so the spine never imports campaign.py at module load."""
228
+ out = []
229
+ for m in getattr(plan, "members", None) or []:
230
+ nm = getattr(m, "name", None)
231
+ if not nm:
232
+ continue
233
+ nid = getattr(m, "new_id", None)
234
+ mode = getattr(m, "mode", "") or ""
235
+ out.append(Entry("field", nm, None, f"campaign field #{nid} ({mode})", nid))
236
+ for fdef in getattr(plan, "flags", None) or []: # shared NAMED story flags (cross-field gates)
237
+ nm = fdef.get("name") if isinstance(fdef, dict) else None
238
+ if nm:
239
+ idx = fdef.get("index")
240
+ out.append(Entry("flag", str(nm), None, f"campaign story flag (bit {idx})", idx))
241
+ return out
242
+
243
+
244
+ # -------------------------------------------------------------------- SPS layer ---
245
+ # Field particle effects (fire/smoke/magic) browsable as the ADDITIVE 'sps' kind a frontend exposes via
246
+ # ``sps_context`` -- a {label: sps_dir} map of the OPEN project's carried ``sps/`` sidecars (the effects a
247
+ # native fork ships + can ``[[sps_edit]]``). Install-FREE: it reads the project's own staged ``.sps``/
248
+ # ``spt.tcb`` files, never the install (the spine stays game-free). Each entry stashes its ``.sps`` path in
249
+ # ``Entry.model`` so :func:`detail` is self-contained. -> [[project-ff9-sps-authoring]], docs/SPS.md.
250
+ def _sps_dir_map(sps_context) -> dict:
251
+ """Normalise ``sps_context`` to ``{label: Path}``. Accepts a {label: dir} map, a single dir path, or a
252
+ list of (label, dir) / dirs. Drops anything that isn't a real directory."""
253
+ from pathlib import Path
254
+ out: dict = {}
255
+ if sps_context is None:
256
+ return out
257
+ items = sps_context.items() if isinstance(sps_context, dict) else (
258
+ [(None, sps_context)] if isinstance(sps_context, (str, Path)) else list(sps_context))
259
+ for label, d in items:
260
+ if d is None:
261
+ label, d = None, label # a bare list of dirs
262
+ p = Path(d)
263
+ if p.is_dir():
264
+ out[str(label) if label is not None else p.parent.name] = p
265
+ return out
266
+
267
+
268
+ def _sps_entries(sps_context) -> list:
269
+ """``Entry(kind='sps')`` for every ``<id>.sps.bytes`` in the open project's carried ``sps/`` sidecars.
270
+ The entry's ``model`` holds the ``.sps`` file path (so detail/preview need no re-resolution)."""
271
+ dirs = _sps_dir_map(sps_context)
272
+ multi = len(dirs) > 1
273
+ out = []
274
+ for label, d in dirs.items():
275
+ for f in sorted(d.glob("*.sps.bytes")):
276
+ stem = f.name[: -len(".sps.bytes")]
277
+ try:
278
+ sid = int(stem)
279
+ except ValueError:
280
+ continue
281
+ name = f"{label}:{sid}" if multi else str(sid)
282
+ out.append(Entry("sps", name, str(f), f"SPS effect #{sid} (carried by {label})", sid))
283
+ return out
284
+
285
+
286
+ _SPS_PREVIEW_DIR = None
287
+
288
+
289
+ def _sps_preview_png(sps, tcb, key: str) -> Optional[str]:
290
+ """Render a single representative frame to a cached PNG (dark-flattened so it's visible on any pane) and
291
+ return its path, or ``None`` if rendering isn't possible (no Pillow). Local imports keep the spine light."""
292
+ global _SPS_PREVIEW_DIR
293
+ try:
294
+ import re as _re
295
+ import tempfile
296
+ from pathlib import Path
297
+ from .sps import render as _render
298
+ if _SPS_PREVIEW_DIR is None:
299
+ _SPS_PREVIEW_DIR = Path(tempfile.gettempdir()) / "ff9mapkit_sps_preview"
300
+ _SPS_PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
301
+ from PIL import Image # noqa: PLC0415
302
+ frame = _render.render_frame(sps, tcb, sps.frame_count // 2, scale=4) # a developed mid frame
303
+ flat = Image.new("RGBA", frame.size, (34, 34, 34, 255)) # dark tile -> always visible
304
+ flat.alpha_composite(frame)
305
+ dest = _SPS_PREVIEW_DIR / (_re.sub(r"[^0-9A-Za-z_.-]", "_", key) + ".png")
306
+ flat.convert("RGB").save(dest)
307
+ return str(dest)
308
+ except Exception: # noqa: BLE001 -- preview is best-effort; never break detail
309
+ return None
310
+
311
+
312
+ def _sps_template_detail(entry: Entry) -> Detail:
313
+ """A ``[[sps]]`` template: facts + a rendered preview of the donor effect it clones (install-gated; the
314
+ listing stays install-free, only this lazy detail reads the install)."""
315
+ from .sps import catalog as _spscat, codec as _spscodec
316
+ d = Detail(name=entry.name, kind="sps_template", model=None, model_id=entry.ident, snippet=snippet(entry))
317
+ field, _, sid = (entry.model or "").partition(":")
318
+ d.facts = [("kind", "SPS effect template"), ("use", f'template = "{entry.name}"'), ("clones", f"{field} #{sid}")]
319
+ try:
320
+ rows = _spscat.list_field_sps(field)
321
+ ent = next((r for r in rows if r.sps_id == int(sid)), None)
322
+ if ent is not None:
323
+ sps = _spscat.load_sps(ent)
324
+ d.facts += [(k, v) for k, v in _spscat.effect_facts(sps) if k != "kind"] # keep the template's kind
325
+ tcb = _spscat.load_tcb(field)
326
+ if tcb is not None:
327
+ d.preview_png = _sps_preview_png(sps, tcb, f"tpl_{entry.name}")
328
+ except Exception: # noqa: BLE001 -- install-gated preview; degrade to facts-only offline
329
+ pass
330
+ return d
331
+
332
+
333
+ def _sps_detail(entry: Entry) -> Detail:
334
+ """Decode one carried effect (``entry.model`` is its ``.sps`` path) -> facts + a rendered preview + a
335
+ ``[[sps_edit]]`` snippet. Reads the sibling ``spt.tcb.bytes`` for the texture."""
336
+ from pathlib import Path
337
+ from .sps import catalog as _spscat, codec as _spscodec
338
+ d = Detail(name=entry.name, kind="sps", model=None, model_id=entry.ident, snippet=snippet(entry))
339
+ path = Path(entry.model) if entry.model else None
340
+ if path is None or not path.is_file():
341
+ d.facts = [("kind", "SPS field effect"), ("id", str(entry.ident)), ("note", "bin not found")]
342
+ return d
343
+ try:
344
+ sps = _spscodec.parse(path.read_bytes())
345
+ except Exception as ex: # noqa: BLE001
346
+ d.facts = [("kind", "SPS field effect"), ("id", str(entry.ident)), ("error", str(ex))]
347
+ return d
348
+ d.facts = _spscat.effect_facts(sps)
349
+ tcb_path = path.parent / "spt.tcb.bytes"
350
+ if tcb_path.is_file():
351
+ d.preview_png = _sps_preview_png(sps, tcb_path.read_bytes(), f"{path.parent.name}_{entry.ident}")
352
+ return d
353
+
354
+
355
+ # --------------------------------------------------------------- public API ---
356
+ def browse(query: str = "", kinds=None, limit=200, campaign_context=None, sps_context=None) -> list:
357
+ """Search every catalog + archetype table at once. ``query`` = a case-insensitive substring of an
358
+ entry's name / model / SUMMARY (the summary folds in the comment description + friendly names, so you
359
+ can search by what a thing IS); ``kinds`` restricts to a subset of :data:`KINDS`; ``limit`` caps the
360
+ result (curated/named kinds come first) or ``None`` for no cap. The Info Hub's 'grab anything by name'.
361
+
362
+ When ``campaign_context`` (a campaign.CampaignPlan) is given, that campaign's member fields are ALSO
363
+ searchable as kind='field' entries, listed FIRST; with no context the result is exactly as before.
364
+ When ``sps_context`` (a {label: sps_dir} map of the open project's carried ``sps/`` sidecars) is given,
365
+ the project's SPS effects are searchable as kind='sps' entries (install-free)."""
366
+ q = (query or "").strip().lower()
367
+ field_entries = _campaign_entries(campaign_context) if campaign_context is not None else []
368
+ sps_entries = _sps_entries(sps_context) if sps_context is not None else []
369
+ if kinds:
370
+ want = set(kinds)
371
+ else:
372
+ want = set(KINDS) | ({"field", "flag"} if campaign_context is not None else set()) \
373
+ | ({"sps"} if sps_entries else set())
374
+ # no context -> iterate the cached list directly (no copy), preserving today's behavior exactly
375
+ extra = field_entries + sps_entries
376
+ entries = (extra + _all_entries()) if extra else _all_entries()
377
+ out = []
378
+ for e in entries:
379
+ if e.kind not in want:
380
+ continue
381
+ if q and q not in e.name.lower() and not (e.model and q in e.model.lower()) \
382
+ and q not in e.summary.lower():
383
+ continue
384
+ out.append(e)
385
+ if limit is not None and len(out) >= limit:
386
+ break
387
+ return out
388
+
389
+
390
+ def find(name, kind=None) -> Optional[Entry]:
391
+ """The first :class:`Entry` whose name matches ``name`` exactly (case-insensitive), optionally of a
392
+ given ``kind`` -- for callers that have a name but no Entry (e.g. resolving a `field.toml` value)."""
393
+ key = str(name).strip().lower()
394
+ for e in _all_entries():
395
+ if e.name.lower() == key and (kind is None or e.kind == kind):
396
+ return e
397
+ return None
398
+
399
+
400
+ def snippet(entry: Entry) -> str:
401
+ """The ``field.toml`` block to author this entry (a frontend's 'copy / insert'). Placeables get a
402
+ ``[[npc]]`` / ``[[prop]]`` block with a ``pos = [0, 0]`` placeholder; item/scene get the line they're
403
+ used in."""
404
+ e = entry
405
+ if e.kind in ("archetype", "creature"):
406
+ return f'[[npc]]\narchetype = "{e.name}"\npos = [0, 0]'
407
+ if e.kind in ("prop", "composite"):
408
+ return f'[[prop]]\nprop = "{e.name}"\npos = [0, 0]'
409
+ if e.kind == "model":
410
+ m = _cat.model(e.ident) if e.ident is not None else _cat.model(e.model)
411
+ if m and m.group == "ACC":
412
+ return f'[[prop]]\nmodel = "{m.name}"\npos = [0, 0]'
413
+ return f'[[npc]]\nmodel = "{e.model}"\npos = [0, 0]'
414
+ if e.kind == "item":
415
+ return f'give_item = [{e.ident}, 1] # {e.name} -- e.g. an [[event]] reward'
416
+ if e.kind == "scene":
417
+ return f'[encounter]\nscene = {e.ident} # {e.name}'
418
+ if e.kind == "sps_template": # a creator preset -> a ready [[sps]] block
419
+ return (f'[[sps]]\nid = 5000\ntemplate = "{e.name}"\n'
420
+ f'pos = [0, 0] # [x, z] -- auto-grounded onto the floor')
421
+ if e.kind == "sps": # a carried field effect -> a re-skin starter block
422
+ return (f'[[sps_edit]]\nsps = {e.ident} # re-skin this effect over its carried texture\n'
423
+ f'kind = "tint"\nmul = [0, 0, 512] # recolour the whole ramp (256 == identity; this -> blue)\n'
424
+ f'# other kinds: recolor_ramp / scale / reposition -- see docs/SPS.md')
425
+ if e.kind == "field": # a campaign member -- not a paste-able toml block
426
+ return f"# campaign field: {e.name} (id {e.ident})"
427
+ if e.kind == "flag": # a shared named story flag -> the gate line
428
+ return f'requires_flag = "{e.name}"'
429
+ if e.kind == "storyflag": # the FF9 registry -> a reference / authoring hint
430
+ from . import flags as _flags
431
+ sub = _storyflag_rows().get(e.name, ("",))[0]
432
+ if sub == "band":
433
+ return (f'[[flag]]\nname = "my_flag"\nindex = {_flags.FIRST_SAFE_FLAG} '
434
+ f'# a custom story flag in the safe band')
435
+ if sub == "scenario":
436
+ return f'ff9mapkit save-edit <SavedData_ww.dat> --scenario {e.ident} # jump to this story beat'
437
+ loc = _storyflag_rows().get(e.name, ("", "", "?"))[2]
438
+ return f"# {e.name} ({loc}) -- FF9 engine state, reference only (do not allocate here)"
439
+ return e.name
440
+
441
+
442
+ def _field_detail(entry: Entry, plan) -> Detail:
443
+ """Detail for a campaign member (kind='field'): its place in the chain -- id/source/mode, the live
444
+ doors it leads to + is entered from, onward seams, and entry/reachability/needs-export flags. Resolved
445
+ through :func:`campaign.campaign_graph` (lazy import -- the spine stays campaign-free at module load)."""
446
+ d = Detail(name=entry.name, kind="field", model=None, model_id=entry.ident, snippet=snippet(entry))
447
+ d.facts = [("kind", "campaign field"), ("id", str(entry.ident))]
448
+ if plan is None:
449
+ return d
450
+ from . import campaign as _camp
451
+ node = _camp.campaign_graph(plan).by_name.get(entry.name)
452
+ if node is None:
453
+ return d
454
+ d.facts = [("kind", "campaign field"), ("id", str(node.new_id)),
455
+ ("source", str(node.real_id)), ("mode", node.mode)]
456
+ if node.is_entry:
457
+ d.facts.append(("role", "campaign entry"))
458
+ if node.needs_export:
459
+ d.facts.append(("needs_export", "yes -- export this field's art in-game"))
460
+ if not node.reachable:
461
+ d.facts.append(("reachable", "NO -- no live-door path from the entry"))
462
+ if node.dead_end:
463
+ d.facts.append(("dead_end", "no onward connection"))
464
+ for oe in node.out_edges:
465
+ d.facts.append(("door", f"-> {oe['to']} (entrance {oe['entrance']})"
466
+ + (" [gated]" if oe["gated"] else "")))
467
+ for ie in node.in_edges:
468
+ d.facts.append(("entered_from", f"<- {ie['frm']} (entrance {ie['entrance']})"))
469
+ for s in node.seams:
470
+ tgt = s["to_member"] or ("WORLDMAP" if s["to_real"] == "WORLDMAP" else s["to_real"])
471
+ d.facts.append((f"seam:{s['kind']}", f"-> {tgt}"))
472
+ return d
473
+
474
+
475
+ def detail(entry: Entry, usage_fn: Optional[Callable] = None, campaign_context=None, sps_context=None) -> Detail:
476
+ """Resolve an :class:`Entry` to its full :class:`Detail`. ``usage_fn(model_id) -> [(field_id, name),
477
+ ...]`` is an optional hook a frontend passes to add 'where it appears in real FF9' (the spine stays
478
+ install-free -- field-usage needs the game); errors from it degrade to ``locations = None``. When the
479
+ entry is a campaign member (kind='field') and ``campaign_context`` (a CampaignPlan) is given, the
480
+ detail is the member's place in the chain (doors/seams/reachability)."""
481
+ e = entry
482
+ if e.kind == "sps_template": # a creator preset (decode + preview the donor)
483
+ return _sps_template_detail(e)
484
+ if e.kind == "sps": # a carried field effect (decode + preview)
485
+ return _sps_detail(e)
486
+ if e.kind == "field":
487
+ return _field_detail(e, campaign_context)
488
+ if e.kind == "flag": # a shared named story flag (cross-field gate)
489
+ d = Detail(name=e.name, kind="flag", model=None, model_id=e.ident, snippet=snippet(e))
490
+ d.facts = [("kind", "campaign story flag"), ("index", str(e.ident)),
491
+ ("gate", f'requires_flag = "{e.name}"'), ("set", f'set_flag = ["{e.name}", 1]')]
492
+ return d
493
+ if e.kind == "storyflag": # an FF9 story-flag registry entry (reference)
494
+ sub, name, loc, meaning, tier = _storyflag_rows().get(e.name, ("", e.name, "", e.summary, ""))
495
+ d = Detail(name=e.name, kind="storyflag", model=None, model_id=None, snippet=snippet(e))
496
+ d.facts = [("kind", _STORYFLAG_SUBLABEL.get(sub, sub)), ("location", loc),
497
+ ("confidence", _STORYFLAG_TIER.get(tier, tier))]
498
+ if meaning:
499
+ d.facts.append(("meaning", meaning))
500
+ if sub == "RESERVED":
501
+ d.facts.append(("note", "reserved -- a mod must NOT allocate flags here"))
502
+ elif sub == "band":
503
+ d.facts.append(("note", "allocate your custom story flags in this band"))
504
+ return d
505
+ d = Detail(name=e.name, kind=e.kind, model=e.model, model_id=e.ident, snippet=snippet(e))
506
+ dsc = _descriptions().get(e.name)
507
+ if e.kind == "composite":
508
+ d.parts = [((_cat.model(mid).name if _cat.model(mid) else str(mid)), pose, dx, dz)
509
+ for mid, pose, dx, dz in _props.resolve_composite(e.name)]
510
+ d.facts = [("kind", "composite set piece"), ("parts", str(len(d.parts)))]
511
+ if dsc:
512
+ d.facts.append(("desc", dsc))
513
+ return d
514
+ if e.kind == "item":
515
+ from . import itemstats as _istats # live stat join from YOUR install
516
+ d.facts = [("kind", "item"), ("id", str(e.ident))] + _istats.facts(e.ident)
517
+ return d
518
+ if e.kind == "scene":
519
+ d.facts = [("kind", "battle scene"), ("id", str(e.ident))]
520
+ return d
521
+ # archetype / creature / prop / model -- everything model-backed
522
+ m = _cat.model(e.ident) if e.ident is not None else (_cat.model(e.model) if e.model else None)
523
+ if m:
524
+ d.model, d.model_id = m.name, m.id
525
+ d.facts = [("kind", e.kind), ("model", m.name), ("role", m.kind), ("form", m.form)]
526
+ if e.kind == "prop":
527
+ d.facts.append(("pose", str(_props.resolve(e.name)[1])))
528
+ if dsc:
529
+ d.facts.append(("desc", dsc))
530
+ d.anims = _cat.animation_actions(m.id)
531
+ d.movement = _cat.npc_anims(m.id) or None
532
+ d.aliases = _aliases_for(e.name, m.name)
533
+ if usage_fn is not None:
534
+ try:
535
+ d.locations = list(usage_fn(m.id))
536
+ except Exception:
537
+ d.locations = None
538
+ return d
539
+
540
+
541
+ _PLACEABLE = ("archetype", "creature", "composite", "prop", "model")
542
+
543
+
544
+ def _place_lines(entry, x, z) -> list:
545
+ """The ``[[npc]]`` / ``[[prop]]`` block placing one entry at world (x, z) on a preview field."""
546
+ e, pos = entry, f"pos = [{x}, {z}]"
547
+ if e.kind in ("archetype", "creature"):
548
+ return ["", "[[npc]]", f'archetype = "{e.name}"', pos]
549
+ if e.kind in ("prop", "composite"):
550
+ return ["", "[[prop]]", f'prop = "{e.name}"', pos]
551
+ if e.kind == "model":
552
+ m = _cat.model(e.ident) if e.ident is not None else _cat.model(e.model)
553
+ if m and m.group == "ACC":
554
+ return ["", "[[prop]]", f'model = "{m.name}"', pos]
555
+ return ["", "[[npc]]", f'model = "{e.model}"', pos]
556
+ return []
557
+
558
+
559
+ def preview_field_toml(entries, art_dir, *, screens: int = 3) -> Optional[str]:
560
+ """Build a deployable arena ``field.toml`` that PLACES the given entries -- a gallery of your selection,
561
+ so a frontend deploys it + F6-reloads to see them LIVE on the debug checkerboard. Writes the arena art
562
+ into ``art_dir`` and returns the toml; returns ``None`` if nothing is placeable (items/scenes are not
563
+ field objects). Game-free -- only the caller's deploy touches the install."""
564
+ from .scene import arena as _arena
565
+ placeable = [e for e in (entries or []) if e.kind in _PLACEABLE]
566
+ if not placeable:
567
+ return None
568
+ n = len(placeable)
569
+ meta = _arena.build_arena(art_dir, screens=max(screens, n))
570
+ half = meta["quad"][1][0]
571
+ margin = 700
572
+ xs = [round(-(half - margin) + 2 * (half - margin) * i / max(1, n - 1)) for i in range(n)]
573
+ zs = [z for _, z in meta["quad"]]
574
+ z_lo, z_hi = min(zs), max(zs)
575
+ row_z, spawn_z = (z_lo + z_hi) // 2, z_hi - 150
576
+ lines = [f"# Info Hub preview -- {', '.join(e.name for e in placeable)}. F6 -> Reload field to see it."]
577
+ lines += _arena.arena_scene_lines(meta, spawn_z=spawn_z, name="PREVIEW")
578
+ for e, x in zip(placeable, xs):
579
+ lines += _place_lines(e, x, row_z)
580
+ return "\n".join(lines)
ff9mapkit/items.py ADDED
@@ -0,0 +1,63 @@
1
+ """Author-facing item catalog -- give an item by NAME instead of a numeric id.
2
+
3
+ ``give_item = ["Potion", 1]`` instead of ``[236, 1]``. Backed by :mod:`ff9mapkit._itemdb` (FF9 item
4
+ id <-> name, from Memoria's open-source ``RegularItem`` enum). Names match case / spacing / hyphen
5
+ insensitively, so ``"Potion"``, ``"potion"``, ``"Hi-Potion"``, ``"phoenix down"`` all resolve. A
6
+ numeric id (int or digit string) passes through, validated to 0-255.
7
+
8
+ Usage::
9
+
10
+ from ff9mapkit import items
11
+ items.resolve("Potion") # -> 236
12
+ items.resolve("hi-potion") # -> 237
13
+ items.resolve(236) # -> 236 (raw id passes through)
14
+ items.name_of(236) # -> "Potion"
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import difflib
20
+
21
+ from ._itemdb import ITEMS
22
+
23
+ # normalized-name -> id (lowercased, alphanumerics only -> "Hi-Potion"/"hi potion" both match HiPotion)
24
+ _BY_NAME = {}
25
+
26
+
27
+ def _norm(s) -> str:
28
+ return "".join(c for c in str(s).lower() if c.isalnum())
29
+
30
+
31
+ for _id, _name in ITEMS.items():
32
+ _BY_NAME[_norm(_name)] = _id
33
+
34
+
35
+ def resolve(name_or_id) -> int:
36
+ """Resolve an item NAME or id to its numeric id. An int / digit-string passes through (validated
37
+ 0-255); a name is matched case/space/hyphen-insensitively. Raises ValueError (with near-miss
38
+ suggestions) on an unknown name or out-of-range id."""
39
+ if isinstance(name_or_id, bool):
40
+ raise ValueError("item cannot be a boolean")
41
+ if isinstance(name_or_id, int):
42
+ if not 0 <= name_or_id <= 255:
43
+ raise ValueError(f"item id {name_or_id} out of range (0-255)")
44
+ return name_or_id
45
+ s = str(name_or_id).strip()
46
+ if s.isdigit():
47
+ return resolve(int(s))
48
+ key = _norm(s)
49
+ if key in _BY_NAME:
50
+ return _BY_NAME[key]
51
+ hints = [ITEMS[_BY_NAME[h]] for h in difflib.get_close_matches(key, list(_BY_NAME), n=6, cutoff=0.4)]
52
+ extra = f" Did you mean: {', '.join(hints)}?" if hints else " Run `ff9mapkit items` to list them."
53
+ raise ValueError(f"unknown item {name_or_id!r}.{extra}")
54
+
55
+
56
+ def name_of(item_id: int):
57
+ """Canonical name for an id (236 -> 'Potion'), or None."""
58
+ return ITEMS.get(int(item_id))
59
+
60
+
61
+ def all_items() -> list:
62
+ """Sorted ``[(id, name), ...]`` (for the CLI / docs)."""
63
+ return sorted(ITEMS.items())