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.
- ff9mapkit/__init__.py +18 -0
- ff9mapkit/__main__.py +36 -0
- ff9mapkit/_animdb.py +2994 -0
- ff9mapkit/_animdb_all.py +14125 -0
- ff9mapkit/_fieldtable.py +1516 -0
- ff9mapkit/_fieldtext.py +845 -0
- ff9mapkit/_held_poses.py +44 -0
- ff9mapkit/_itemdb.py +65 -0
- ff9mapkit/_modeldb.py +725 -0
- ff9mapkit/_narrowmap_data.py +10 -0
- ff9mapkit/_npcparams.py +634 -0
- ff9mapkit/_regen_animdb.py +72 -0
- ff9mapkit/_regen_animdb_all.py +66 -0
- ff9mapkit/_regen_fieldtable.py +95 -0
- ff9mapkit/_regen_fieldtext.py +66 -0
- ff9mapkit/_regen_modeldb.py +67 -0
- ff9mapkit/_regen_npcparams.py +123 -0
- ff9mapkit/_regen_scenedb.py +57 -0
- ff9mapkit/_scenedb.py +869 -0
- ff9mapkit/abilities.py +225 -0
- ff9mapkit/animations.py +120 -0
- ff9mapkit/archetypes.py +218 -0
- ff9mapkit/areatitle.py +76 -0
- ff9mapkit/battle/__init__.py +21 -0
- ff9mapkit/battle/abilityfeatures.py +294 -0
- ff9mapkit/battle/actiondelta.py +441 -0
- ff9mapkit/battle/aiauthor.py +305 -0
- ff9mapkit/battle/ailint.py +140 -0
- ff9mapkit/battle/aipatch.py +175 -0
- ff9mapkit/battle/battleai.py +148 -0
- ff9mapkit/battle/battlecsv.py +390 -0
- ff9mapkit/battle/battlepatch.py +395 -0
- ff9mapkit/battle/build.py +558 -0
- ff9mapkit/battle/camera_codec.py +332 -0
- ff9mapkit/battle/camera_data.py +128 -0
- ff9mapkit/battle/characterdelta.py +789 -0
- ff9mapkit/battle/event_data.py +72 -0
- ff9mapkit/battle/extract.py +540 -0
- ff9mapkit/battle/fbx.py +223 -0
- ff9mapkit/battle/reskin.py +149 -0
- ff9mapkit/battle/scene_codec.py +314 -0
- ff9mapkit/battle/scene_data.py +369 -0
- ff9mapkit/battle/scenelint.py +125 -0
- ff9mapkit/battle/seqasm.py +131 -0
- ff9mapkit/battle/seqauthor.py +220 -0
- ff9mapkit/battle/seqcodec.py +300 -0
- ff9mapkit/battle/seqdis.py +106 -0
- ff9mapkit/battle/seqpatch.py +137 -0
- ff9mapkit/battle_bgm.py +133 -0
- ff9mapkit/binutils.py +60 -0
- ff9mapkit/build.py +5445 -0
- ff9mapkit/campaign.py +1276 -0
- ff9mapkit/catalog.py +316 -0
- ff9mapkit/chain.py +358 -0
- ff9mapkit/cli.py +3114 -0
- ff9mapkit/config.py +360 -0
- ff9mapkit/content/__init__.py +13 -0
- ff9mapkit/content/areatitle.py +36 -0
- ff9mapkit/content/ate.py +118 -0
- ff9mapkit/content/camera.py +123 -0
- ff9mapkit/content/chest.py +186 -0
- ff9mapkit/content/choice.py +163 -0
- ff9mapkit/content/conductor.py +217 -0
- ff9mapkit/content/cutscene.py +290 -0
- ff9mapkit/content/encounter.py +41 -0
- ff9mapkit/content/entry_settle.py +50 -0
- ff9mapkit/content/equipment.py +93 -0
- ff9mapkit/content/event.py +191 -0
- ff9mapkit/content/gateway.py +101 -0
- ff9mapkit/content/inventory.py +59 -0
- ff9mapkit/content/itemdata.py +644 -0
- ff9mapkit/content/itemtext.py +168 -0
- ff9mapkit/content/jump.py +114 -0
- ff9mapkit/content/ladder.py +633 -0
- ff9mapkit/content/movement.py +53 -0
- ff9mapkit/content/music.py +97 -0
- ff9mapkit/content/npc.py +348 -0
- ff9mapkit/content/object.py +340 -0
- ff9mapkit/content/onentry.py +135 -0
- ff9mapkit/content/party.py +111 -0
- ff9mapkit/content/pathfind.py +138 -0
- ff9mapkit/content/platform.py +314 -0
- ff9mapkit/content/player.py +168 -0
- ff9mapkit/content/prop.py +75 -0
- ff9mapkit/content/region.py +340 -0
- ff9mapkit/content/reinit.py +59 -0
- ff9mapkit/content/savepoint.py +90 -0
- ff9mapkit/content/shop.py +178 -0
- ff9mapkit/content/sps_trigger.py +66 -0
- ff9mapkit/content/startup.py +71 -0
- ff9mapkit/content/synthesis.py +106 -0
- ff9mapkit/content/text.py +183 -0
- ff9mapkit/content/textcarry.py +290 -0
- ff9mapkit/content/verbatim.py +86 -0
- ff9mapkit/content/walkmesh_hotfix.py +38 -0
- ff9mapkit/data/__init__.py +48 -0
- ff9mapkit/data/_regen_provenance.py +142 -0
- ff9mapkit/data/provenance/blank.es.patch +1 -0
- ff9mapkit/data/provenance/blank.fr.patch +1 -0
- ff9mapkit/data/provenance/blank.gr.patch +1 -0
- ff9mapkit/data/provenance/blank.it.patch +1 -0
- ff9mapkit/data/provenance/blank.jp.patch +1 -0
- ff9mapkit/data/provenance/blank.uk.patch +1 -0
- ff9mapkit/data/provenance/blank.us.patch +1 -0
- ff9mapkit/data/provenance/manifest.json +65 -0
- ff9mapkit/data/provenance/region_template.patch +1 -0
- ff9mapkit/data/reference_arcs.toml +89 -0
- ff9mapkit/data/region_catalog.toml +593 -0
- ff9mapkit/deploystack.py +358 -0
- ff9mapkit/dialogue.py +803 -0
- ff9mapkit/eb/__init__.py +12 -0
- ff9mapkit/eb/_exprtable.py +59 -0
- ff9mapkit/eb/_membertable.py +38 -0
- ff9mapkit/eb/_optables.py +537 -0
- ff9mapkit/eb/_regen_optables.py +76 -0
- ff9mapkit/eb/cmdasm.py +323 -0
- ff9mapkit/eb/disasm.py +332 -0
- ff9mapkit/eb/edit.py +439 -0
- ff9mapkit/eb/exprasm.py +158 -0
- ff9mapkit/eb/model.py +178 -0
- ff9mapkit/eb/opcodes.py +463 -0
- ff9mapkit/eblint.py +177 -0
- ff9mapkit/editor/__init__.py +20 -0
- ff9mapkit/editor/app.py +950 -0
- ff9mapkit/editor/battle_forms.py +240 -0
- ff9mapkit/editor/breadcrumb.py +89 -0
- ff9mapkit/editor/dialogs.py +116 -0
- ff9mapkit/editor/feedback.py +208 -0
- ff9mapkit/editor/forms.py +632 -0
- ff9mapkit/editor/graphview.py +350 -0
- ff9mapkit/editor/jobs.py +342 -0
- ff9mapkit/editor/model.py +243 -0
- ff9mapkit/editor/picker.py +120 -0
- ff9mapkit/editor/theme.py +212 -0
- ff9mapkit/eventscan.py +1441 -0
- ff9mapkit/extract.py +2279 -0
- ff9mapkit/flags.py +693 -0
- ff9mapkit/forkreport.py +1383 -0
- ff9mapkit/hub.py +477 -0
- ff9mapkit/idgated.py +101 -0
- ff9mapkit/infohub.py +580 -0
- ff9mapkit/items.py +63 -0
- ff9mapkit/itemstats.py +346 -0
- ff9mapkit/journey.py +1902 -0
- ff9mapkit/keyitems.py +93 -0
- ff9mapkit/logic_add.py +632 -0
- ff9mapkit/logic_edit.py +728 -0
- ff9mapkit/logic_map.py +526 -0
- ff9mapkit/pack.py +175 -0
- ff9mapkit/playerswap.py +231 -0
- ff9mapkit/prop_archetypes.py +228 -0
- ff9mapkit/provision.py +282 -0
- ff9mapkit/refarc.py +825 -0
- ff9mapkit/save.py +337 -0
- ff9mapkit/save_items.py +1673 -0
- ff9mapkit/scene/__init__.py +11 -0
- ff9mapkit/scene/arena.py +63 -0
- ff9mapkit/scene/bgart.py +140 -0
- ff9mapkit/scene/bgi.py +732 -0
- ff9mapkit/scene/bgs.py +174 -0
- ff9mapkit/scene/bgx.py +185 -0
- ff9mapkit/scene/cam.py +345 -0
- ff9mapkit/scene/guide.py +311 -0
- ff9mapkit/scene/paint.py +506 -0
- ff9mapkit/scene/placeholder.py +107 -0
- ff9mapkit/sjbinary.py +285 -0
- ff9mapkit/sps/__init__.py +17 -0
- ff9mapkit/sps/author.py +294 -0
- ff9mapkit/sps/catalog.py +88 -0
- ff9mapkit/sps/codec.py +264 -0
- ff9mapkit/sps/edit.py +184 -0
- ff9mapkit/sps/lint.py +58 -0
- ff9mapkit/sps/render.py +116 -0
- ff9mapkit/sps/templates.py +47 -0
- ff9mapkit/sps/texture.py +131 -0
- ff9mapkit/walkmesh_hotfixes.py +163 -0
- ff9mapkit/workspace/__init__.py +18 -0
- ff9mapkit/workspace/battledoc.py +985 -0
- ff9mapkit/workspace/builddoc.py +607 -0
- ff9mapkit/workspace/forms_qt.py +586 -0
- ff9mapkit/workspace/importdoc.py +665 -0
- ff9mapkit/workspace/mapview.py +131 -0
- ff9mapkit/workspace/palette.py +85 -0
- ff9mapkit/workspace/savedoc.py +664 -0
- ff9mapkit/workspace/shell.py +6907 -0
- ff9mapkit/workspace/style.py +105 -0
- ff9mapkit/workspace/tuningdialog.py +223 -0
- ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
- ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
- ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
- ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
- ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
- 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
|
ff9mapkit/animations.py
ADDED
|
@@ -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)
|
ff9mapkit/archetypes.py
ADDED
|
@@ -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)
|