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/abilities.py ADDED
@@ -0,0 +1,225 @@
1
+ """Character ABILITY data -- the AP/ability-mastery half of the #5 save editor.
2
+
3
+ Two pieces, both provenance-clean (ship/commit nothing -- the same live-read pattern as :mod:`ff9mapkit.itemstats`
4
+ and :mod:`ff9mapkit.keyitems`):
5
+
6
+ * a mod-agnostic **id<->token codec** (``AA:X`` active / ``SA:X`` support <-> the integer ``abil_id`` the save
7
+ stores in ``players[].pa_extended[].id``), mirroring Memoria's ``CsvParser.AnyAbility`` / ``ff9abil``; and
8
+ * a best-effort **name + AP-requirement** lookup, read LIVE from the install's per-character pool CSVs
9
+ ``<install>/StreamingAssets/Data/Characters/Abilities/<Preset>.csv`` (rows ``AA:101;40;# Flee`` --
10
+ token ; AP-to-master ; ``# name``).
11
+
12
+ ★ The codec ALWAYS works (pure arithmetic). The name/AP lookup is BEST-EFFORT: it reads the base-game CSVs, so
13
+ an id that a mod (e.g. Moguri) introduced -- not present in the base pool -- resolves to ``None`` and the editor
14
+ falls back to the raw ``AA:X``/``SA:X`` token. The save's own ``pa_extended`` is the source of truth for which
15
+ abilities a character has; this module only enriches it with names + the master threshold.
16
+
17
+ The id<->token math (Memoria ``ff9abil``/``CsvParser.AnyAbility``): each ability has a global integer ``abil_id``;
18
+ ``mod = abil_id % 256`` is < 192 for ACTIVE (``AA``) and >= 192 for SUPPORT (``SA``); ``pool = abil_id // 256``.
19
+ For ``AA:X`` -> ``abil_id = (X // 192) * 256 + X % 192``; for ``SA:X`` -> ``(X // 64) * 256 + X % 64 + 192``.
20
+
21
+ Usage::
22
+
23
+ from ff9mapkit import abilities
24
+ abilities.encode_token("AA:108") # -> 108
25
+ abilities.decode_token(192) # -> "SA:0"
26
+ abilities.name_of(108) # -> "Thievery" (or None if the install isn't reachable)
27
+ abilities.ap_required(0, 108) # -> 100 (Zidane preset=0; or None)
28
+ abilities.resolve(0, "Thievery") # -> 108
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import re
33
+ from dataclasses import dataclass
34
+
35
+ # CharacterPresetId (Memoria.Data.Characters.CharacterPresetId) -> the per-pool CSV basename. menu_type in the
36
+ # save == this preset id. Only 0-15 ship a pool CSV (16+ are stage doubles with no learnable set).
37
+ PRESET_NAMES = {0: "Zidane", 1: "Vivi", 2: "Garnet", 3: "Steiner", 4: "Freya", 5: "Quina", 6: "Eiko",
38
+ 7: "Amarant", 8: "Cinna1", 9: "Cinna2", 10: "Marcus1", 11: "Marcus2", 12: "Blank1",
39
+ 13: "Blank2", 14: "Beatrix1", 15: "Beatrix2"}
40
+
41
+ _AP_MAX = 255 # the old-format `pa` cell is a Byte; AP-to-master <= 255
42
+ _ROW_RE = re.compile(r"#\s*(.+?)\s*$") # the trailing `# Name` comment on a pool-CSV row
43
+
44
+ _POOL_CACHE: dict = {} # menu_type -> [Ability, ...] (or False = tried + unavailable)
45
+ _GLOBAL_NAMES = None # None = not built; dict {abil_id: name} unioned across every preset pool
46
+
47
+
48
+ # --- the mod-agnostic id <-> token codec (pure arithmetic; always available) ----------------------
49
+
50
+ def kind_of(abil_id: int) -> str:
51
+ """``"AA"`` (active) if ``abil_id % 256 < 192`` else ``"SA"`` (support) -- the engine's split (ff9abil)."""
52
+ return "AA" if abil_id % 256 < 192 else "SA"
53
+
54
+
55
+ def decode_token(abil_id: int) -> str:
56
+ """An integer ``abil_id`` -> its ``"AA:X"`` / ``"SA:X"`` token (the inverse of :func:`encode_token`)."""
57
+ if isinstance(abil_id, bool) or not isinstance(abil_id, int):
58
+ raise TypeError(f"abil_id must be an int (got {type(abil_id).__name__})")
59
+ pool, mod = divmod(abil_id, 256)
60
+ if mod < 192:
61
+ return f"AA:{pool * 192 + mod}"
62
+ return f"SA:{pool * 64 + (mod - 192)}"
63
+
64
+
65
+ def encode_token(token) -> int:
66
+ """An ``"AA:X"`` / ``"SA:X"`` token (or a plain integer / digit string) -> the integer ``abil_id``. Mirrors
67
+ Memoria ``CsvParser.AnyAbility``. Raises ValueError on a malformed token."""
68
+ if isinstance(token, bool):
69
+ raise ValueError("ability cannot be a boolean")
70
+ if isinstance(token, int):
71
+ if token < 0:
72
+ raise ValueError(f"abil_id cannot be negative (got {token})")
73
+ return token
74
+ s = str(token).strip()
75
+ m = re.fullmatch(r"(AA|SA):(-?\d+)", s, re.IGNORECASE)
76
+ if m:
77
+ kind, x = m.group(1).upper(), int(m.group(2))
78
+ if x < 0:
79
+ raise ValueError(f"ability index cannot be negative in {token!r}")
80
+ if kind == "AA":
81
+ return (x // 192) * 256 + x % 192
82
+ return (x // 64) * 256 + x % 64 + 192
83
+ if re.fullmatch(r"\d+", s):
84
+ return int(s)
85
+ raise ValueError(f"not an ability token: {token!r} (expected AA:X, SA:X, or a numeric abil_id)")
86
+
87
+
88
+ def is_token(ability) -> bool:
89
+ """True if ``ability`` is token-SHAPED -- an ``AA:``/``SA:``-prefixed string or a numeric id (NOT a NAME) --
90
+ so it can be validated WITHOUT the install's name pools: :func:`resolve` / :func:`encode_token` either decode
91
+ it or REJECT it with a clear error, offline. A real ability name never contains a colon, so an ``AA:``/``SA:``
92
+ prefix is unambiguously a token -- even a malformed one (``AA:nope``), which ``resolve`` then rejects (so a
93
+ typo'd token is caught by lint with no install, not silently skipped)."""
94
+ if isinstance(ability, bool):
95
+ return False
96
+ if isinstance(ability, int):
97
+ return True
98
+ s = str(ability).strip()
99
+ return bool(re.match(r"(?:AA|SA):", s, re.IGNORECASE)) or bool(re.fullmatch(r"\d+", s))
100
+
101
+
102
+ # --- best-effort name + AP-requirement, read live from the install's pool CSVs ---------------------
103
+
104
+ @dataclass(frozen=True)
105
+ class Ability:
106
+ """One row of a character's learnable-ability pool CSV."""
107
+ index: int # row position (== the old-format `pa` array index)
108
+ abil_id: int # the global integer id stored in pa_extended
109
+ token: str # "AA:X" / "SA:X"
110
+ kind: str # "AA" | "SA"
111
+ ap_req: int # AP needed to master (the row's 2nd column)
112
+ name: "str | None" # the row's `# comment` name (None if absent)
113
+
114
+
115
+ def _norm(s) -> str:
116
+ return "".join(c for c in str(s).lower() if c.isalnum())
117
+
118
+
119
+ def pool_for_preset(menu_type: int, game=None) -> list:
120
+ """The ordered learnable-ability pool for a preset (``menu_type``), read live from
121
+ ``<install>/.../Abilities/<Preset>.csv`` and cached. ``[]`` if the install/file isn't reachable or the preset
122
+ has no pool (16+)."""
123
+ if menu_type is None: # no preset known (e.g. a save missing info.menu_type)
124
+ return [] # -> no base pool; callers fall back to token/AP_CAP
125
+ if menu_type in _POOL_CACHE:
126
+ return _POOL_CACHE[menu_type] or []
127
+ name = PRESET_NAMES.get(int(menu_type))
128
+ if name is None:
129
+ _POOL_CACHE[menu_type] = False
130
+ return []
131
+ try:
132
+ from .config import find_game_path
133
+ p = find_game_path(game) / "StreamingAssets" / "Data" / "Characters" / "Abilities" / f"{name}.csv"
134
+ text = p.read_text(encoding="utf-8-sig", errors="replace")
135
+ except Exception: # noqa: BLE001 -- install not reachable -> degrade
136
+ _POOL_CACHE[menu_type] = False
137
+ return []
138
+ out = []
139
+ for line in text.splitlines():
140
+ s = line.strip()
141
+ if not s or s.startswith("#"):
142
+ continue
143
+ parts = s.split(";")
144
+ if len(parts) < 2:
145
+ continue
146
+ try:
147
+ abil_id = encode_token(parts[0].strip())
148
+ ap_req = int(parts[1].strip())
149
+ except ValueError:
150
+ continue
151
+ m = _ROW_RE.search(s)
152
+ nm = m.group(1).strip() if m else None
153
+ out.append(Ability(index=len(out), abil_id=abil_id, token=decode_token(abil_id),
154
+ kind=kind_of(abil_id), ap_req=ap_req, name=nm or None))
155
+ _POOL_CACHE[menu_type] = out or False
156
+ return out
157
+
158
+
159
+ def _pool_index(menu_type: int, game=None) -> dict:
160
+ """``{abil_id: Ability}`` for a preset (last row wins on a dup id)."""
161
+ return {a.abil_id: a for a in pool_for_preset(menu_type, game)}
162
+
163
+
164
+ def _global_names(game=None) -> dict:
165
+ """``{abil_id: name}`` unioned across every preset pool (for naming an id when the preset is unknown)."""
166
+ global _GLOBAL_NAMES
167
+ if _GLOBAL_NAMES is not None:
168
+ return _GLOBAL_NAMES
169
+ names: dict = {}
170
+ for mt in PRESET_NAMES:
171
+ for a in pool_for_preset(mt, game):
172
+ if a.name and a.abil_id not in names:
173
+ names[a.abil_id] = a.name
174
+ _GLOBAL_NAMES = names
175
+ return names
176
+
177
+
178
+ def name_of(abil_id: int, menu_type=None, game=None) -> "str | None":
179
+ """The display name for an ``abil_id`` -- from the given preset's pool if ``menu_type`` is set, else from the
180
+ global union. ``None`` if unknown (a modded id not in the base pools, or the install isn't reachable)."""
181
+ if menu_type is not None:
182
+ a = _pool_index(menu_type, game).get(int(abil_id))
183
+ if a is not None and a.name:
184
+ return a.name
185
+ return _global_names(game).get(int(abil_id))
186
+
187
+
188
+ def ap_required(menu_type, abil_id: int, game=None) -> "int | None":
189
+ """The AP needed to master ``abil_id`` for a preset, or ``None`` if that id isn't in the (base) pool."""
190
+ a = _pool_index(menu_type, game).get(int(abil_id))
191
+ return a.ap_req if a is not None else None
192
+
193
+
194
+ def resolve(menu_type, ability, game=None) -> int:
195
+ """An ability NAME / ``AA:X`` / ``SA:X`` / numeric id -> the integer ``abil_id``. A token / id is decoded
196
+ directly (mod-agnostic, no install needed). A NAME is matched case/space/punct-insensitively against the
197
+ preset's pool first, then the global union. Raises ValueError on an unknown name."""
198
+ if isinstance(ability, bool):
199
+ raise ValueError("ability cannot be a boolean")
200
+ if isinstance(ability, int):
201
+ return encode_token(ability)
202
+ s = str(ability).strip()
203
+ if re.match(r"(?:AA|SA):", s, re.IGNORECASE) or re.fullmatch(r"\d+", s):
204
+ return encode_token(s) # token-SHAPED -> decode, or raise a clear token error
205
+ key = _norm(s)
206
+ if menu_type is not None:
207
+ for a in pool_for_preset(menu_type, game):
208
+ if a.name and _norm(a.name) == key:
209
+ return a.abil_id
210
+ for aid, nm in _global_names(game).items(): # then any character's pool
211
+ if _norm(nm) == key:
212
+ return aid
213
+ raise ValueError(f"unknown ability {ability!r} (use an AA:X / SA:X token or a numeric id, or run "
214
+ "`ff9mapkit items --abilities` to list names)")
215
+
216
+
217
+ def available(game=None) -> bool:
218
+ """True if at least one preset pool CSV could be read (so ability NAMES/AP are live)."""
219
+ return any(pool_for_preset(mt, game) for mt in (0, 1, 2))
220
+
221
+
222
+ def _reset_cache(): # for tests
223
+ global _GLOBAL_NAMES
224
+ _POOL_CACHE.clear()
225
+ _GLOBAL_NAMES = None
@@ -0,0 +1,120 @@
1
+ """Author-facing character animation catalog for cutscenes.
2
+
3
+ Pick a gesture by NAME -- ``animation = "glad"`` -- instead of hunting a numeric id. Backed by
4
+ :mod:`ff9mapkit._animdb` (FF9 anim id <-> name, from Memoria's open-source ``AnimationDB``). An anim
5
+ name encodes its model + action: ``ANH_MAIN_F0_VIV_TALK_3_1`` -> character ``VIV`` (Vivi), form
6
+ ``F0``, action ``TALK_3_1``. The engine loads an anim by name->id onto the matching model on demand
7
+ (``AnimationFactory``), so any anim tokened to a character's model plays on that model -- proven
8
+ in-game with Vivi 7302 (= ``TALK_3_1``).
9
+
10
+ Usage::
11
+
12
+ from ff9mapkit import animations
13
+ animations.resolve("vivi", "glad") # -> 1234 (action by name on Vivi's model)
14
+ animations.resolve("vivi", "idle") # -> 148 (a universal CORE gesture)
15
+ animations.resolve("vivi", 7302) # -> 7302 (a raw id passes through)
16
+ animations.actions("vivi") # -> [("angry", 111), ("angry_2", ...), ...]
17
+
18
+ Only the 8 playable characters are covered (the cutscene presets); see ``_animdb``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import difflib
24
+
25
+ from ._animdb import MAIN_ANIMATIONS
26
+
27
+ # preset / friendly name -> the character's anim-name TOKEN.
28
+ TOKENS = {
29
+ "vivi": "VIV", "zidane": "ZDN",
30
+ "garnet": "GRN", "dagger": "GRN", "princess": "GRN",
31
+ "steiner": "STN", "freya": "FRJ", "quina": "KUI", "eiko": "EIK",
32
+ "amarant": "SLM", "salamander": "SLM",
33
+ }
34
+ _VALID_TOKENS = set(TOKENS.values())
35
+
36
+ # Universal gestures that exist for every playable character (friendly alias -> action label). These
37
+ # are the standard field-movement clips the engine itself uses; safe on any main-character model.
38
+ CORE = {
39
+ "idle": "IDLE", "stand": "IDLE",
40
+ "walk": "WALK", "run": "RUN",
41
+ "turn_left": "TURN_L", "turn_l": "TURN_L",
42
+ "turn_right": "TURN_R", "turn_r": "TURN_R",
43
+ }
44
+
45
+
46
+ def _token(model) -> str:
47
+ """Normalize a preset name / friendly name / raw token to a character TOKEN (e.g. 'VIV')."""
48
+ if model is None:
49
+ raise ValueError("no character given -- pass a preset like 'vivi' or a token like 'VIV'")
50
+ key = str(model).strip()
51
+ if key.upper() in _VALID_TOKENS:
52
+ return key.upper()
53
+ if key.lower() in TOKENS:
54
+ return TOKENS[key.lower()]
55
+ raise ValueError(f"unknown character {model!r}; known: "
56
+ f"{', '.join(sorted(set(TOKENS) | {t.lower() for t in _VALID_TOKENS}))}")
57
+
58
+
59
+ def _split(name: str):
60
+ """(form_number, token, action_label_lower) for an anim name, or None if it isn't a MAIN anim."""
61
+ p = name.split("_") # ANH MAIN F0 VIV TALK 3 1
62
+ if len(p) < 5 or p[0] != "ANH" or p[1] != "MAIN":
63
+ return None
64
+ form = int(p[2][1:]) if p[2][:1] == "F" and p[2][1:].isdigit() else 99
65
+ return form, p[3], "_".join(p[4:]).lower()
66
+
67
+
68
+ def catalog(model) -> dict:
69
+ """``{action_label: anim_id}`` for one character, preferring the canonical F0 form when an action
70
+ appears in more than one form (F0 is the field model)."""
71
+ token = _token(model)
72
+ best = {} # action -> (form_number, id)
73
+ for anim_id, name in MAIN_ANIMATIONS.items():
74
+ s = _split(name)
75
+ if not s or s[1] != token:
76
+ continue
77
+ form, _, action = s
78
+ if action not in best or form < best[action][0]:
79
+ best[action] = (form, anim_id)
80
+ return {action: aid for action, (form, aid) in best.items()}
81
+
82
+
83
+ def actions(model) -> list:
84
+ """Sorted ``[(action_label, anim_id), ...]`` for a character (for display / the CLI)."""
85
+ return sorted(catalog(model).items())
86
+
87
+
88
+ def resolve(model, action) -> int:
89
+ """Resolve an ``animation`` value to a numeric anim id. ``action`` may be:
90
+ * an int (or digit string) -> passed through unchanged (a raw id, even if not in the catalog);
91
+ * a CORE alias ('idle' / 'walk' / 'run' / 'turn_left' / 'turn_right');
92
+ * an action label from this character's catalog (case-insensitive, '-'/space -> '_').
93
+ Raises ValueError (with near-miss suggestions) on an unknown name."""
94
+ if isinstance(action, bool):
95
+ raise ValueError("animation cannot be a boolean")
96
+ if isinstance(action, int):
97
+ return action
98
+ s = str(action).strip()
99
+ if s.isdigit():
100
+ return int(s)
101
+ key = s.lower().replace("-", "_").replace(" ", "_")
102
+ if key in CORE:
103
+ key = CORE[key].lower()
104
+ cat = catalog(model)
105
+ if key in cat:
106
+ return cat[key]
107
+ hints = difflib.get_close_matches(key, cat, n=6, cutoff=0.4)
108
+ extra = f" Did you mean: {', '.join(hints)}?" if hints else \
109
+ f" Run `ff9mapkit animations {model}` to list gestures."
110
+ raise ValueError(f"unknown animation {action!r} for {model!r}.{extra}")
111
+
112
+
113
+ def name_of(anim_id: int):
114
+ """The full anim name for an id (e.g. 7302 -> 'ANH_MAIN_F0_VIV_TALK_3_1'), or None."""
115
+ return MAIN_ANIMATIONS.get(int(anim_id))
116
+
117
+
118
+ def characters() -> list:
119
+ """The preset/friendly character names that have a catalog (for the CLI / docs)."""
120
+ return sorted(TOKENS)
@@ -0,0 +1,218 @@
1
+ """Named NPC archetypes -- the Info Hub's "place a common NPC with one word".
2
+
3
+ A thin curated layer over the model->animation auto-resolution (:func:`catalog.npc_anims`): a friendly
4
+ name (``"garnet"``, ``"black_mage"``, ``"moogle"``) maps to a model whose gestures auto-resolve. Use it
5
+ as ``[[npc]] archetype = "garnet"`` (``preset`` is an accepted alias). For anything not curated here,
6
+ name the model directly: ``[[npc]] model = "GEO_NPC_F0_BAR"`` (browse with ``ff9mapkit models``).
7
+
8
+ Provenance-clean: only references Memoria model IDENTIFIERS (GEO names), never game bytes. The curated
9
+ set is intentionally small + high-confidence (the playable cast, plus NPC types confirmed in-game) and
10
+ grows as more models are identified -- a wrong name is worse than none.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from . import catalog as _catalog
15
+ from .content.npc import PRESETS as _CHAR_PRESETS # vivi / zidane: explicit anims, byte-golden
16
+
17
+ # friendly name -> a curated spec. ``model`` is a GEO name (resolved via the catalog); the model's
18
+ # gestures auto-resolve unless ``anims`` is given; ``animset`` (head height) + ``dialogue`` (a default
19
+ # line) are optional. vivi/zidane are NOT here -- they come from _CHAR_PRESETS (explicit, byte-golden).
20
+ ARCHETYPES: dict = {
21
+ # -- the playable cast: place any party member as a field NPC --
22
+ "garnet": {"model": "GEO_MAIN_F0_GRN"},
23
+ "dagger": {"model": "GEO_MAIN_F0_GRN"}, # alias for garnet
24
+ "steiner": {"model": "GEO_MAIN_F0_STN"},
25
+ "freya": {"model": "GEO_MAIN_F0_FRJ"},
26
+ "quina": {"model": "GEO_MAIN_F0_KUI"},
27
+ "eiko": {"model": "GEO_MAIN_F0_EIK"},
28
+ "amarant": {"model": "GEO_MAIN_F0_SLM"},
29
+ # -- main-character alt-forms (a specific scripted version of a hero) --
30
+ "zidane_npc": {"model": "GEO_MAIN_F0_ZDN"}, # ZDN -- Zidane's own field model placed as an NPC (vs "zidane" = the cloned player)
31
+ "steiner_carrying_dagger": {"model": "GEO_MAIN_F0_STD"}, # STD -- "STeiner + Dagger": Steiner carrying Princess Garnet (Evil Forest)
32
+ "zidane_carrying_dagger": {"model": "GEO_MAIN_F0_ZDD"}, # ZDD -- "ZiDane + Dagger": Zidane carrying Princess Garnet
33
+ # -- common NPC types (grow this as models are confirmed in-game) --
34
+ "black_mage": {"model": "GEO_NPC_F0_BMG"}, # verified in-game
35
+ "moogle": {"model": "GEO_NPC_F0_MOG"}, # the FF moogle code
36
+ # -- identified via the in-game gallery (token -> what the model actually is) --
37
+ "townswoman": {"model": "GEO_NPC_F0_APF"}, # APF "Adult Person Female"
38
+ "woman": {"model": "GEO_NPC_F0_APF"}, # alias of townswoman
39
+ "townsman": {"model": "GEO_NPC_F0_APM"}, # APM "Adult Person Male"
40
+ "man": {"model": "GEO_NPC_F0_APM"}, # alias of townsman
41
+ "bartender": {"model": "GEO_NPC_F0_BAR"}, # BAR
42
+ "old_woman": {"model": "GEO_NPC_F0_BBA"}, # BBA (JP "baba" = granny)
43
+ "granny": {"model": "GEO_NPC_F0_BBA"}, # alias of old_woman
44
+ "oglop": {"model": "GEO_NPC_F0_BRI"}, # BRI (JP "burimushi" = the Oglop bug)
45
+ "burmecian_child": {"model": "GEO_NPC_F0_BUC"}, # BUC
46
+ "burmecian_woman": {"model": "GEO_NPC_F0_BUF"}, # BUF
47
+ "cat": {"model": "GEO_NPC_F0_CAT"}, # CAT
48
+ "bird": {"model": "GEO_NPC_F0_CCB"}, # CCB (pigeon-ish)
49
+ "chocobo_child": {"model": "GEO_NPC_F0_CHC"}, # CHC (tentative -- a Black Mage Vil. chocobo)
50
+ "fat_chocobo": {"model": "GEO_NPC_F0_CHD"}, # CHD (JP "Choco Debu")
51
+ "chocobo": {"model": "GEO_NPC_F0_CHO"}, # CHO (the common field chocobo)
52
+ "high_priest": {"model": "GEO_NPC_F0_CLD"}, # CLD (Cleyra Cathedral, JP "Daikanshu")
53
+ "cleyran_woman": {"model": "GEO_NPC_F0_CLM"}, # CLM
54
+ "cook": {"model": "GEO_NPC_F0_COK"}, # COK
55
+ "engineer": {"model": "GEO_NPC_F0_CSA"}, # CSA (Lindblum engineer, e.g. Zebolt)
56
+ "zebolt": {"model": "GEO_NPC_F0_CSA"}, # alias (the named Lindblum engineer)
57
+ "lindblum_man": {"model": "GEO_NPC_F0_CSM"}, # CSM
58
+ "guard": {"model": "GEO_NPC_F0_CSO"}, # CSO (armed Lindblum guard/soldier)
59
+ "soldier": {"model": "GEO_NPC_F0_CSO"}, # alias of guard
60
+ "dali_boy": {"model": "GEO_NPC_F0_DAC"}, # DAC (Dali male child)
61
+ "dali_girl": {"model": "GEO_NPC_F0_DAF"}, # DAF (Dali female child)
62
+ "dali_man": {"model": "GEO_NPC_F0_DAL"}, # DAL (Dali male citizen)
63
+ "dali_woman": {"model": "GEO_NPC_F0_DAW"}, # DAW (Dali female citizen/worker)
64
+ "dwarf": {"model": "GEO_NPC_F0_DOC"}, # DOC (Conde Petie -- "Rally-ho!")
65
+ "dwarf_woman": {"model": "GEO_NPC_F0_DOF"}, # DOF
66
+ "dog": {"model": "GEO_NPC_F0_DOG"}, # DOG (a literal dog)
67
+ "dwarf_priest": {"model": "GEO_NPC_F0_DOK"}, # DOK (JP "Okashira" = chief/leader)
68
+ "dwarf_man": {"model": "GEO_NPC_F0_DOM"}, # DOM
69
+ "sand_oracle": {"model": "GEO_NPC_F0_FLS"}, # FLS (Cleyra's priestesses)
70
+ "frog": {"model": "GEO_NPC_F0_FRM"}, # FRM (the catchable marsh frog)
71
+ "burmecian_king": {"model": "GEO_NPC_F0_FUK"}, # FUK (dev humor: FUkkatsu = "Revival/Ruined" King)
72
+ "noble": {"model": "GEO_NPC_F0_G16"}, # G16 (G = Gentleman; Treno/Lindblum noble)
73
+ "gentleman": {"model": "GEO_NPC_F0_G16"}, # alias of noble
74
+ "noblewoman": {"model": "GEO_NPC_F0_G17"}, # G17 (female noble)
75
+ "noble_man": {"model": "GEO_NPC_F0_G18"}, # G18 (another male-noble variant)
76
+ "queen_stella": {"model": "GEO_NPC_F0_G19"}, # G19 (the Treno noble Queen Stella)
77
+ "stella": {"model": "GEO_NPC_F0_G19"}, # alias of queen_stella
78
+ "aristocrat": {"model": "GEO_NPC_F0_G20"}, # G20 (another male-noble variant)
79
+ "tour_guide": {"model": "GEO_NPC_F0_GUD"}, # GUD (Alexandria tour guide)
80
+ "commoner": {"model": "GEO_NPC_F0_HEK"}, # HEK (JP "Heikin" = average/commoner)
81
+ "bandit": {"model": "GEO_NPC_F0_HTH"}, # HTH (JP "Heikin Thief")
82
+ "thief": {"model": "GEO_NPC_F0_HTH"}, # alias of bandit
83
+ "fan_club_member": {"model": "GEO_NPC_F0_HUF"}, # HUF (Lowell's fan club, a woman)
84
+ "human_male": {"model": "GEO_NPC_F0_HUM"}, # HUM (a generic adult man)
85
+ "old_man": {"model": "GEO_NPC_F0_JJY"}, # JJY (JP "jijii" = old man)
86
+ "grandpa": {"model": "GEO_NPC_F0_JJY"}, # alias of old_man
87
+ "alexandria_child": {"model": "GEO_NPC_F0_KAC"}, # KAC (Alexandria kid, e.g. Hippaul)
88
+ "hippaul": {"model": "GEO_NPC_F0_KAC"}, # alias (the named Alexandria boy)
89
+ "bishop": {"model": "GEO_NPC_F0_NAN"}, # NAN (Esto Gaza altar)
90
+ "alexandria_soldier": {"model": "GEO_NPC_F0_OFF"}, # OFF (Alexandria's female soldiers)
91
+ "auctioneer": {"model": "GEO_NPC_F0_ORC"}, # ORC (Treno Auction House)
92
+ "scholar": {"model": "GEO_NPC_F0_OSC"}, # OSC (A. Castle Library)
93
+ "burmecian_soldier": {"model": "GEO_NPC_F0_RAS"}, # RAS (Gizamaluke bell guards)
94
+ "red_mage_woman": {"model": "GEO_NPC_F0_RMF"}, # RMF (Red Mage, Female)
95
+ "red_mage_man": {"model": "GEO_NPC_F0_RMM"}, # RMM (Red Mage, Male)
96
+ "red_mage": {"model": "GEO_NPC_F0_RMM"}, # alias of red_mage_man
97
+ "puck": {"model": "GEO_NPC_F0_RTC"}, # RTC ("Rat Child" -- the Burmecian boy-thief Zidane befriends)
98
+ "lowell": {"model": "GEO_NPC_F0_STR"}, # STR ("star" -- the famous actor; HUF = his fan club)
99
+ "theater_star": {"model": "GEO_NPC_F0_STR"}, # alias of lowell
100
+ "tadpole": {"model": "GEO_NPC_F0_TAD"}, # TAD (Qu's Marsh)
101
+ "little_boy": {"model": "GEO_NPC_F0_TBY"}, # TBY ("Tag Boy" -- Alexandria kid, plays tag with TGR)
102
+ "boy": {"model": "GEO_NPC_F0_TBY"}, # alias of little_boy
103
+ "ticket_master": {"model": "GEO_NPC_F0_TCK"}, # TCK ("ticket" -- Alexandria play ticketmaster)
104
+ "ticketmaster": {"model": "GEO_NPC_F0_TCK"}, # alias of ticket_master
105
+ "little_girl": {"model": "GEO_NPC_F0_TGR"}, # TGR ("Tag Girl" -- Alexandria kid, chases TBY)
106
+ "girl": {"model": "GEO_NPC_F0_TGR"}, # alias of little_girl
107
+ "conductor": {"model": "GEO_NPC_F0_BND"}, # BND ("band" -- the Prima Vista's conductor)
108
+ "band_member": {"model": "GEO_NPC_F0_BND"}, # alias of conductor (a Tantalus musician)
109
+ "alexandria_woman": {"model": "GEO_NPC_F0_TMF"}, # TMF (an Alexandria townswoman -- e.g. Hippaul's mother)
110
+ "hippauls_mom": {"model": "GEO_NPC_F0_TMF"}, # alias (the named Alexandria mother)
111
+ "innkeeper": {"model": "GEO_NPC_F0_TMM"}, # TMM (Alexandria townsman / the inn keeper, "Fish Man")
112
+ "fish_man": {"model": "GEO_NPC_F0_TMM"}, # alias of innkeeper (the named Alexandria man)
113
+ "servant": {"model": "GEO_NPC_F0_TRF"}, # TRF (a noble's servant -- e.g. Queen Stella's, in Treno)
114
+ "stellas_servant": {"model": "GEO_NPC_F0_TRF"}, # alias of servant
115
+ "worker": {"model": "GEO_NPC_F0_WRK"}, # WRK ("worker" -- a laborer, e.g. Dante the Alexandria signmaker)
116
+ "signmaker": {"model": "GEO_NPC_F0_WRK"}, # alias of worker
117
+ "dante": {"model": "GEO_NPC_F0_WRK"}, # alias (the named Alexandria signmaker)
118
+ # -- SUB group: the named story cast (a unique character; same model->anim auto-resolve as an NPC) --
119
+ "hilda": {"model": "GEO_SUB_F0_CDW"}, # CDW -- Cid's Wife (Hilda); seen kidnapped, Lindblum Castle
120
+ "quale": {"model": "GEO_SUB_F0_KUT"}, # KUT -- Quina's master, in Qu's Marsh (KU = Qu Tribe romaji ク族 + T = Teacher/Top = Master)
121
+ "qu_master": {"model": "GEO_SUB_F0_KUT"}, # alias of quale
122
+ "quan": {"model": "GEO_SUB_F0_KUW"}, # KUW -- Vivi's grandfather, Quan's Dwelling (KU = Qu Tribe + W = elder/grandpa suffix, JP おじいさん)
123
+ "garnets_mother": {"model": "GEO_SUB_F0_MOM"}, # MOM ("mother") -- the woman in Garnet's Memoria recollection (likely her birth mother; user: "Jane"). TENTATIVE
124
+ "genome": {"model": "GEO_SUB_F0_NTC"}, # NTC -- a genome; the roaming Terra one (normal stand/walk, best as a general placeable)
125
+ "genome_2": {"model": "GEO_SUB_F0_NTA"}, # NTA -- a Bran Bal genome (distinct idle posture; some are seated)
126
+ "genome_3": {"model": "GEO_SUB_F0_NTB"}, # NTB -- a Bran Bal genome (distinct idle posture)
127
+ "genome_4": {"model": "GEO_SUB_F0_NTD"}, # NTD -- a Bran Bal genome (distinct idle posture)
128
+ # Tantalus -- the theater-troupe thieves (all aboard the Prima Vista)
129
+ "baku": {"model": "GEO_SUB_F0_BAK"}, # BAK -- Tantalus' boss
130
+ "blank": {"model": "GEO_SUB_F0_BLN"}, # BLN -- Tantalus thief (Zidane's friend)
131
+ "marcus": {"model": "GEO_SUB_F0_MRC"}, # MRC -- Tantalus thief
132
+ "cinna": {"model": "GEO_SUB_F0_CNA"}, # CNA -- Tantalus thief (the hammer)
133
+ "ruby": {"model": "GEO_SUB_F0_RBY"}, # RBY -- Tantalus' actress
134
+ "zenero": {"model": "GEO_SUB_F0_ZNR"}, # ZNR -- a Tantalus "Nero family" member (ZNR ~ Zenero); tentative
135
+ # Alexandria royalty / antagonists
136
+ "brahne": {"model": "GEO_SUB_F0_BRN"}, # BRN -- Queen Brahne of Alexandria
137
+ "queen_brahne": {"model": "GEO_SUB_F0_BRN"}, # alias of brahne
138
+ "beatrix": {"model": "GEO_SUB_F0_BTX"}, # BTX -- General Beatrix of Alexandria
139
+ "kuja": {"model": "GEO_SUB_F0_KJA"}, # KJA -- the antagonist
140
+ "zorn": {"model": "GEO_SUB_F0_ZON"}, # ZON -- Brahne's jester (paired with Thorn)
141
+ "lani": {"model": "GEO_SUB_F0_SBW"}, # SBW -- "Scarlet Bounty Woman": Lani, the bounty hunter
142
+ "pluto_knight": {"model": "GEO_SUB_F0_SSB"}, # SSB -- "Soldier Steiner Base": a male Alexandrian soldier / Knight of Pluto (e.g. Haagen, Weimar)
143
+ # other named figures
144
+ "garland": {"model": "GEO_SUB_F0_GRL"}, # GRL -- Garland of Terra (its field is literally "Invincible/Garland")
145
+ "cid": {"model": "GEO_SUB_F0_CID"}, # CID -- Regent Cid Fabool IX of Lindblum
146
+ "regent_cid": {"model": "GEO_SUB_F0_CID"}, # alias of cid
147
+ "fratley": {"model": "GEO_SUB_F0_FLT"}, # FLT -- Sir Fratley, Burmecian Dragon Knight (Freya's lost love); JP フラットレイ "Furattorei"
148
+ "doctor_tot": {"model": "GEO_SUB_F0_TOT"}, # TOT -- Doctor Tot, the Treno scholar ("Tot Residence")
149
+ "tot": {"model": "GEO_SUB_F0_TOT"}, # alias of doctor_tot
150
+ # Black Waltzes -- Brahne's hunter-mages (No. 2 + Trance Kuja are special boss models with no
151
+ # standard idle/walk anim, so they're intentionally not archetypes -- place by model id if needed)
152
+ "black_waltz_1": {"model": "GEO_SUB_F0_BW1"}, # BW1 -- Black Waltz No. 1 (Ice Cavern)
153
+ "black_waltz_3": {"model": "GEO_SUB_F0_BW3"}, # BW3 -- Black Waltz No. 3 (Cargo Ship)
154
+ }
155
+
156
+
157
+ # --- CREATURES (GEO_MON): place a battle monster as a field object by name. The kit's field-RENDERABLE
158
+ # GEO_MON set -- verified in-game via the arena gallery (they render + animate as field objects). Most also
159
+ # appear in shipping field scripts; a few are battle bosses the kit can still place. Identified in-game via
160
+ # the gallery (`tools/build_archetype_gallery.py --arena --group MON`). Names are the canonical FF9 bestiary
161
+ # names; the token decode (where known) is in the comment.
162
+ CREATURES: dict = {
163
+ "armodullahan": {"model": "GEO_MON_F0_AMD"}, # AMD -- the Fossil Roo boss (headless armored rider)
164
+ "amdusias": {"model": "GEO_MON_F0_AMS"}, # AMS -- Treno weapon-shop duel + Pandemonium enemy
165
+ "bandersnatch": {"model": "GEO_MON_F0_BAN"}, # BAN -- Queen Brahne's hunting hounds (Alexandria)
166
+ "zaghnol": {"model": "GEO_MON_F0_BFF"}, # BFF "Beast Festival Foe" -- the Festival of the Hunt boar
167
+ "red_dragon": {"model": "GEO_MON_F0_CDR"}, # CDR "Chocobo Dragon" -- Mount Gulug boss; reuses the chocobo quadruped rig (the dev trick for many large quadrupeds)
168
+ "antlion": {"model": "GEO_MON_F0_CLB"}, # CLB "CLeyra Boss" -- the Cleyra sandpit Antlion
169
+ "taharka": {"model": "GEO_MON_F0_DAH"}, # DAH -- Ipsen's Castle boss; JP ダハカ "Dahaka", localized to Taharka
170
+ "dahaka": {"model": "GEO_MON_F0_DAH"}, # alias of taharka (the JP name + the DAH token origin)
171
+ "lich": {"model": "GEO_MON_F0_EEE"}, # EEE -- Lich, the Earth Shrine boss (the Earth elemental fiend; "EEE" = Earth)
172
+ "prison_cage": {"model": "GEO_MON_F0_EFM"}, # EFM "Evil Forest Monster" -- the Evil Forest miniboss (cage-plant that captures Garnet)
173
+ "fang": {"model": "GEO_MON_F0_FFG"}, # FFG -- a Fang (dog-like monster), seen all over Lindblum
174
+ "griffin": {"model": "GEO_MON_F0_GRI"}, # GRI -- Griffin (the Treno Weapon Shop fight)
175
+ "hedgehog_pie": {"model": "GEO_MON_F0_HHP"}, # HHP -- Hedgehog Pie (the palace hedgehog)
176
+ "catoblepas": {"model": "GEO_MON_F0_KAT"}, # KAT -- Catoblepas (phonetic: KAToblepas カトブレパス)
177
+ "mistodon": {"model": "GEO_MON_F0_MKM"}, # MKM "Mist Kaiju Monster" -- the disc-4 Mistodon (swarms Alexandria)
178
+ "behemoth": {"model": "GEO_MON_F0_MOS"}, # MOS "Monster of Shop" -- Behemoth (the Treno Weapon Shop fight)
179
+ "mu": {"model": "GEO_MON_F0_MUU"}, # MUU -- Mu (blue cat/fox; Festival of the Hunt); "Mu" padded to 3 letters
180
+ "ramuh": {"model": "GEO_MON_F0_RAM"}, # RAM -- Ramuh, the Thunder eidolon (Pinnacle Rocks cutscene)
181
+ "silver_dragon": {"model": "GEO_MON_F0_SDR"}, # SDR "Silver Dragon (Rideable)" -- Kuja's mountable dragon (Iifa Tree)
182
+ "trick_bird": {"model": "GEO_MON_F0_TBL"}, # TBL "Trick Bird, Lindblum" -- the Lindblum ambient bird
183
+ "ralvuimago": {"model": "GEO_MON_F0_TOM"}, # TOM "(Tail) Orochi Monster" (Orochi 大蛇 = giant legendary serpent) -- the Gargan Roo snake boss
184
+ "soulcage": {"model": "GEO_MON_F0_ZZZ"}, # ZZZ "Zonbi Zetsubou Zree" (ゾンビ絶望ツリー = Zombie Despair Tree) -- the Iifa Tree undead boss
185
+ }
186
+
187
+
188
+ def names() -> list:
189
+ """Every archetype name (playable presets + curated NPC types + creatures), sorted."""
190
+ return sorted(set(_CHAR_PRESETS) | set(ARCHETYPES) | set(CREATURES))
191
+
192
+
193
+ def is_archetype(name) -> bool:
194
+ """True if ``name`` is a known archetype (case-insensitive)."""
195
+ key = str(name).strip().lower()
196
+ return key in _CHAR_PRESETS or key in ARCHETYPES or key in CREATURES
197
+
198
+
199
+ def resolve(name):
200
+ """``(model_id|None, animset|None, anims|None, default_dialogue|None)`` for an archetype name.
201
+
202
+ ``vivi``/``zidane`` resolve to their byte-golden character preset (explicit anims; zidane keeps the
203
+ cloned player's model). Every other archetype resolves its model (GEO name -> id) and auto-resolves
204
+ the model's gestures via :func:`catalog.npc_anims`. Raises ValueError (listing the known names) on an
205
+ unknown one. Feeding this to ``inject_npc(model=, animset=, anims=)`` reproduces the old
206
+ ``inject_npc(preset="vivi")`` byte-for-byte, so existing builds are unaffected.
207
+ """
208
+ key = str(name).strip().lower()
209
+ if key in _CHAR_PRESETS:
210
+ model, animset, anims = _CHAR_PRESETS[key]
211
+ return model, animset, anims, None
212
+ spec = ARCHETYPES.get(key) or CREATURES.get(key)
213
+ if spec is not None:
214
+ model = _catalog.resolve_model(spec["model"])
215
+ anims = spec.get("anims") or _catalog.npc_anims(model) or None
216
+ return model, spec.get("animset"), anims, spec.get("dialogue")
217
+ raise ValueError(f"unknown archetype {name!r}. Known: {', '.join(names())}. "
218
+ f"Or name a model directly with model = \"GEO_...\" (see `ff9mapkit models`).")
ff9mapkit/areatitle.py ADDED
@@ -0,0 +1,76 @@
1
+ """The FF9 area-title overlay manifest -- WHICH scene overlays carry a field's localized "area title"
2
+ card (the big "Ice Cavern" / "Mognet Central" lettering shown on entry).
3
+
4
+ Engine background: the title is NOT a UI banner and NOT a passive engine fade -- it is a range of scene
5
+ OVERLAYS, listed per-field in the TextAsset ``mapLocalizeAreaTitle.txt`` (columns:
6
+ ``mapName, atlasW, atlasH, startOvrIdx, endOvrIdx, hasUK, ...``). The engine only re-textures that range
7
+ from ``atlas_<lang>`` for the 38 fields in ``FieldMap.fieldMapNameWithAreaTitle`` -- it never shows, hides
8
+ or fades them; the DONOR field's own ``.eb`` scripts the show+fade (scenario-gated). A fork / BG-borrow
9
+ that doesn't carry that script leaves the overlays in their default (active) state, so the title sits there
10
+ statically. :mod:`ff9mapkit.content.areatitle` uses these indices to script the lifecycle ourselves.
11
+
12
+ Offline: reads ``x64/FF9_Data/resources.assets`` via UnityPy (a DIFFERENT source than the kit's usual
13
+ ``p0data*.bin`` -- the manifest lives in the main Unity build, not the field bundles). Degrades to ``{}``
14
+ / ``None`` if UnityPy or the file is absent, so callers no-op cleanly. Provenance-clean: ships nothing,
15
+ reads the user's own install at author time.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import functools
21
+
22
+ from . import config, extract
23
+
24
+ MANIFEST_NAME = "mapLocalizeAreaTitle" # the TextAsset's m_Name (matched with/without a .txt suffix)
25
+
26
+
27
+ @functools.lru_cache(maxsize=4)
28
+ def _manifest(game_key: str | None = None) -> dict:
29
+ """``{donor_fbg: (startOvrIdx, endOvrIdx)}`` parsed from resources.assets; ``{}`` if unavailable."""
30
+ try:
31
+ UnityPy = extract._unitypy()
32
+ except Exception:
33
+ return {}
34
+ game = config.find_game_path(game_key)
35
+ if game is None:
36
+ return {}
37
+ candidates = [game / "x64" / "FF9_Data" / "resources.assets",
38
+ game / "FF9_Data" / "resources.assets",
39
+ game / "x86" / "FF9_Data" / "resources.assets"]
40
+ for res in candidates:
41
+ if not res.exists():
42
+ continue
43
+ try:
44
+ env = UnityPy.load(str(res))
45
+ except Exception:
46
+ continue
47
+ for obj in env.objects:
48
+ if obj.type.name != "TextAsset":
49
+ continue
50
+ try:
51
+ data = obj.read()
52
+ except Exception:
53
+ continue
54
+ name = str(getattr(data, "m_Name", None) or getattr(data, "name", ""))
55
+ if name.rsplit(".", 1)[0] != MANIFEST_NAME: # m_Name is "mapLocalizeAreaTitle.txt"
56
+ continue
57
+ raw = extract._raw_bytes(data)
58
+ txt = raw.decode("utf-8", "replace") if isinstance(raw, (bytes, bytearray)) else str(raw)
59
+ out: dict = {}
60
+ for line in txt.splitlines():
61
+ cols = [c.strip() for c in line.split(",")]
62
+ if len(cols) >= 5 and cols[0]:
63
+ try:
64
+ out[cols[0]] = (int(cols[3]), int(cols[4]))
65
+ except ValueError:
66
+ continue # header / malformed row
67
+ return out
68
+ return {}
69
+
70
+
71
+ def title_range(donor_fbg: str, game=None) -> "tuple[int, int] | None":
72
+ """The ``(startOvrIdx, endOvrIdx)`` scene-overlay range carrying ``donor_fbg``'s area title, or
73
+ ``None`` if the field has no area title (the vast majority -- 636 of 674). ``donor_fbg`` is the real
74
+ field's full FBG name, e.g. ``"FBG_N05_ICCV_MAP085_IC_ENT_0"``."""
75
+ key = str(game) if game is not None else None
76
+ return _manifest(key).get(donor_fbg)