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
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Custom FF9 battle-background (BBG) authoring — the battle analogue of the field pillar.
|
|
2
|
+
|
|
3
|
+
A battle background is a real textured 3D Unity model whose child meshes are named Group_0/2/4/8.
|
|
4
|
+
Memoria's ModelImporter loads a loose ``.fbx`` from the mod folder INSTEAD of the bundle, so a custom
|
|
5
|
+
battle map ships as an ASCII FBX (+ ``image#.png`` textures) at ``ModLayout.battlemap_dir(bbg)`` — no
|
|
6
|
+
engine rebuild. Proven in-game 2026-06-09 (texture reskin, a synthetic quad, and a faithful BBG_B013
|
|
7
|
+
geometry round-trip).
|
|
8
|
+
|
|
9
|
+
Loop (mirrors fields' import -> build -> deploy):
|
|
10
|
+
ff9mapkit battle-import BBG_B013 --out my_map # fork a real map -> battle.toml + FBX + textures
|
|
11
|
+
# edit my_map/BBG_B013.fbx in Blender (keep meshes named Group_0/2/4/8) / repaint the PNGs
|
|
12
|
+
ff9mapkit battle-build my_map/battle.toml --out dist
|
|
13
|
+
py tools/deploy_battle.py my_map/battle.toml # reversible install into the per-worktree mod folder
|
|
14
|
+
|
|
15
|
+
Modules: ``fbx`` (pure ASCII-FBX emitter + geometry model), ``extract`` (fork a real BBG via UnityPy),
|
|
16
|
+
``build`` (BattleProject + build_battle_mod). Provenance: extraction reads the user's install at
|
|
17
|
+
runtime; nothing extracted is committed.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from . import fbx # noqa: F401 (pure, no I/O — safe to import always)
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Emit a drop-in Memoria ``AbilityFeatures.txt`` from ``[[ability_feature]]`` -- the no-DLL DSL for what
|
|
2
|
+
support/active abilities DO: Auto-Haste, killers (Man Eater), MP+20%, Counter, gil-gated casts, command
|
|
3
|
+
disables, etc. (Memoria wiki: Supporting-/Active-ability-features.)
|
|
4
|
+
|
|
5
|
+
The kit emits a PARTIAL file (only the author's ``>SA``/``>AA``/``>CMD`` blocks); the engine reads every mod
|
|
6
|
+
folder low->high and accumulates per-ability over the base, so the base supplies the 64 SAs / 192 AAs we don't
|
|
7
|
+
touch. We pass the ``[code=...]`` / feature-line body through OPAQUE -- the engine validates the NCalc formula at
|
|
8
|
+
load; the kit only STRUCTURE-checks (id range per kind, balanced ``[code]``/``[/code]``, no nested header, known
|
|
9
|
+
``[code=TAG]`` for the closed AA/CMD sets, the cumulate/replace merge flag).
|
|
10
|
+
|
|
11
|
+
Header grammar (the engine regex, ``ff9abil.cs:515``): ``^(>SA|>AA|>CMD)\\s+(\\d+|GlobalEnemyLast|GlobalEnemy|
|
|
12
|
+
GlobalLast|Global)(\\+?)`` -- a kind, a numeric id OR a special word, an optional ``+`` (cumulate). ``+`` ADDS
|
|
13
|
+
the block on top of the base + lower folders; NO ``+`` CLEARS the lower-priority features for that id first (full
|
|
14
|
+
override). Default here is ``cumulate=true`` (the safe partial). Provenance: emits only the author's own blocks;
|
|
15
|
+
the SA name<->id table is committed (open-source ``SupportAbility`` enum, reused from :mod:`characterdelta`); AA
|
|
16
|
+
names resolve LIVE against the install's ``Actions.csv`` (ships nothing).
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
from . import characterdelta as _cd
|
|
23
|
+
|
|
24
|
+
_KINDS = ("SA", "AA", "CMD")
|
|
25
|
+
_SA_MAX, _AA_MAX = 63, 191
|
|
26
|
+
|
|
27
|
+
# the special-id words -> canonical casing + which kinds they actually ACT for (the engine silently no-ops the
|
|
28
|
+
# rest: for >AA/>CMD only "Global" reaches a branch, the other three fall through both -> a dead block).
|
|
29
|
+
_SPECIALS = {"global": "Global", "globallast": "GlobalLast",
|
|
30
|
+
"globalenemy": "GlobalEnemy", "globalenemylast": "GlobalEnemyLast"}
|
|
31
|
+
_SPECIAL_OK = {"SA": {"Global", "GlobalLast", "GlobalEnemy", "GlobalEnemyLast"},
|
|
32
|
+
"AA": {"Global"}, "CMD": {"Global"}}
|
|
33
|
+
|
|
34
|
+
# >SA feature-type verbs (a body line's first token) -- structural recognition only (the formula args are opaque).
|
|
35
|
+
_SA_FEATURE_KW = ("Permanent", "BattleStart", "BattleResult", "StatusInit", "Ability", "Command",
|
|
36
|
+
"EnemyFeature", "MorphFeature")
|
|
37
|
+
# the CLOSED [code=TAG] sets for >AA / >CMD; a tag outside them is a silent no-op in the engine -> warn (not a
|
|
38
|
+
# hard error -- the set could be incomplete, and the formula text is the author's to own).
|
|
39
|
+
_AA_TAGS = frozenset({"Condition", "Patch", "Priority", "Power", "HitRate", "Element", "Status",
|
|
40
|
+
"Target", "SpecialEffect", "GilCost", "MPCost", "ItemRequirement", "Disable"})
|
|
41
|
+
_CMD_TAGS = frozenset({"Condition", "Patch", "Disable", "HardDisable"})
|
|
42
|
+
|
|
43
|
+
_HEADER_RE = re.compile(r"^\s*>(SA|AA|CMD)\b", re.I)
|
|
44
|
+
_CODE_OPEN = re.compile(r"\[code=([^\]]*)\]", re.I)
|
|
45
|
+
_CODE_CLOSE = re.compile(r"\[/code\]", re.I)
|
|
46
|
+
_FILE_HEADER = "# ff9mapkit [[ability_feature]] -- a partial AbilityFeatures.txt (merged per-ability over the base)."
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AbilityFeatureError(ValueError):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _as_list(features):
|
|
54
|
+
if features is None:
|
|
55
|
+
return []
|
|
56
|
+
if isinstance(features, dict):
|
|
57
|
+
return [features]
|
|
58
|
+
if isinstance(features, list):
|
|
59
|
+
return features
|
|
60
|
+
raise AbilityFeatureError("[[ability_feature]] must be a table or a list of tables")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _norm(s) -> str:
|
|
64
|
+
return re.sub(r"[^0-9a-z]", "", str(s).lower())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _resolve_kind(blk, ctx) -> str:
|
|
68
|
+
k = str(blk.get("kind", "SA")).strip().upper()
|
|
69
|
+
if k not in _KINDS:
|
|
70
|
+
raise AbilityFeatureError(f"{ctx}: kind {blk.get('kind')!r} must be one of SA / AA / CMD")
|
|
71
|
+
return k
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _resolve_cumulate(blk, ctx) -> bool:
|
|
75
|
+
"""``cumulate`` (default True) -> the trailing ``+``. ``replace`` is the inverse alias (replace=True ==
|
|
76
|
+
cumulate=False = full override). Both given must agree (cumulate != replace)."""
|
|
77
|
+
cum, rep = blk.get("cumulate"), blk.get("replace")
|
|
78
|
+
if cum is not None and rep is not None:
|
|
79
|
+
if bool(cum) == bool(rep):
|
|
80
|
+
raise AbilityFeatureError(f"{ctx}: cumulate and replace are inverses -- set one, or make them differ")
|
|
81
|
+
return bool(cum)
|
|
82
|
+
if rep is not None:
|
|
83
|
+
return not bool(rep)
|
|
84
|
+
if cum is not None:
|
|
85
|
+
return bool(cum)
|
|
86
|
+
return True # safe partial: stack on top of the base
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _resolve_ability(blk, kind, *, game, strict, ctx):
|
|
90
|
+
"""``ability`` -> (header_id_str, display_name). For an AA NAME with strict=False and no install, returns
|
|
91
|
+
(None, name) -- deferred to build (the offline lint can't read Actions.csv)."""
|
|
92
|
+
tok = blk.get("ability")
|
|
93
|
+
if tok is None or isinstance(tok, bool):
|
|
94
|
+
raise AbilityFeatureError(f"{ctx}: needs an 'ability' (a name, an id, or Global/GlobalLast/...)")
|
|
95
|
+
if isinstance(tok, str) and _norm(tok) in _SPECIALS: # a special-id word
|
|
96
|
+
word = _SPECIALS[_norm(tok)]
|
|
97
|
+
if word not in _SPECIAL_OK[kind]:
|
|
98
|
+
raise AbilityFeatureError(f"{ctx}: '{word}' has no effect for >{kind} (only "
|
|
99
|
+
f"{'/'.join(sorted(_SPECIAL_OK[kind]))} act; the engine ignores the rest)")
|
|
100
|
+
return word, word
|
|
101
|
+
_is_int = isinstance(tok, int) or (isinstance(tok, str) and tok.strip().lstrip("-").isdigit())
|
|
102
|
+
if kind == "SA":
|
|
103
|
+
if _is_int:
|
|
104
|
+
aid = int(tok)
|
|
105
|
+
if not 0 <= aid <= _SA_MAX:
|
|
106
|
+
raise AbilityFeatureError(f"{ctx}: SA id {aid} out of range (0-{_SA_MAX})")
|
|
107
|
+
return str(aid), _cd._SA_NAMES[aid]
|
|
108
|
+
aid = _cd._SA_BY_NORM.get(_cd._norm_sa(tok))
|
|
109
|
+
if aid is None:
|
|
110
|
+
raise AbilityFeatureError(f"{ctx}: unknown SupportAbility {tok!r} (a name like 'Auto-Haste', "
|
|
111
|
+
f"or a 0-{_SA_MAX} id)")
|
|
112
|
+
return str(aid), _cd._SA_NAMES[aid]
|
|
113
|
+
if kind == "CMD":
|
|
114
|
+
if _is_int:
|
|
115
|
+
cid = int(tok)
|
|
116
|
+
if cid < 1:
|
|
117
|
+
raise AbilityFeatureError(f"{ctx}: CMD id {cid} invalid (id 0 no-ops; use a command id >= 1)")
|
|
118
|
+
return str(cid), str(cid)
|
|
119
|
+
raise AbilityFeatureError(f"{ctx}: CMD is id-only (give an int command id, not a name {tok!r})")
|
|
120
|
+
# kind == "AA"
|
|
121
|
+
if _is_int:
|
|
122
|
+
aid = int(tok)
|
|
123
|
+
if not 0 <= aid <= _AA_MAX:
|
|
124
|
+
raise AbilityFeatureError(f"{ctx}: AA id {aid} out of range (0-{_AA_MAX})")
|
|
125
|
+
return str(aid), str(aid)
|
|
126
|
+
if not strict and game is None:
|
|
127
|
+
return None, str(tok) # AA-by-name defers to build (needs Actions.csv)
|
|
128
|
+
from . import actiondelta as _ad
|
|
129
|
+
try:
|
|
130
|
+
_opt, _leg, cols, rows = _ad._read_raw(_ad._csv_path("Actions.csv", game))
|
|
131
|
+
aid = _ad._resolve_id(tok, rows, _ad._name_index(rows, cols), kind="ability_feature", max_id=_AA_MAX)
|
|
132
|
+
except _ad.ActionDeltaError as ex:
|
|
133
|
+
raise AbilityFeatureError(f"{ctx}: {ex}")
|
|
134
|
+
except (FileNotFoundError, OSError, RuntimeError) as ex:
|
|
135
|
+
raise AbilityFeatureError(f"{ctx}: AA-by-name needs your FF9 install to read Actions.csv ({ex})")
|
|
136
|
+
return str(aid), str(tok)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _features_text(blk, ctx) -> str:
|
|
140
|
+
present = [k for k in ("features", "code", "body") if blk.get(k) is not None]
|
|
141
|
+
if not present:
|
|
142
|
+
raise AbilityFeatureError(f"{ctx}: needs a 'features' block (the [code=...] / feature-line body)")
|
|
143
|
+
if len(present) > 1:
|
|
144
|
+
raise AbilityFeatureError(f"{ctx}: set only one of features/code/body (got {', '.join(present)})")
|
|
145
|
+
v = blk[present[0]]
|
|
146
|
+
if not isinstance(v, str):
|
|
147
|
+
raise AbilityFeatureError(f"{ctx}: '{present[0]}' must be a string")
|
|
148
|
+
return v
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _check_body(body, kind, ctx, warnings) -> None:
|
|
152
|
+
if not body.strip():
|
|
153
|
+
raise AbilityFeatureError(f"{ctx}: empty 'features' body (a header with no feature patches nothing)")
|
|
154
|
+
lines = body.splitlines()
|
|
155
|
+
for ln in lines: # (d) a body line that is itself a header would split blocks
|
|
156
|
+
if _HEADER_RE.match(ln):
|
|
157
|
+
raise AbilityFeatureError(f"{ctx}: a body line is itself a >SA/>AA/>CMD header ({ln.strip()!r}) -- "
|
|
158
|
+
f"use ONE [[ability_feature]] per header")
|
|
159
|
+
toks = sorted([(m.start(), 1) for m in _CODE_OPEN.finditer(body)] # (e) [code]/[/code] balance + no nesting
|
|
160
|
+
+ [(m.start(), -1) for m in _CODE_CLOSE.finditer(body)])
|
|
161
|
+
depth = 0
|
|
162
|
+
for _pos, d in toks:
|
|
163
|
+
depth += d
|
|
164
|
+
if depth < 0 or depth > 1:
|
|
165
|
+
raise AbilityFeatureError(f"{ctx}: unbalanced or nested [code=...]/[/code] tags")
|
|
166
|
+
if depth != 0:
|
|
167
|
+
raise AbilityFeatureError(f"{ctx}: unbalanced [code=...]/[/code] tags (a [code=...] without [/code])")
|
|
168
|
+
for m in re.finditer(r"\[code=.*?\[/code\]", body, re.DOTALL): # the engine [code] regex is NOT multiline ->
|
|
169
|
+
if "\n" in m.group(0): # a [code=...] spanning lines is silently ignored
|
|
170
|
+
warnings.append(f"{ctx}: a [code=...]...[/code] spans multiple lines -- the engine's [code] regex is "
|
|
171
|
+
f"NOT multiline and will IGNORE it; keep each [code=...] block on one line")
|
|
172
|
+
break
|
|
173
|
+
if kind in ("AA", "CMD"): # (g) closed tag set -> warn on an unknown / cross-kind tag
|
|
174
|
+
allowed = _AA_TAGS if kind == "AA" else _CMD_TAGS
|
|
175
|
+
other_kind, other_set = ("CMD", _CMD_TAGS) if kind == "AA" else ("AA", _AA_TAGS)
|
|
176
|
+
for m in _CODE_OPEN.finditer(body):
|
|
177
|
+
tag = m.group(1).strip()
|
|
178
|
+
if tag and tag not in allowed:
|
|
179
|
+
hint = f" (it's a >{other_kind} tag)" if tag in other_set else ""
|
|
180
|
+
warnings.append(f"{ctx}: [code={tag}] is not a known >{kind} feature tag{hint} -- the engine "
|
|
181
|
+
f"silently ignores an unknown tag")
|
|
182
|
+
elif kind == "SA": # (f) first body line should be a feature verb (lenient)
|
|
183
|
+
first = next((ln.strip() for ln in lines if ln.strip()), "")
|
|
184
|
+
first_tok = first.split(None, 1)[0] if first else "" # exact token (no prefix false-accept like "Abilityx")
|
|
185
|
+
if first_tok not in _SA_FEATURE_KW:
|
|
186
|
+
warnings.append(f"{ctx}: a >SA body should start with a feature type "
|
|
187
|
+
f"({' / '.join(_SA_FEATURE_KW)}); got {first_tok!r}")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _resolve_comment(blk, default_name, ctx) -> str:
|
|
191
|
+
c = blk.get("comment")
|
|
192
|
+
if c is None:
|
|
193
|
+
return str(default_name)
|
|
194
|
+
return re.sub(r"\s+", " ", str(c)).strip() # the engine ignores the tail, but keep it one line
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _emit_block(blk, n, *, game, strict, warnings, seen) -> list:
|
|
198
|
+
ctx = f"[[ability_feature]] #{n}"
|
|
199
|
+
if not isinstance(blk, dict):
|
|
200
|
+
raise AbilityFeatureError(f"{ctx} must be a table")
|
|
201
|
+
kind = _resolve_kind(blk, ctx)
|
|
202
|
+
cumulate = _resolve_cumulate(blk, ctx)
|
|
203
|
+
id_str, display = _resolve_ability(blk, kind, game=game, strict=strict, ctx=ctx)
|
|
204
|
+
if kind == "AA" and id_str == "0":
|
|
205
|
+
warnings.append(f"{ctx}: >AA id 0 is Void (a no-op active ability) -- this block won't apply")
|
|
206
|
+
has_body = any(blk.get(k) is not None for k in ("features", "code", "body"))
|
|
207
|
+
body = _features_text(blk, ctx) if has_body else ""
|
|
208
|
+
if body.strip():
|
|
209
|
+
_check_body(body, kind, ctx, warnings)
|
|
210
|
+
elif cumulate: # a `+` header with no features patches nothing
|
|
211
|
+
raise AbilityFeatureError(f"{ctx}: empty 'features' -- a cumulate (+) header with no body patches "
|
|
212
|
+
f"nothing; add a feature body, or set replace=true to CLEAR the ability's "
|
|
213
|
+
f"base features")
|
|
214
|
+
# else: empty body + replace (no `+`) = a legitimate "clear all lower-priority features" override (header only)
|
|
215
|
+
if id_str is not None: # a 2nd block for the same id with replace WIPES the first
|
|
216
|
+
key = (kind, id_str)
|
|
217
|
+
if key in seen and not cumulate:
|
|
218
|
+
warnings.append(f"{ctx}: a 2nd >{kind} {id_str} with replace/no-cumulate WIPES the earlier block")
|
|
219
|
+
seen[key] = n
|
|
220
|
+
comment = _resolve_comment(blk, display, ctx)
|
|
221
|
+
header = f">{kind} {id_str if id_str is not None else display}" + ("+" if cumulate else "")
|
|
222
|
+
if comment:
|
|
223
|
+
header += " " + comment
|
|
224
|
+
# STRIP each line (not just rstrip): a >SA feature verb must sit at column 0 -- the engine matcher `^verb\b`
|
|
225
|
+
# is Multiline but does NOT consume leading whitespace, so an indented verb is silently dropped. Indentation
|
|
226
|
+
# is never meaningful in this DSL (the [code=...] regex is position-free), so normalizing is safe.
|
|
227
|
+
body_lines = [ln.strip() for ln in body.splitlines()]
|
|
228
|
+
while body_lines and not body_lines[0]:
|
|
229
|
+
body_lines.pop(0)
|
|
230
|
+
while body_lines and not body_lines[-1]:
|
|
231
|
+
body_lines.pop()
|
|
232
|
+
return [header, *body_lines, ""]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def build_lines(features, *, game=None, strict=True):
|
|
236
|
+
"""``[[ability_feature]]`` blocks -> (lines, warnings). Offline for SA/CMD-id + AA-id; an AA NAME needs the
|
|
237
|
+
install (``game``) -- with ``strict=False`` + no install it structure-checks and defers id resolution."""
|
|
238
|
+
blocks = _as_list(features)
|
|
239
|
+
if not blocks:
|
|
240
|
+
return [], []
|
|
241
|
+
warnings, seen, out = [], {}, [_FILE_HEADER, ""]
|
|
242
|
+
for n, blk in enumerate(blocks):
|
|
243
|
+
out += _emit_block(blk, n, game=game, strict=strict, warnings=warnings, seen=seen)
|
|
244
|
+
return out, warnings
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def validate_blocks(features, *, game=None) -> list:
|
|
248
|
+
"""Offline structural + range problems (empty => OK). Re-runs emission on a copy; AA-by-name id resolution
|
|
249
|
+
defers to build (install-gated), like ``[[battle_attack]]``'s by-name path."""
|
|
250
|
+
try:
|
|
251
|
+
build_lines(features, game=game, strict=False)
|
|
252
|
+
except AbilityFeatureError as ex:
|
|
253
|
+
return [str(ex)]
|
|
254
|
+
return []
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def write_ability_features(layout, features, *, game=None) -> list:
|
|
258
|
+
"""Build + write ``layout.ability_features_txt`` (cp1252 / LF, byte-faithful with the base). Returns
|
|
259
|
+
warnings; writes nothing when there are no blocks."""
|
|
260
|
+
lines, warnings = build_lines(features, game=game, strict=True)
|
|
261
|
+
if not lines:
|
|
262
|
+
return warnings
|
|
263
|
+
path = layout.ability_features_txt
|
|
264
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
265
|
+
path.write_text("\n".join(lines) + "\n", encoding="cp1252", errors="replace", newline="\n")
|
|
266
|
+
return warnings
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---- non-clobbering merge into a live AbilityFeatures.txt (deferred -- the MVP deploy whole-file-copies) ----
|
|
270
|
+
def _markers(marker_id):
|
|
271
|
+
return (f"## >>> ff9mapkit ability_feature {marker_id} (auto -- edit the toml, not here)",
|
|
272
|
+
f"## <<< ff9mapkit ability_feature {marker_id}")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def merge_ability_features(live_text: str, block_lines, marker_id) -> str:
|
|
276
|
+
"""Splice ``block_lines`` between ``##`` sentinel markers, replacing a prior same-id block + preserving the
|
|
277
|
+
rest. ``##`` lines don't start with ``>SA/>AA/>CMD`` so the engine skips them. Idempotent; an empty
|
|
278
|
+
``block_lines`` just strips our prior block."""
|
|
279
|
+
begin, end = _markers(marker_id)
|
|
280
|
+
kept, skip = [], False
|
|
281
|
+
for ln in live_text.splitlines():
|
|
282
|
+
if ln.strip() == begin:
|
|
283
|
+
skip = True
|
|
284
|
+
continue
|
|
285
|
+
if ln.strip() == end:
|
|
286
|
+
skip = False
|
|
287
|
+
continue
|
|
288
|
+
if not skip:
|
|
289
|
+
kept.append(ln)
|
|
290
|
+
while kept and not kept[-1].strip():
|
|
291
|
+
kept.pop()
|
|
292
|
+
if block_lines:
|
|
293
|
+
kept += ["", begin, *block_lines, end]
|
|
294
|
+
return "\n".join(kept) + "\n"
|