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,441 @@
|
|
|
1
|
+
"""``[[battle_action]]`` / ``[[status]]`` -- author the shared PLAYER abilities + status definitions as
|
|
2
|
+
partial-CSV deltas (the kit's WIN over Hades Workshop: declarative, campaign-wide, provenance-clean).
|
|
3
|
+
|
|
4
|
+
[[battle_action]] # rebalance a shared ability (Actions.csv, id 0-191)
|
|
5
|
+
action = "Fire" # name (resolved from the base CSV) or a 0-191 id
|
|
6
|
+
power = 30 # damage: power / element(s) / rate / mp / script / category / type
|
|
7
|
+
element = ["Ice"] # element names -> the `elements` bitmask
|
|
8
|
+
targets = "AllEnemy" # targeting (TargetType name/id) + menu_window (TargetDisplay) +
|
|
9
|
+
default_ally = true # default_ally / for_dead / default_on_dead / camera (bools) + vfx1 / vfx2
|
|
10
|
+
status_index = 70 # the StatusSets.csv row this action inflicts/cures
|
|
11
|
+
|
|
12
|
+
[[status]] # retune a status ailment (StatusData.csv, id 0-32)
|
|
13
|
+
status = "Poison"
|
|
14
|
+
tick = 30 # OprCount (per-tick effect, 0-255)
|
|
15
|
+
duration = 0 # ContiCount (0 = until cured, 0-65535)
|
|
16
|
+
clear_on_apply = ["Sleep"] # BattleStatus lists: what applying it CLEARS / what it grants IMMUNITY to
|
|
17
|
+
immunity_provided = ["Poison"]
|
|
18
|
+
|
|
19
|
+
WHY a delta + read-base: the engine merges these CSVs by **whole-ROW replacement** keyed on id
|
|
20
|
+
(``FF9BattleDB`` / ``EnumerateCsvFromLowToHigh``), so a partial file overrides only the rows it lists while
|
|
21
|
+
the base supplies the rest. To change ONE field we must therefore emit the COMPLETE row with the base game's
|
|
22
|
+
other columns -- so this reads the base ``Actions.csv`` / ``StatusData.csv`` LIVE from the install (provenance:
|
|
23
|
+
the authored ``field.toml`` holds only your overrides; the emitted CSV is mod build-output, never committed).
|
|
24
|
+
★ The base file's ``#!`` option lines (e.g. ``#! IncludeCastingTitleType``) are LOAD-BEARING -- the engine
|
|
25
|
+
parses by column POSITION and ``#!`` toggles optional columns -- so the delta repeats them verbatim
|
|
26
|
+
(``CsvReader`` resets metadata per file). ★ The CSVs are **cp1252** (not UTF-8: a few names carry a 0x92 curly
|
|
27
|
+
apostrophe), read+written byte-faithfully. ★ Narrow engine column types (Byte/UInt16) are RANGE-CHECKED
|
|
28
|
+
offline: an out-of-range value would otherwise crash the battle DB at boot (``Byte.Parse`` overflow ->
|
|
29
|
+
``ConfirmQuit``). These blocks are mod-GLOBAL (always-on, not new-game-scoped); they live on a ``field.toml``
|
|
30
|
+
and emit at the mod-write stage. See ``docs/BATTLE_DESIGN.md`` Phase 3.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import re
|
|
35
|
+
|
|
36
|
+
from . import battlecsv
|
|
37
|
+
|
|
38
|
+
_I32 = 2 ** 31 - 1
|
|
39
|
+
# friendly TOML key -> (CSV column, encoder, max). The capped columns are narrow engine types (Byte 0-255 /
|
|
40
|
+
# UInt16 0-65535); a value past the cap is rejected OFFLINE (else Byte.Parse overflows -> a boot crash).
|
|
41
|
+
ACTION_FIELDS = {
|
|
42
|
+
"power": ("power", "int", _I32),
|
|
43
|
+
"element": ("elements", "elements", 255), "elements": ("elements", "elements", 255),
|
|
44
|
+
"rate": ("rate", "int", _I32),
|
|
45
|
+
"mp": ("mp", "int", _I32),
|
|
46
|
+
"script": ("scriptid", "script", _I32), "script_id": ("scriptid", "script", _I32),
|
|
47
|
+
"category": ("category", "int", 255),
|
|
48
|
+
"type": ("type", "int", 255),
|
|
49
|
+
# targeting + presentation (cols 3-10): the engine parses these as TargetType/TargetDisplay ENUMS (by
|
|
50
|
+
# ``Name(value)``), Booleans (``1``/``0``), and signed/unsigned Int16 anim ids -- see the encoders below.
|
|
51
|
+
"targets": ("targets", "target_type", 0),
|
|
52
|
+
"menu_window": ("menuwindow", "target_display", 0),
|
|
53
|
+
"default_ally": ("defaultally", "bool", 0),
|
|
54
|
+
"for_dead": ("fordead", "bool", 0),
|
|
55
|
+
"default_on_dead": ("defaultondead", "bool", 0),
|
|
56
|
+
"camera": ("defaultcamera", "bool", 0),
|
|
57
|
+
"vfx1": ("animationid1", "sint", 32767), "animation1": ("animationid1", "sint", 32767),
|
|
58
|
+
"vfx2": ("animationid2", "int", 65535), "animation2": ("animationid2", "int", 65535),
|
|
59
|
+
"status_index": ("statusindex", "int", _I32), # the StatusSets.csv row this action inflicts/cures
|
|
60
|
+
}
|
|
61
|
+
STATUS_FIELDS = {
|
|
62
|
+
"tick": ("oprcount", "int", 255),
|
|
63
|
+
"duration": ("conticount", "int", 65535),
|
|
64
|
+
# what this status clears / blocks: a BattleStatus list (``Name(idx), ...``) via encode_status_list.
|
|
65
|
+
"clear_on_apply": ("clearonapply", "statuslist", 0),
|
|
66
|
+
"immunity_provided": ("immunityprovided", "statuslist", 0),
|
|
67
|
+
}
|
|
68
|
+
_ACTION_MAX_ID = 191
|
|
69
|
+
_STATUS_MAX_ID = 32
|
|
70
|
+
_STATUS_SET_MAX_ID = 65535 # StatusSetId is Int32; 0-38 are the base sets, >=39 = custom (the band an action's
|
|
71
|
+
# status_index points at). Cap generously -- catches a typo, never the real type.
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ActionDeltaError(ValueError):
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _norm_name(s: str) -> str:
|
|
79
|
+
"""Lowercase + straighten curly apostrophes so ``"Dragon's Crest"`` matches the cp1252 base name."""
|
|
80
|
+
return s.strip().lower().replace("’", "'").replace("‘", "'")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---- read the base CSV (cp1252, byte-faithful), preserving the #! options + the column legend ---------
|
|
84
|
+
def _read_raw(path) -> tuple:
|
|
85
|
+
"""Parse a Memoria battle CSV for DELTA authoring -> ``(options, legend, cols, rows)``:
|
|
86
|
+
* ``options`` -- the ``#!`` lines, VERBATIM (load-bearing: they toggle optional columns).
|
|
87
|
+
* ``legend`` -- the ``# Comment;id;...`` header line (cosmetic; re-emitted for humans), or None.
|
|
88
|
+
* ``cols`` -- column name (normalized lower, ``Foo(bar)``->``foo``) -> index.
|
|
89
|
+
* ``rows`` -- ``{id: [cells...]}`` the FULL split cells of each data row (kept verbatim for re-emit).
|
|
90
|
+
Decoded as cp1252 (the install's real encoding) so a 0x92 apostrophe round-trips byte-faithfully."""
|
|
91
|
+
data = path.read_bytes()
|
|
92
|
+
if data.startswith(b"\xef\xbb\xbf"): # strip a stray UTF-8 BOM if one ever appears
|
|
93
|
+
data = data[3:]
|
|
94
|
+
options: list = []
|
|
95
|
+
legend = None
|
|
96
|
+
cols: "dict | None" = None
|
|
97
|
+
rows: dict = {}
|
|
98
|
+
for raw in data.decode("cp1252", errors="replace").splitlines():
|
|
99
|
+
s = raw.strip()
|
|
100
|
+
if not s:
|
|
101
|
+
continue
|
|
102
|
+
if s.startswith("#!"):
|
|
103
|
+
options.append(s)
|
|
104
|
+
continue
|
|
105
|
+
if s.startswith("#"):
|
|
106
|
+
if cols is None:
|
|
107
|
+
fields = [f.strip().split("(")[0].strip().lower() for f in s.lstrip("#").strip().split(";")]
|
|
108
|
+
if "id" in fields and len(fields) > 1:
|
|
109
|
+
cols = {name: i for i, name in enumerate(fields)}
|
|
110
|
+
legend = s
|
|
111
|
+
continue
|
|
112
|
+
if cols is None:
|
|
113
|
+
continue
|
|
114
|
+
cells = raw.split(";")
|
|
115
|
+
idx = cols["id"]
|
|
116
|
+
if idx >= len(cells):
|
|
117
|
+
continue
|
|
118
|
+
try:
|
|
119
|
+
rid = int(cells[idx].strip())
|
|
120
|
+
except ValueError:
|
|
121
|
+
continue
|
|
122
|
+
rows[rid] = cells
|
|
123
|
+
return options, legend, (cols or {}), rows
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _name_index(rows, cols) -> dict:
|
|
127
|
+
"""``{normalized Comment name: [ids]}`` from the base rows (a name may map to several ids -> ambiguous)."""
|
|
128
|
+
nidx = cols.get("comment", 0)
|
|
129
|
+
out: dict = {}
|
|
130
|
+
for rid, cells in rows.items():
|
|
131
|
+
if nidx < len(cells):
|
|
132
|
+
nm = _norm_name(cells[nidx].split("#")[0])
|
|
133
|
+
if nm:
|
|
134
|
+
out.setdefault(nm, []).append(rid)
|
|
135
|
+
return out
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _resolve_id(token, rows, names, *, kind, max_id):
|
|
139
|
+
if token is None or isinstance(token, bool):
|
|
140
|
+
raise ActionDeltaError(f"[[{kind}]] needs a '{kind}' (a name or a 0-{max_id} id)")
|
|
141
|
+
if isinstance(token, int) or (isinstance(token, str) and token.strip().lstrip("-").isdigit()):
|
|
142
|
+
rid = int(token)
|
|
143
|
+
if not 0 <= rid <= max_id:
|
|
144
|
+
raise ActionDeltaError(f"[[{kind}]] id {rid} out of range (0-{max_id})")
|
|
145
|
+
if rid not in rows:
|
|
146
|
+
raise ActionDeltaError(f"[[{kind}]] id {rid} is not in the base CSV")
|
|
147
|
+
return rid
|
|
148
|
+
ids = names.get(_norm_name(str(token)))
|
|
149
|
+
if not ids:
|
|
150
|
+
raise ActionDeltaError(f"[[{kind}]] unknown name {token!r} (not a row in the base CSV)")
|
|
151
|
+
if len(ids) > 1:
|
|
152
|
+
raise ActionDeltaError(f"[[{kind}]] name {token!r} is ambiguous (ids "
|
|
153
|
+
f"{', '.join(str(i) for i in sorted(ids))}) -- use the id")
|
|
154
|
+
return ids[0]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---- value encoding + range guard (shared by build + offline validate) --------------------------------
|
|
158
|
+
def _to_int(value, key) -> int:
|
|
159
|
+
if isinstance(value, bool) or not isinstance(value, (int, str)):
|
|
160
|
+
raise ActionDeltaError(f"{key} must be an integer (got {value!r})")
|
|
161
|
+
try:
|
|
162
|
+
return int(value)
|
|
163
|
+
except ValueError:
|
|
164
|
+
raise ActionDeltaError(f"{key} must be an integer (got {value!r})")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _resolve_script(value) -> int:
|
|
168
|
+
if isinstance(value, str) and not value.strip().lstrip("-").isdigit():
|
|
169
|
+
sid = {n.lower(): i for i, n in battlecsv.SCRIPT_IDS.items()}.get(value.strip().lower())
|
|
170
|
+
if sid is None:
|
|
171
|
+
raise ActionDeltaError(f"unknown scriptId formula {value!r} (see `ff9mapkit battle-actions "
|
|
172
|
+
f"--script-ids`)")
|
|
173
|
+
return sid
|
|
174
|
+
return _to_int(value, "script")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _encode_value(key, value, spec, *, warnings=None) -> str:
|
|
178
|
+
"""Resolve + RANGE-CHECK an override value -> the CSV cell string. Raises ActionDeltaError offline (so a
|
|
179
|
+
bad value fails the build/lint, never the game). ``warnings`` (optional) collects the script-catalog note."""
|
|
180
|
+
col, enc, vmax = spec
|
|
181
|
+
if enc == "int":
|
|
182
|
+
v = _to_int(value, key)
|
|
183
|
+
elif enc == "sint": # a SIGNED column (Int16 anim id): -vmax-1 .. vmax
|
|
184
|
+
v = _to_int(value, key)
|
|
185
|
+
if not -(vmax + 1) <= v <= vmax:
|
|
186
|
+
raise ActionDeltaError(f"{key}={v} out of range ({-(vmax + 1)}..{vmax})")
|
|
187
|
+
return str(v)
|
|
188
|
+
elif enc == "bool":
|
|
189
|
+
return _encode_bool(value, key) # the CSV stores Booleans as 1/0
|
|
190
|
+
elif enc in ("target_type", "target_display", "statuslist"):
|
|
191
|
+
fn = {"target_type": battlecsv.encode_target_type, "target_display": battlecsv.encode_target_display,
|
|
192
|
+
"statuslist": battlecsv.encode_status_list}[enc]
|
|
193
|
+
try:
|
|
194
|
+
return fn(value) # returns the final cell string (Name(value) / Name(idx) list)
|
|
195
|
+
except (ValueError, TypeError) as ex:
|
|
196
|
+
raise ActionDeltaError(f"{key}: {ex}")
|
|
197
|
+
elif enc == "elements":
|
|
198
|
+
try:
|
|
199
|
+
v = battlecsv.encode_elements(value)
|
|
200
|
+
except (ValueError, TypeError) as ex:
|
|
201
|
+
raise ActionDeltaError(f"{key}: {ex}")
|
|
202
|
+
elif enc == "script":
|
|
203
|
+
v = _resolve_script(value)
|
|
204
|
+
if warnings is not None and not battlecsv.is_stock_script(v):
|
|
205
|
+
warnings.append(f"scriptId {v} is not in the externalized formula catalog -- re-pointing an action "
|
|
206
|
+
f"at an existing (incl. base-engine) formula is data, but a BRAND-NEW formula needs "
|
|
207
|
+
f"a Memoria.Scripts.<Mod>.dll (not the engine DLL)")
|
|
208
|
+
else:
|
|
209
|
+
raise ActionDeltaError(f"internal: bad encoder {enc!r}")
|
|
210
|
+
if not 0 <= v <= vmax:
|
|
211
|
+
raise ActionDeltaError(f"{key}={v} out of range (0-{vmax})")
|
|
212
|
+
return str(v)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _encode_bool(value, key) -> str:
|
|
216
|
+
"""A bool / 0|1 / "true"|"false" -> the CSV "1"/"0" cell."""
|
|
217
|
+
if isinstance(value, bool):
|
|
218
|
+
return "1" if value else "0"
|
|
219
|
+
if isinstance(value, int) and value in (0, 1):
|
|
220
|
+
return str(value)
|
|
221
|
+
if isinstance(value, str) and value.strip().lower() in ("0", "1", "true", "false"):
|
|
222
|
+
return "1" if value.strip().lower() in ("1", "true") else "0"
|
|
223
|
+
raise ActionDeltaError(f"{key} must be a boolean (true/false or 1/0)")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _apply_entries(entries, rows, cols, names, fields_map, *, kind, max_id) -> tuple:
|
|
227
|
+
"""Apply ``[[kind]]`` override dicts onto the base ``rows`` in place. Returns (changed_ids, warnings)."""
|
|
228
|
+
warnings: list = []
|
|
229
|
+
changed: dict = {} # id -> first entry index that touched it (dup lint)
|
|
230
|
+
selector = "action" if kind == "battle_action" else "status"
|
|
231
|
+
for n, e in enumerate(entries):
|
|
232
|
+
rid = _resolve_id(e.get(selector), rows, names, kind=kind, max_id=max_id)
|
|
233
|
+
if rid in changed:
|
|
234
|
+
warnings.append(f"[[{kind}]] #{n} and #{changed[rid]} both target id {rid} -- they MERGE (a field "
|
|
235
|
+
f"set by both: the later wins)")
|
|
236
|
+
changed.setdefault(rid, n)
|
|
237
|
+
cells = rows[rid]
|
|
238
|
+
for k, v in e.items():
|
|
239
|
+
if k == selector:
|
|
240
|
+
continue
|
|
241
|
+
if k not in fields_map:
|
|
242
|
+
raise ActionDeltaError(f"[[{kind}]] {e.get(selector)!r}: unknown field {k!r} "
|
|
243
|
+
f"(known: {', '.join(sorted(fields_map))})")
|
|
244
|
+
col = fields_map[k][0]
|
|
245
|
+
ci = cols.get(col)
|
|
246
|
+
if ci is None:
|
|
247
|
+
warnings.append(f"[[{kind}]]: column {col!r} not present in this install's CSV -- {k} skipped")
|
|
248
|
+
continue
|
|
249
|
+
if ci >= len(cells):
|
|
250
|
+
raise ActionDeltaError(f"[[{kind}]] id {rid}: base row has no column {col!r}")
|
|
251
|
+
try:
|
|
252
|
+
cells[ci] = _encode_value(k, v, fields_map[k], warnings=warnings)
|
|
253
|
+
except ActionDeltaError as ex:
|
|
254
|
+
raise ActionDeltaError(f"[[{kind}]] {e.get(selector)!r} {ex}")
|
|
255
|
+
return list(changed), warnings
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _render(options, legend, rows, changed_ids, *, note) -> str:
|
|
259
|
+
lines = [note]
|
|
260
|
+
lines += options # the #! option lines, VERBATIM (load-bearing)
|
|
261
|
+
if legend:
|
|
262
|
+
lines.append(legend) # the column legend (cosmetic, for humans)
|
|
263
|
+
for rid in sorted(changed_ids):
|
|
264
|
+
lines.append(";".join(rows[rid]))
|
|
265
|
+
return "\n".join(lines) + "\n"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---- public: read the install + build the delta text -------------------------------------------------
|
|
269
|
+
def _csv_path(name, game):
|
|
270
|
+
from ..config import find_game_path
|
|
271
|
+
return find_game_path(game) / "StreamingAssets" / "Data" / "Battle" / name
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _build(name, entries, fields_map, *, kind, max_id, note, game):
|
|
275
|
+
try:
|
|
276
|
+
options, legend, cols, rows = _read_raw(_csv_path(name, game))
|
|
277
|
+
except (FileNotFoundError, OSError, RuntimeError) as ex: # incl. config.ConfigError (install not found)
|
|
278
|
+
raise ActionDeltaError(f"[[{kind}]] needs your FF9 install to read the base {name} ({ex})")
|
|
279
|
+
if not cols or not rows:
|
|
280
|
+
raise ActionDeltaError(f"could not parse the base {name} (no id column / no rows)")
|
|
281
|
+
changed, warnings = _apply_entries(entries, rows, cols, _name_index(rows, cols),
|
|
282
|
+
fields_map, kind=kind, max_id=max_id)
|
|
283
|
+
return _render(options, legend, rows, changed, note=note), warnings
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def build_actions_delta(entries, *, game=None) -> tuple:
|
|
287
|
+
"""Read the base Actions.csv + apply ``[[battle_action]]`` entries -> (delta_text, warnings)."""
|
|
288
|
+
note = ("# ff9mapkit [[battle_action]] -- a partial Actions.csv delta (merged over the base by the engine; "
|
|
289
|
+
"the #! lines below are load-bearing).")
|
|
290
|
+
return _build("Actions.csv", entries, ACTION_FIELDS, kind="battle_action", max_id=_ACTION_MAX_ID,
|
|
291
|
+
note=note, game=game)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def build_status_delta(entries, *, game=None) -> tuple:
|
|
295
|
+
note = ("# ff9mapkit [[status]] -- a partial StatusData.csv delta (merged over the base by the engine; "
|
|
296
|
+
"the #! lines below are load-bearing).")
|
|
297
|
+
return _build("StatusData.csv", entries, STATUS_FIELDS, kind="status", max_id=_STATUS_MAX_ID,
|
|
298
|
+
note=note, game=game)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def build_status_sets(entries, *, game=None) -> tuple:
|
|
302
|
+
"""``[[status_set]]`` -> a partial ``StatusSets.csv`` (the named multi-status BUNDLES an action's
|
|
303
|
+
``status_index`` points at). Emits ONLY the author's rows -- the engine merges low->high BY ID
|
|
304
|
+
(``FF9BattleDB.LoadStatusSets``), so no base read is needed (fully offline + provenance-clean). Row format
|
|
305
|
+
``Name;Id;StatusList`` with ``#! UnshiftStatuses`` (the ``Name(idx)`` status list, reusing the StatusData
|
|
306
|
+
encoder). Returns (text, warnings)."""
|
|
307
|
+
note = ("# ff9mapkit [[status_set]] -- a partial StatusSets.csv (merged per-id over the base; ids 0-38 are "
|
|
308
|
+
"the base sets, use >=39 for a custom one). An action points at a set via its `status_index`.")
|
|
309
|
+
lines, warnings, seen = [note, "#! UnshiftStatuses"], [], {}
|
|
310
|
+
for n, e in enumerate(entries if isinstance(entries, list) else [entries]):
|
|
311
|
+
ctx = f"[[status_set]] #{n}"
|
|
312
|
+
if not isinstance(e, dict):
|
|
313
|
+
raise ActionDeltaError(f"{ctx} must be a table")
|
|
314
|
+
sid = _to_int(e.get("id"), f"{ctx} id")
|
|
315
|
+
if not 0 <= sid <= _STATUS_SET_MAX_ID:
|
|
316
|
+
raise ActionDeltaError(f"{ctx}: id {sid} out of range (0-{_STATUS_SET_MAX_ID}; 0-38 = base sets, "
|
|
317
|
+
f">=39 = custom)")
|
|
318
|
+
if sid in seen:
|
|
319
|
+
warnings.append(f"{ctx}: id {sid} already set by #{seen[sid]} -- the later wins")
|
|
320
|
+
seen[sid] = n
|
|
321
|
+
name = re.sub(r"[;\r\n]+", " ", str(e.get("name", f"Set {sid}"))).strip() or f"Set {sid}"
|
|
322
|
+
try:
|
|
323
|
+
statuses = battlecsv.encode_status_list(e.get("statuses"))
|
|
324
|
+
except (ValueError, TypeError) as ex:
|
|
325
|
+
raise ActionDeltaError(f"{ctx}: {ex}")
|
|
326
|
+
lines.append(f"{name};{sid};{statuses};# {name}")
|
|
327
|
+
return "\n".join(lines) + "\n", warnings
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def validate_status_sets(entries) -> list:
|
|
331
|
+
"""Offline structural + range problems for ``[[status_set]]`` (empty => OK)."""
|
|
332
|
+
try:
|
|
333
|
+
build_status_sets(entries)
|
|
334
|
+
except ActionDeltaError as ex:
|
|
335
|
+
return [str(ex)]
|
|
336
|
+
return []
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _ability_list(value, ctx) -> str:
|
|
340
|
+
"""A list of active-ability ids (0-191, or ``"AA:n"`` tokens) -> the ``AA:n, AA:n`` Ability[] cell."""
|
|
341
|
+
if value is None:
|
|
342
|
+
return ""
|
|
343
|
+
out = []
|
|
344
|
+
for a in (value if isinstance(value, list) else [value]):
|
|
345
|
+
if isinstance(a, str) and a.strip().upper().startswith("AA:"):
|
|
346
|
+
a = a.strip()[3:]
|
|
347
|
+
aid = _to_int(a, ctx)
|
|
348
|
+
if not 0 <= aid <= _ACTION_MAX_ID:
|
|
349
|
+
raise ActionDeltaError(f"{ctx}: ability id {aid} out of range (0-{_ACTION_MAX_ID})")
|
|
350
|
+
out.append(f"AA:{aid}")
|
|
351
|
+
return ", ".join(out)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def build_magic_sword_sets(entries, *, game=None) -> tuple:
|
|
355
|
+
"""``[[magic_sword_set]]`` -> a partial ``MagicSwordSets.csv`` (Steiner+Vivi-style combo unlocks): a
|
|
356
|
+
Supporter's BaseAbilities unlock the Beneficiary's UnlockedAbilities, unless a blocking status is present.
|
|
357
|
+
Per-id partial merge (``LoadMagicSwordSets`` via ``EnumerateCsvFromLowToHigh``) -> emits ONLY the author's
|
|
358
|
+
rows (no base read; offline + provenance-clean). Row ``Id;Sup;Ben;AA[];AA[];Status[];Status[]``."""
|
|
359
|
+
from . import characterdelta as _cd
|
|
360
|
+
note = ("# ff9mapkit [[magic_sword_set]] -- a partial MagicSwordSets.csv (merged per-id over the base). The "
|
|
361
|
+
"Supporter's base_abilities unlock the Beneficiary's unlocked_abilities (e.g. Vivi's Black Magic -> "
|
|
362
|
+
"Steiner's Magic Sword), unless a blocking status is on the supporter/beneficiary.")
|
|
363
|
+
lines, warnings, seen = [note, "#! IncludeStatusBlockers"], [], {}
|
|
364
|
+
for n, e in enumerate(entries if isinstance(entries, list) else [entries]):
|
|
365
|
+
ctx = f"[[magic_sword_set]] #{n}"
|
|
366
|
+
if not isinstance(e, dict):
|
|
367
|
+
raise ActionDeltaError(f"{ctx} must be a table")
|
|
368
|
+
sid = _to_int(e.get("id"), f"{ctx} id")
|
|
369
|
+
if not 0 <= sid <= _STATUS_SET_MAX_ID:
|
|
370
|
+
raise ActionDeltaError(f"{ctx}: id {sid} out of range (0-{_STATUS_SET_MAX_ID})")
|
|
371
|
+
if sid in seen:
|
|
372
|
+
warnings.append(f"{ctx}: id {sid} already set by #{seen[sid]} -- the later wins")
|
|
373
|
+
seen[sid] = n
|
|
374
|
+
try:
|
|
375
|
+
sup, ben = _cd._resolve_char_id(e.get("supporter")), _cd._resolve_char_id(e.get("beneficiary"))
|
|
376
|
+
except _cd.CharacterDeltaError as ex:
|
|
377
|
+
raise ActionDeltaError(f"{ctx} (supporter/beneficiary): {str(ex).split(': ', 1)[-1]}")
|
|
378
|
+
base = _ability_list(e.get("base_abilities"), f"{ctx} base_abilities")
|
|
379
|
+
unlocked = _ability_list(e.get("unlocked_abilities"), f"{ctx} unlocked_abilities")
|
|
380
|
+
try:
|
|
381
|
+
sup_b = battlecsv.encode_status_list(e.get("supporter_blocking_status"))
|
|
382
|
+
ben_b = battlecsv.encode_status_list(e.get("beneficiary_blocking_status"))
|
|
383
|
+
except (ValueError, TypeError) as ex:
|
|
384
|
+
raise ActionDeltaError(f"{ctx}: {ex}")
|
|
385
|
+
cmt = re.sub(r"[;\r\n]+", " ", str(e.get("name", f"magic sword set {sid}"))).strip() or f"set {sid}"
|
|
386
|
+
lines.append(f"{sid};{sup};{ben};{base};{unlocked};{sup_b};{ben_b};# {cmt}")
|
|
387
|
+
return "\n".join(lines) + "\n", warnings
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def validate_magic_sword_sets(entries) -> list:
|
|
391
|
+
"""Offline structural + range problems for ``[[magic_sword_set]]`` (empty => OK)."""
|
|
392
|
+
try:
|
|
393
|
+
build_magic_sword_sets(entries)
|
|
394
|
+
except ActionDeltaError as ex:
|
|
395
|
+
return [str(ex)]
|
|
396
|
+
return []
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def write_battle_data(layout, *, actions=None, statuses=None, status_sets=None, magic_sword_sets=None,
|
|
400
|
+
game=None) -> list:
|
|
401
|
+
"""Emit the Actions / StatusData / StatusSets / MagicSwordSets CSV deltas into ``layout`` (mod-write stage).
|
|
402
|
+
Returns warnings. Written cp1252 (byte-faithful with the base) + LF; the engine StreamReader is EOL-agnostic."""
|
|
403
|
+
warnings: list = []
|
|
404
|
+
for entries, path, builder in ((actions, layout.actions_csv, build_actions_delta),
|
|
405
|
+
(statuses, layout.status_data_csv, build_status_delta),
|
|
406
|
+
(status_sets, layout.status_sets_csv, build_status_sets),
|
|
407
|
+
(magic_sword_sets, layout.magic_sword_sets_csv, build_magic_sword_sets)):
|
|
408
|
+
if entries:
|
|
409
|
+
text, w = builder(entries, game=game)
|
|
410
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
411
|
+
path.write_text(text, encoding="cp1252", errors="replace", newline="\n")
|
|
412
|
+
warnings += w
|
|
413
|
+
return warnings
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ---- offline (no-install) structural + range validation ----------------------------------------------
|
|
417
|
+
def validate_entry(entry, *, kind) -> list:
|
|
418
|
+
"""Checks that don't need the install: structural + the value range/encoders (so `lint` catches an
|
|
419
|
+
out-of-Byte-range value or a bad element/script name offline). The name->id resolution of the
|
|
420
|
+
action/status itself happens at build (which has the install to read the base row)."""
|
|
421
|
+
fields_map = ACTION_FIELDS if kind == "battle_action" else STATUS_FIELDS
|
|
422
|
+
selector = "action" if kind == "battle_action" else "status"
|
|
423
|
+
problems: list = []
|
|
424
|
+
if not isinstance(entry, dict):
|
|
425
|
+
return [f"[[{kind}]] must be a table ({selector} = \"...\", a field = value)"]
|
|
426
|
+
if entry.get(selector) is None or isinstance(entry.get(selector), bool):
|
|
427
|
+
problems.append(f"[[{kind}]] needs a '{selector}' (a name or an id)")
|
|
428
|
+
overrides = [k for k in entry if k != selector]
|
|
429
|
+
if not overrides:
|
|
430
|
+
problems.append(f"[[{kind}]] {entry.get(selector)!r} sets no fields (give e.g. "
|
|
431
|
+
f"{'power = 30' if kind == 'battle_action' else 'tick = 30'})")
|
|
432
|
+
for k in overrides:
|
|
433
|
+
if k not in fields_map:
|
|
434
|
+
problems.append(f"[[{kind}]] {entry.get(selector)!r}: unknown field {k!r} "
|
|
435
|
+
f"(known: {', '.join(sorted(fields_map))})")
|
|
436
|
+
continue
|
|
437
|
+
try:
|
|
438
|
+
_encode_value(k, entry[k], fields_map[k])
|
|
439
|
+
except ActionDeltaError as ex:
|
|
440
|
+
problems.append(f"[[{kind}]] {entry.get(selector)!r} {ex}")
|
|
441
|
+
return problems
|