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,789 @@
|
|
|
1
|
+
"""``[[character]]`` / ``[[leveling]]`` -- author the PLAYER-side balance CSVs (``Data/Characters``) as deltas:
|
|
2
|
+
the Phase-5 twin of :mod:`actiondelta` (which does the enemy/ability side). See ``docs/BATTLE_DESIGN.md`` §8.
|
|
3
|
+
|
|
4
|
+
[[character]] # per-character base stats (BaseStats.csv, CharacterId 0-11)
|
|
5
|
+
character = "Vivi" # name (Zidane..Beatrix) or a 0-11 id
|
|
6
|
+
strength = 30 # any of: dexterity / strength / magic / will / gems
|
|
7
|
+
magic = 40
|
|
8
|
+
|
|
9
|
+
[[leveling]] # the 99-step growth curve (Leveling.csv, by level 1-99)
|
|
10
|
+
level = 50 # 1-99
|
|
11
|
+
exp = 250000 # experience to the NEXT level (UInt32)
|
|
12
|
+
bonus_hp = 4000 # HP at this level grows BonusHP*Strength/50 (UInt16)
|
|
13
|
+
bonus_mp = 600 # MP grows BonusMP*Magic/100 (UInt16)
|
|
14
|
+
|
|
15
|
+
WHY the two channels differ (this is the whole design):
|
|
16
|
+
* **BaseStats.csv merges PER-ID** -- ``EnumerateCsvFromLowToHigh`` then ``result[id]=row`` (``ff9level.cs:30``),
|
|
17
|
+
so a PARTIAL file overrides only the characters it lists; the base supplies the other 11. A delta is legal.
|
|
18
|
+
* **Leveling.csv is read WHOLE-FILE** -- ``GetCsvWithHighestPriority`` (``ff9level.cs:53``) returns the single
|
|
19
|
+
highest-priority file (it never accumulates rows) and the loader GATES at ``levels.Length >= 99``
|
|
20
|
+
(``ff9level.cs:59``). So a partial Leveling.csv would **WIPE** every level it omits -> we read the base 99
|
|
21
|
+
rows LIVE, patch the named levels, and re-emit ALL 99. (Like ``InitialItems.csv``, a higher-priority stacked
|
|
22
|
+
mod folder's Leveling.csv SHADOWS ours -- warned.)
|
|
23
|
+
|
|
24
|
+
Both read the base CSV LIVE from the install (provenance: only your overrides live in the toml; the emitted CSV
|
|
25
|
+
is mod build-output, never committed). The full ``#`` header block is preserved verbatim. Narrow engine column
|
|
26
|
+
types (Byte / UInt16 / UInt32) are RANGE-CHECKED offline so an out-of-range value fails the build/lint -- never
|
|
27
|
+
the game's boot (``CsvParser.Byte`` would overflow -> ``ff9level`` ``ConfirmQuit`` at load). cp1252 + LF, matching
|
|
28
|
+
the install.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
|
|
34
|
+
_U16 = 0xFFFF
|
|
35
|
+
_U32 = 0xFFFFFFFF
|
|
36
|
+
_I32 = 2 ** 31 - 1
|
|
37
|
+
|
|
38
|
+
# committed CharacterId name->id (the open-source Memoria enum, CharacterId.cs: Zidane=0 .. Beatrix=11). The
|
|
39
|
+
# 8-11 guests (Cinna/Marcus/Blank/Beatrix) are valid BaseStats ids too. Provenance-clean (enum names, no SE data).
|
|
40
|
+
CHARACTER_IDS = {
|
|
41
|
+
"zidane": 0, "vivi": 1, "garnet": 2, "steiner": 3, "freya": 4, "quina": 5, "eiko": 6, "amarant": 7,
|
|
42
|
+
"cinna": 8, "marcus": 9, "blank": 10, "beatrix": 11,
|
|
43
|
+
}
|
|
44
|
+
_MAX_CHAR_ID = 11
|
|
45
|
+
|
|
46
|
+
# friendly TOML key -> (BaseStats column name, max). Dexterity/Strength/Magic/Will are Byte (the base stat; the
|
|
47
|
+
# engine formula later clamps the DERIVED stat to 50/99). Gems is UInt32.
|
|
48
|
+
CHARACTER_FIELDS = {
|
|
49
|
+
"dexterity": ("dexterity", 0xFF), "dex": ("dexterity", 0xFF),
|
|
50
|
+
"strength": ("strength", 0xFF), "str": ("strength", 0xFF),
|
|
51
|
+
"magic": ("magic", 0xFF), "mag": ("magic", 0xFF),
|
|
52
|
+
"will": ("will", 0xFF), "spirit": ("will", 0xFF),
|
|
53
|
+
"gems": ("gems", _U32),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Leveling has NO id column -- it is keyed by ROW ORDER (line N = level N). friendly key -> (column INDEX, max).
|
|
57
|
+
LEVELING_FIELDS = {
|
|
58
|
+
"exp": (0, _U32), "experience": (0, _U32),
|
|
59
|
+
"bonus_hp": (1, _U16), "hp": (1, _U16),
|
|
60
|
+
"bonus_mp": (2, _U16), "mp": (2, _U16),
|
|
61
|
+
}
|
|
62
|
+
_LEVEL_COUNT = 99
|
|
63
|
+
|
|
64
|
+
# committed SupportAbility names by id (the open-source Memoria enum SupportAbility.cs: id 0-62 real, 63=Void
|
|
65
|
+
# sentinel). Provenance-clean (enum names, no SE data). The CSV's Comment column ("Auto-Reflect") differs from
|
|
66
|
+
# these enum names ("AutoReflect"), so we key by Id and match input by a normalized name (strip non-alphanumerics).
|
|
67
|
+
_SA_NAMES = (
|
|
68
|
+
"AutoReflect", "AutoFloat", "AutoHaste", "AutoRegen", "AutoLife", "HP10", "HP20", "MP10", "MP20", "Accuracy",
|
|
69
|
+
"Distract", "LongReach", "MPAttack", "BirdKiller", "BugKiller", "StoneKiller", "UndeadKiller", "DragonKiller",
|
|
70
|
+
"DevilKiller", "BeastKiller", "ManEater", "HighJump", "MasterThief", "StealGil", "Healer", "AddStatus",
|
|
71
|
+
"GambleDefence", "Chemist", "PowerThrow", "PowerUp", "ReflectNull", "Reflectx2", "MagElemNull", "Concentrate",
|
|
72
|
+
"HalfMP", "HighTide", "Counter", "Cover", "ProtectGirls", "Eye4Eye", "BodyTemp", "Alert", "Initiative",
|
|
73
|
+
"LevelUp", "AbilityUp", "Millionaire", "FleeGil", "GuardianMog", "Insomniac", "Antibody", "BrightEyes",
|
|
74
|
+
"Loudmouth", "RestoreHP", "Jelly", "ReturnMagic", "AbsorbMP", "AutoPotion", "Locomotion", "ClearHeaded",
|
|
75
|
+
"Boost", "OdinSword", "Mug", "Bandit", "Void",
|
|
76
|
+
)
|
|
77
|
+
_MAX_SA_ID = len(_SA_NAMES) - 1 # 63 (Void)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _norm_sa(s) -> str:
|
|
81
|
+
return re.sub(r"[^0-9a-z]", "", str(s).lower())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_SA_BY_NORM = {_norm_sa(n): i for i, n in enumerate(_SA_NAMES)}
|
|
85
|
+
# id 60's CSV display Comment is "Odin's Sword" (possessive) -> normalizes to "odinssword" (the apostrophe-s adds
|
|
86
|
+
# an extra 's'), differing from the enum "OdinSword" -> "odinsword". It is the ONLY one of 64 whose display name
|
|
87
|
+
# diverges this way, so alias it -> a user copying the name the `ability-gems` catalog prints resolves correctly.
|
|
88
|
+
_SA_BY_NORM.setdefault("odinssword", 60)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class CharacterDeltaError(ValueError):
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _csv_path(name, game):
|
|
96
|
+
from ..config import find_game_path
|
|
97
|
+
return find_game_path(game) / "StreamingAssets" / "Data" / "Characters" / name
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _to_int(value, key) -> int:
|
|
101
|
+
if isinstance(value, bool) or not isinstance(value, (int, str)):
|
|
102
|
+
raise CharacterDeltaError(f"{key} must be an integer (got {value!r})")
|
|
103
|
+
try:
|
|
104
|
+
return int(value)
|
|
105
|
+
except ValueError:
|
|
106
|
+
raise CharacterDeltaError(f"{key} must be an integer (got {value!r})")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _range(v, vmax, key) -> str:
|
|
110
|
+
if not 0 <= v <= vmax:
|
|
111
|
+
raise CharacterDeltaError(f"{key}={v} out of range (0-{vmax})")
|
|
112
|
+
return str(v)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---- read the base CSV (cp1252, byte-faithful), preserving the FULL header block verbatim -----------------
|
|
116
|
+
def _read_csv(path) -> tuple:
|
|
117
|
+
"""Parse a ``Data/Characters`` CSV -> ``(header_lines, legend_cols, data_rows)``:
|
|
118
|
+
* ``header_lines`` -- every ``#`` line (comments / ``#!`` options / legend / type row), VERBATIM + in order.
|
|
119
|
+
* ``legend_cols`` -- ``{lower column name: index}`` from the first ``#``-legend with an ``id`` field (BaseStats
|
|
120
|
+
has one; Leveling does NOT -> ``{}``, the caller keys by row order instead).
|
|
121
|
+
* ``data_rows`` -- the list of ``;``-split data rows, IN ORDER (verbatim cells, for re-emit)."""
|
|
122
|
+
data = path.read_bytes()
|
|
123
|
+
if data.startswith(b"\xef\xbb\xbf"):
|
|
124
|
+
data = data[3:]
|
|
125
|
+
header: list = []
|
|
126
|
+
legend: "dict | None" = None
|
|
127
|
+
rows: list = []
|
|
128
|
+
for raw in data.decode("cp1252", errors="replace").splitlines():
|
|
129
|
+
s = raw.strip()
|
|
130
|
+
if not s:
|
|
131
|
+
continue
|
|
132
|
+
if s.startswith("#"):
|
|
133
|
+
header.append(raw) # keep comments/#!/legend/types verbatim
|
|
134
|
+
if legend is None and not s.startswith("#!"):
|
|
135
|
+
fields = [f.strip().split("(")[0].strip().lower() for f in s.lstrip("#").strip().split(";")]
|
|
136
|
+
if "id" in fields and len(fields) > 1:
|
|
137
|
+
legend = {name: i for i, name in enumerate(fields)}
|
|
138
|
+
continue
|
|
139
|
+
rows.append(raw.split(";"))
|
|
140
|
+
return header, (legend or {}), rows
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---- read-live catalog (for the `characters` CLI -- the import->SEE->tune view) ---------------------------
|
|
144
|
+
def basestats_catalog(game=None):
|
|
145
|
+
"""``[(name, id, [(stat, value)...])...]`` per character from the live BaseStats.csv, or None if unreadable
|
|
146
|
+
(offline-safe). The provenance-clean READ side (names/ids/the live values shown, never committed)."""
|
|
147
|
+
try:
|
|
148
|
+
_header, cols, rows = _read_csv(_csv_path("BaseStats.csv", game))
|
|
149
|
+
except (FileNotFoundError, OSError, RuntimeError):
|
|
150
|
+
return None
|
|
151
|
+
if not cols or not rows:
|
|
152
|
+
return None
|
|
153
|
+
nidx = cols.get("comment", 0)
|
|
154
|
+
out = []
|
|
155
|
+
for cells in rows:
|
|
156
|
+
try:
|
|
157
|
+
cid = int(cells[cols["id"]].strip())
|
|
158
|
+
except (ValueError, IndexError, KeyError):
|
|
159
|
+
continue
|
|
160
|
+
name = cells[nidx].strip() if nidx < len(cells) else str(cid)
|
|
161
|
+
stats = [(s, cells[cols[s]].strip()) for s in ("dexterity", "strength", "magic", "will", "gems")
|
|
162
|
+
if cols.get(s) is not None and cols[s] < len(cells)]
|
|
163
|
+
out.append((name, cid, stats))
|
|
164
|
+
return sorted(out, key=lambda t: t[1])
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def ability_gems_catalog(game=None):
|
|
168
|
+
"""``[(name, id, gems)...]`` per SupportAbility from the live AbilityGems.csv, or None if unreadable. The
|
|
169
|
+
name is the CSV's display Comment (e.g. ``Auto-Reflect``); ``[[ability_gem]]`` accepts that, the enum name
|
|
170
|
+
(``AutoReflect``), or the id."""
|
|
171
|
+
try:
|
|
172
|
+
_h, cols, rows = _read_csv(_csv_path("Abilities/AbilityGems.csv", game))
|
|
173
|
+
except (FileNotFoundError, OSError, RuntimeError):
|
|
174
|
+
return None
|
|
175
|
+
if not cols or not rows:
|
|
176
|
+
return None
|
|
177
|
+
nidx, gem_col = cols.get("comment", 0), cols.get("gems", cols.get("gemscount", 2))
|
|
178
|
+
out = []
|
|
179
|
+
for cells in rows:
|
|
180
|
+
try:
|
|
181
|
+
aid = int(cells[cols["id"]].strip())
|
|
182
|
+
except (ValueError, IndexError, KeyError):
|
|
183
|
+
continue
|
|
184
|
+
name = cells[nidx].strip() if nidx < len(cells) else _SA_NAMES[aid] if aid <= _MAX_SA_ID else str(aid)
|
|
185
|
+
gems = cells[gem_col].strip() if gem_col < len(cells) else "?"
|
|
186
|
+
out.append((name, aid, gems))
|
|
187
|
+
return sorted(out, key=lambda t: t[1])
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---- [[character]] -> BaseStats.csv (per-id PARTIAL delta) ------------------------------------------------
|
|
191
|
+
def _resolve_char_id(token):
|
|
192
|
+
if token is None or isinstance(token, bool):
|
|
193
|
+
raise CharacterDeltaError("[[character]] needs a 'character' (a name or a 0-11 id)")
|
|
194
|
+
if isinstance(token, int) or (isinstance(token, str) and token.strip().lstrip("-").isdigit()):
|
|
195
|
+
cid = int(token)
|
|
196
|
+
if not 0 <= cid <= _MAX_CHAR_ID:
|
|
197
|
+
raise CharacterDeltaError(f"[[character]] id {cid} out of range (0-{_MAX_CHAR_ID})")
|
|
198
|
+
return cid
|
|
199
|
+
cid = CHARACTER_IDS.get(str(token).strip().lower())
|
|
200
|
+
if cid is None:
|
|
201
|
+
raise CharacterDeltaError(f"[[character]] unknown character {token!r} "
|
|
202
|
+
f"(known: {', '.join(n.title() for n in CHARACTER_IDS)})")
|
|
203
|
+
return cid
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def build_basestats_delta(entries, *, game=None) -> tuple:
|
|
207
|
+
"""Read the base BaseStats.csv + apply ``[[character]]`` entries -> (delta_text, warnings). A PARTIAL delta:
|
|
208
|
+
only the changed character rows are emitted; the engine supplies the rest per-id."""
|
|
209
|
+
try:
|
|
210
|
+
header, cols, rows = _read_csv(_csv_path("BaseStats.csv", game))
|
|
211
|
+
except (FileNotFoundError, OSError, RuntimeError) as ex:
|
|
212
|
+
raise CharacterDeltaError(f"[[character]] needs your FF9 install to read the base BaseStats.csv ({ex})")
|
|
213
|
+
if not cols or not rows:
|
|
214
|
+
raise CharacterDeltaError("could not parse the base BaseStats.csv (no id-legend / no rows)")
|
|
215
|
+
if not isinstance(entries, list):
|
|
216
|
+
raise CharacterDeltaError("[[character]] must be a list of tables")
|
|
217
|
+
idx = cols["id"]
|
|
218
|
+
by_id = {}
|
|
219
|
+
for cells in rows:
|
|
220
|
+
try:
|
|
221
|
+
by_id[int(cells[idx].strip())] = cells
|
|
222
|
+
except (ValueError, IndexError):
|
|
223
|
+
continue
|
|
224
|
+
warnings: list = []
|
|
225
|
+
changed: dict = {}
|
|
226
|
+
for n, e in enumerate(entries):
|
|
227
|
+
if not isinstance(e, dict):
|
|
228
|
+
raise CharacterDeltaError(f"[[character]] #{n} must be a table (got {type(e).__name__})")
|
|
229
|
+
cid = _resolve_char_id(e.get("character"))
|
|
230
|
+
if cid not in by_id:
|
|
231
|
+
raise CharacterDeltaError(f"[[character]] id {cid} is not in the base BaseStats.csv")
|
|
232
|
+
if cid in changed:
|
|
233
|
+
warnings.append(f"[[character]] #{n} and #{changed[cid]} both target id {cid} -- they MERGE "
|
|
234
|
+
f"(a field set by both: the later wins)")
|
|
235
|
+
changed.setdefault(cid, n)
|
|
236
|
+
cells = by_id[cid]
|
|
237
|
+
for k, v in e.items():
|
|
238
|
+
if k == "character":
|
|
239
|
+
continue
|
|
240
|
+
spec = CHARACTER_FIELDS.get(k)
|
|
241
|
+
if spec is None:
|
|
242
|
+
raise CharacterDeltaError(f"[[character]] {e.get('character')!r}: unknown field {k!r} "
|
|
243
|
+
f"(known: {', '.join(sorted(set(s[0] for s in CHARACTER_FIELDS.values())))})")
|
|
244
|
+
col, vmax = spec
|
|
245
|
+
ci = cols.get(col)
|
|
246
|
+
if ci is None or ci >= len(cells):
|
|
247
|
+
raise CharacterDeltaError(f"[[character]] id {cid}: base row has no column {col!r}")
|
|
248
|
+
cells[ci] = _range(_to_int(v, f"{e.get('character')} {k}"), vmax, f"[[character]] {e.get('character')!r} {k}")
|
|
249
|
+
note = "# ff9mapkit [[character]] -- a partial BaseStats.csv delta (merged per-CharacterId over the base)."
|
|
250
|
+
out = [note] + header + [";".join(by_id[c]) for c in sorted(changed)]
|
|
251
|
+
return "\n".join(out) + "\n", warnings
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ---- [[leveling]] -> Leveling.csv (WHOLE-FILE; read all 99, patch by level, re-emit all 99) ----------------
|
|
255
|
+
def build_leveling_file(entries, *, game=None) -> tuple:
|
|
256
|
+
"""Read the base Leveling.csv + apply ``[[leveling]]`` entries -> (full_99_row_text, warnings). WHOLE-FILE:
|
|
257
|
+
the engine reads only the highest-priority Leveling.csv and gates at >=99 rows, so we re-emit ALL 99."""
|
|
258
|
+
try:
|
|
259
|
+
header, _cols, rows = _read_csv(_csv_path("Leveling.csv", game))
|
|
260
|
+
except (FileNotFoundError, OSError, RuntimeError) as ex:
|
|
261
|
+
raise CharacterDeltaError(f"[[leveling]] needs your FF9 install to read the base Leveling.csv ({ex})")
|
|
262
|
+
if len(rows) < _LEVEL_COUNT:
|
|
263
|
+
raise CharacterDeltaError(f"the base Leveling.csv has {len(rows)} rows, need >= {_LEVEL_COUNT}")
|
|
264
|
+
if not isinstance(entries, list):
|
|
265
|
+
raise CharacterDeltaError("[[leveling]] must be a list of tables")
|
|
266
|
+
warnings: list = []
|
|
267
|
+
seen: dict = {}
|
|
268
|
+
for n, e in enumerate(entries):
|
|
269
|
+
if not isinstance(e, dict):
|
|
270
|
+
raise CharacterDeltaError(f"[[leveling]] #{n} must be a table (got {type(e).__name__})")
|
|
271
|
+
lvl = _to_int(e.get("level"), "[[leveling]] level")
|
|
272
|
+
if not 1 <= lvl <= _LEVEL_COUNT:
|
|
273
|
+
raise CharacterDeltaError(f"[[leveling]] level {lvl} out of range (1-{_LEVEL_COUNT})")
|
|
274
|
+
if lvl in seen:
|
|
275
|
+
warnings.append(f"[[leveling]] #{n} and #{seen[lvl]} both target level {lvl} -- the later wins")
|
|
276
|
+
seen.setdefault(lvl, n)
|
|
277
|
+
overrides = [k for k in e if k != "level"]
|
|
278
|
+
if not overrides:
|
|
279
|
+
raise CharacterDeltaError(f"[[leveling]] level {lvl} sets no fields (give exp / bonus_hp / bonus_mp)")
|
|
280
|
+
cells = rows[lvl - 1] # row order == level (line N = level N)
|
|
281
|
+
for k in overrides:
|
|
282
|
+
spec = LEVELING_FIELDS.get(k)
|
|
283
|
+
if spec is None:
|
|
284
|
+
raise CharacterDeltaError(f"[[leveling]] level {lvl}: unknown field {k!r} "
|
|
285
|
+
f"(known: exp, bonus_hp, bonus_mp)")
|
|
286
|
+
ci, vmax = spec
|
|
287
|
+
if ci >= len(cells):
|
|
288
|
+
raise CharacterDeltaError(f"[[leveling]] level {lvl}: base row has no column index {ci}")
|
|
289
|
+
cells[ci] = _range(_to_int(e[k], f"level {lvl} {k}"), vmax, f"[[leveling]] level {lvl} {k}")
|
|
290
|
+
warnings.append("[[leveling]] -> Leveling.csv is WHOLE-FILE (highest-priority-wins): it REPLACES the entire "
|
|
291
|
+
"growth curve, and a stacked higher-priority mod folder's Leveling.csv would SHADOW it")
|
|
292
|
+
note = "# ff9mapkit [[leveling]] -- the COMPLETE 99-row Leveling.csv (whole-file; patched levels + the base rest)."
|
|
293
|
+
out = [note] + header + [";".join(r) for r in rows[:_LEVEL_COUNT]] + [";".join(r) for r in rows[_LEVEL_COUNT:]]
|
|
294
|
+
return "\n".join(out) + "\n", warnings
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ---- [[ability_gem]] -> AbilityGems.csv (per-SupportAbility PARTIAL delta; the gem-COST balance lever) -----
|
|
298
|
+
def _resolve_sa_id(token):
|
|
299
|
+
if token is None or isinstance(token, bool):
|
|
300
|
+
raise CharacterDeltaError("[[ability_gem]] needs an 'ability' (a SupportAbility name or a 0-63 id)")
|
|
301
|
+
if isinstance(token, int) or (isinstance(token, str) and token.strip().lstrip("-").isdigit()):
|
|
302
|
+
aid = int(token)
|
|
303
|
+
if not 0 <= aid <= _MAX_SA_ID:
|
|
304
|
+
raise CharacterDeltaError(f"[[ability_gem]] id {aid} out of range (0-{_MAX_SA_ID})")
|
|
305
|
+
return aid
|
|
306
|
+
aid = _SA_BY_NORM.get(_norm_sa(token))
|
|
307
|
+
if aid is None:
|
|
308
|
+
raise CharacterDeltaError(f"[[ability_gem]] unknown ability {token!r} "
|
|
309
|
+
f"(a SupportAbility name like 'Auto-Haste'/'AutoHaste', or a 0-{_MAX_SA_ID} id)")
|
|
310
|
+
return aid
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def build_ability_gems_delta(entries, *, game=None) -> tuple:
|
|
314
|
+
"""Read the base AbilityGems.csv + apply ``[[ability_gem]]`` entries -> (delta_text, warnings). A PARTIAL
|
|
315
|
+
delta keyed per-SupportAbility (``EnumerateCsvFromLowToHigh``, ff9abil.cs:409); only the changed rows are
|
|
316
|
+
emitted, the base supplies the other 63. The ``#! IncludeBoosted`` option + the Boosted column are preserved
|
|
317
|
+
verbatim in the header/rows (load-bearing: the engine parses Boosted only when that option is present)."""
|
|
318
|
+
try:
|
|
319
|
+
header, cols, rows = _read_csv(_csv_path("Abilities/AbilityGems.csv", game))
|
|
320
|
+
except (FileNotFoundError, OSError, RuntimeError) as ex:
|
|
321
|
+
raise CharacterDeltaError(f"[[ability_gem]] needs your FF9 install to read the base AbilityGems.csv ({ex})")
|
|
322
|
+
if not cols or not rows:
|
|
323
|
+
raise CharacterDeltaError("could not parse the base AbilityGems.csv (no id-legend / no rows)")
|
|
324
|
+
if not isinstance(entries, list):
|
|
325
|
+
raise CharacterDeltaError("[[ability_gem]] must be a list of tables")
|
|
326
|
+
idx = cols["id"]
|
|
327
|
+
gem_col = cols.get("gems", cols.get("gemscount", 2))
|
|
328
|
+
by_id = {}
|
|
329
|
+
for cells in rows:
|
|
330
|
+
try:
|
|
331
|
+
by_id[int(cells[idx].strip())] = cells
|
|
332
|
+
except (ValueError, IndexError):
|
|
333
|
+
continue
|
|
334
|
+
warnings: list = []
|
|
335
|
+
changed: dict = {}
|
|
336
|
+
for n, e in enumerate(entries):
|
|
337
|
+
if not isinstance(e, dict):
|
|
338
|
+
raise CharacterDeltaError(f"[[ability_gem]] #{n} must be a table (got {type(e).__name__})")
|
|
339
|
+
aid = _resolve_sa_id(e.get("ability"))
|
|
340
|
+
if aid not in by_id:
|
|
341
|
+
raise CharacterDeltaError(f"[[ability_gem]] id {aid} is not in the base AbilityGems.csv")
|
|
342
|
+
if aid in changed:
|
|
343
|
+
warnings.append(f"[[ability_gem]] #{n} and #{changed[aid]} both target ability {aid} -- the later wins")
|
|
344
|
+
changed.setdefault(aid, n)
|
|
345
|
+
overrides = [k for k in e if k != "ability"]
|
|
346
|
+
if not overrides:
|
|
347
|
+
raise CharacterDeltaError(f"[[ability_gem]] {e.get('ability')!r} sets no fields (give gems = N)")
|
|
348
|
+
for k in overrides:
|
|
349
|
+
if k != "gems":
|
|
350
|
+
raise CharacterDeltaError(f"[[ability_gem]] {e.get('ability')!r}: unknown field {k!r} (known: gems)")
|
|
351
|
+
cells = by_id[aid]
|
|
352
|
+
if gem_col >= len(cells):
|
|
353
|
+
raise CharacterDeltaError(f"[[ability_gem]] id {aid}: base row has no gems column")
|
|
354
|
+
cells[gem_col] = _range(_to_int(e[k], f"{e.get('ability')} gems"), _I32,
|
|
355
|
+
f"[[ability_gem]] {e.get('ability')!r} gems")
|
|
356
|
+
note = "# ff9mapkit [[ability_gem]] -- a partial AbilityGems.csv delta (merged per-SupportAbility over the base)."
|
|
357
|
+
out = [note] + header + [";".join(by_id[a]) for a in sorted(changed)]
|
|
358
|
+
return "\n".join(out) + "\n", warnings
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ---- CharacterPresetId 0-19 (the per-preset Abilities/<Name>.csv learn files + the CommandSets/menu_type key) -
|
|
362
|
+
# DISTINCT from CHARACTER_IDS (0-11): guests split into two preset slots (Cinna1/2 etc.), and the canonical enum
|
|
363
|
+
# NAME is the filename. Committed open-source names (CharacterPresetId.cs); provenance-clean.
|
|
364
|
+
_PRESET_NAMES = ("Zidane", "Vivi", "Garnet", "Steiner", "Freya", "Quina", "Eiko", "Amarant",
|
|
365
|
+
"Cinna1", "Cinna2", "Marcus1", "Marcus2", "Blank1", "Blank2", "Beatrix1", "Beatrix2",
|
|
366
|
+
"StageZidane", "StageCinna", "StageMarcus", "StageBlank")
|
|
367
|
+
PRESET_IDS = {n.lower(): i for i, n in enumerate(_PRESET_NAMES)}
|
|
368
|
+
_MAX_PRESET_ID = len(_PRESET_NAMES) - 1
|
|
369
|
+
_AMBIGUOUS_PRESETS = {"cinna": ("Cinna1", "Cinna2"), "marcus": ("Marcus1", "Marcus2"),
|
|
370
|
+
"blank": ("Blank1", "Blank2"), "beatrix": ("Beatrix1", "Beatrix2")}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _resolve_preset(token, ctx="[[learn]]"):
|
|
374
|
+
"""A CharacterPresetId name or 0-19 id -> (id, canonical_name). Bare Cinna/Marcus/Blank/Beatrix = ambiguous."""
|
|
375
|
+
if token is None or isinstance(token, bool):
|
|
376
|
+
raise CharacterDeltaError(f"{ctx} needs a 'preset' (a CharacterPresetId name or a 0-{_MAX_PRESET_ID} id)")
|
|
377
|
+
if isinstance(token, int) or (isinstance(token, str) and token.strip().lstrip("-").isdigit()):
|
|
378
|
+
pid = int(token)
|
|
379
|
+
if not 0 <= pid <= _MAX_PRESET_ID:
|
|
380
|
+
raise CharacterDeltaError(f"{ctx} preset id {pid} out of range (0-{_MAX_PRESET_ID})")
|
|
381
|
+
return pid, _PRESET_NAMES[pid]
|
|
382
|
+
key = str(token).strip().lower()
|
|
383
|
+
if key in _AMBIGUOUS_PRESETS:
|
|
384
|
+
raise CharacterDeltaError(f"{ctx} preset {token!r} is ambiguous -- use {' or '.join(_AMBIGUOUS_PRESETS[key])}")
|
|
385
|
+
pid = PRESET_IDS.get(key)
|
|
386
|
+
if pid is None:
|
|
387
|
+
raise CharacterDeltaError(f"{ctx} unknown preset {token!r} (a CharacterPresetId name or 0-{_MAX_PRESET_ID} id)")
|
|
388
|
+
return pid, _PRESET_NAMES[pid]
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# ---- [[character_param]] -> CharacterParameters.csv (PARTIAL per-id; FIXED-INDEX cols -- legend names are stale) -
|
|
392
|
+
# All numerics are CsvParser.Byte (0-255; the legend type row "Int32;Boolean" is a LIE). Cols 6/7 are Strings.
|
|
393
|
+
CHARACTER_PARAM_FIELDS = {
|
|
394
|
+
"row": (1, "int", 0xFF), "win_pose": (2, "int", 0xFF), "category": (3, "int", 0xFF),
|
|
395
|
+
"menu_type": (4, "preset", 0xFF), "preset": (4, "preset", 0xFF),
|
|
396
|
+
"equipment_set": (5, "int", 0xFF), "equip_set": (5, "int", 0xFF),
|
|
397
|
+
"serial_formula": (6, "str", 0), "name_keyword": (7, "str", 0),
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _resolve_char_id_as(token, ctx):
|
|
402
|
+
try:
|
|
403
|
+
return _resolve_char_id(token)
|
|
404
|
+
except CharacterDeltaError as ex:
|
|
405
|
+
raise CharacterDeltaError(str(ex).replace("[[character]]", ctx, 1))
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _encode_param(value, kind, vmax, key) -> str:
|
|
409
|
+
if kind == "str":
|
|
410
|
+
s = str(value)
|
|
411
|
+
if ";" in s:
|
|
412
|
+
raise CharacterDeltaError(f"{key}: a String value can't contain ';' (the CSV delimiter)")
|
|
413
|
+
return s
|
|
414
|
+
if kind == "preset":
|
|
415
|
+
return str(_resolve_preset(value, key)[0]) # a CharacterPresetId: bounded to 0-19 (name OR id), NOT
|
|
416
|
+
return str(_range(_to_int(value, key), vmax, key)) # 0-255 -- a 20-254 menu_type crashes at battle entry
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def build_character_params_delta(entries, *, game=None) -> tuple:
|
|
420
|
+
"""Read CharacterParameters.csv + apply ``[[character_param]]`` -> (partial delta, warnings). PER-id (0-11):
|
|
421
|
+
only the changed rows are emitted; the base supplies the rest. Columns are written by FIXED INDEX."""
|
|
422
|
+
try:
|
|
423
|
+
header, cols, rows = _read_csv(_csv_path("CharacterParameters.csv", game))
|
|
424
|
+
except (FileNotFoundError, OSError, RuntimeError) as ex:
|
|
425
|
+
raise CharacterDeltaError(f"[[character_param]] needs your FF9 install to read CharacterParameters.csv ({ex})")
|
|
426
|
+
if not rows:
|
|
427
|
+
raise CharacterDeltaError("could not parse the base CharacterParameters.csv (no rows)")
|
|
428
|
+
if not isinstance(entries, list):
|
|
429
|
+
raise CharacterDeltaError("[[character_param]] must be a list of tables")
|
|
430
|
+
idx = cols.get("id", 0) # Id is col 0 (the legend may not name it)
|
|
431
|
+
by_id, warnings, changed = {}, [], {}
|
|
432
|
+
for cells in rows:
|
|
433
|
+
try:
|
|
434
|
+
by_id[int(cells[idx].strip())] = cells
|
|
435
|
+
except (ValueError, IndexError):
|
|
436
|
+
continue
|
|
437
|
+
for n, e in enumerate(entries):
|
|
438
|
+
if not isinstance(e, dict):
|
|
439
|
+
raise CharacterDeltaError(f"[[character_param]] #{n} must be a table (got {type(e).__name__})")
|
|
440
|
+
cid = _resolve_char_id_as(e.get("character"), "[[character_param]]")
|
|
441
|
+
if cid not in by_id:
|
|
442
|
+
raise CharacterDeltaError(f"[[character_param]] id {cid} is not in the base CharacterParameters.csv")
|
|
443
|
+
if cid in changed:
|
|
444
|
+
warnings.append(f"[[character_param]] #{n} and #{changed[cid]} both target id {cid} -- the later wins")
|
|
445
|
+
changed.setdefault(cid, n)
|
|
446
|
+
cells = by_id[cid]
|
|
447
|
+
for k, v in e.items():
|
|
448
|
+
if k == "character":
|
|
449
|
+
continue
|
|
450
|
+
spec = CHARACTER_PARAM_FIELDS.get(k)
|
|
451
|
+
if spec is None:
|
|
452
|
+
raise CharacterDeltaError(f"[[character_param]] {e.get('character')!r}: unknown field {k!r} "
|
|
453
|
+
f"(known: {', '.join(sorted(CHARACTER_PARAM_FIELDS))})")
|
|
454
|
+
ci, kind, vmax = spec
|
|
455
|
+
if ci >= len(cells):
|
|
456
|
+
raise CharacterDeltaError(f"[[character_param]] id {cid}: base row has no column index {ci}")
|
|
457
|
+
cells[ci] = _encode_param(v, kind, vmax, f"[[character_param]] {e.get('character')!r} {k}")
|
|
458
|
+
note = "# ff9mapkit [[character_param]] -- a partial CharacterParameters.csv delta (merged per-CharacterId)."
|
|
459
|
+
return "\n".join([note] + header + [";".join(by_id[c]) for c in sorted(changed)]) + "\n", warnings
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ---- [[command_set]] -> CommandSets.csv (PARTIAL per-preset; tab-padded -> strip + index slots by position) ----
|
|
463
|
+
COMMANDSET_SLOTS = {
|
|
464
|
+
"attack": 1, "defend": 2, "ability1": 3, "ability2": 4, "item": 5, "change": 6,
|
|
465
|
+
"attack_trance": 7, "defend_trance": 8, "ability1_trance": 9, "ability2_trance": 10,
|
|
466
|
+
"item_trance": 11, "change_trance": 12,
|
|
467
|
+
}
|
|
468
|
+
_MAX_COMMAND_ID = 47 # BattleCommandId slot value; >=48 = system/boundary
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def build_command_set_delta(entries, *, game=None) -> tuple:
|
|
472
|
+
"""Read CommandSets.csv + apply ``[[command_set]]`` -> (partial delta, warnings). PER-preset (0-19): re-point
|
|
473
|
+
a character's battle-menu command SLOTS to existing BattleCommandIds (e.g. give Vivi a different ability
|
|
474
|
+
command). The file is tab-padded + its legend collides Attack(Trance), so slots are written by FIXED INDEX
|
|
475
|
+
and every emitted cell is stripped clean."""
|
|
476
|
+
try:
|
|
477
|
+
header, cols, rows = _read_csv(_csv_path("CommandSets.csv", game))
|
|
478
|
+
except (FileNotFoundError, OSError, RuntimeError) as ex:
|
|
479
|
+
raise CharacterDeltaError(f"[[command_set]] needs your FF9 install to read CommandSets.csv ({ex})")
|
|
480
|
+
if not rows:
|
|
481
|
+
raise CharacterDeltaError("could not parse the base CommandSets.csv (no rows)")
|
|
482
|
+
if not isinstance(entries, list):
|
|
483
|
+
raise CharacterDeltaError("[[command_set]] must be a list of tables")
|
|
484
|
+
idx = cols.get("id", 0)
|
|
485
|
+
by_id, warnings, changed = {}, [], {}
|
|
486
|
+
for cells in rows:
|
|
487
|
+
try:
|
|
488
|
+
by_id[int(cells[idx].strip())] = [c.strip() for c in cells] # strip the tab-padding
|
|
489
|
+
except (ValueError, IndexError):
|
|
490
|
+
continue
|
|
491
|
+
for n, e in enumerate(entries):
|
|
492
|
+
if not isinstance(e, dict):
|
|
493
|
+
raise CharacterDeltaError(f"[[command_set]] #{n} must be a table (got {type(e).__name__})")
|
|
494
|
+
pid, pname = _resolve_preset(e.get("preset"), "[[command_set]]")
|
|
495
|
+
if pid not in by_id:
|
|
496
|
+
raise CharacterDeltaError(f"[[command_set]] preset {pname} (id {pid}) is not in the base CommandSets.csv")
|
|
497
|
+
if pid in changed:
|
|
498
|
+
warnings.append(f"[[command_set]] #{n} and #{changed[pid]} both target preset {pname} -- the later wins")
|
|
499
|
+
changed.setdefault(pid, n)
|
|
500
|
+
cells = by_id[pid]
|
|
501
|
+
for k, v in e.items():
|
|
502
|
+
if k == "preset":
|
|
503
|
+
continue
|
|
504
|
+
slot = COMMANDSET_SLOTS.get(k)
|
|
505
|
+
if slot is None:
|
|
506
|
+
raise CharacterDeltaError(f"[[command_set]] {pname}: unknown slot {k!r} "
|
|
507
|
+
f"(known: {', '.join(sorted(COMMANDSET_SLOTS))})")
|
|
508
|
+
if slot >= len(cells):
|
|
509
|
+
raise CharacterDeltaError(f"[[command_set]] {pname}: base row has no slot index {slot}")
|
|
510
|
+
cid = _to_int(v, f"[[command_set]] {pname} {k}")
|
|
511
|
+
if not 0 <= cid <= _MAX_COMMAND_ID:
|
|
512
|
+
raise CharacterDeltaError(f"[[command_set]] {pname} {k}={cid} out of range (0-{_MAX_COMMAND_ID})")
|
|
513
|
+
cells[slot] = str(cid)
|
|
514
|
+
note = "# ff9mapkit [[command_set]] -- a partial CommandSets.csv delta (merged per-preset over the base)."
|
|
515
|
+
return "\n".join([note] + header + [";".join(by_id[c]) for c in sorted(changed)]) + "\n", warnings
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# ---- [[learn]] -> Abilities/<Preset>.csv (WHOLE-FILE per preset; the ability-progression curve) -------------
|
|
519
|
+
def _resolve_learn_token(token, *, game=None) -> str:
|
|
520
|
+
"""An ability -> the canonical Abilities-CSV cell. Forms: ``0`` / ``AA:n`` / ``SA:n`` (passthrough + range);
|
|
521
|
+
an SA NAME -> ``SA:id`` (committed table); an active-ability NAME -> ``AA:id`` (live Actions.csv, needs install)."""
|
|
522
|
+
if token is None or isinstance(token, bool):
|
|
523
|
+
raise CharacterDeltaError("[[learn.ability]] needs an 'ability' (0, AA:n, SA:n, or a name)")
|
|
524
|
+
s = str(token).strip()
|
|
525
|
+
if s == "0":
|
|
526
|
+
return "0"
|
|
527
|
+
up = s.upper()
|
|
528
|
+
if up.startswith(("AA:", "SA:")):
|
|
529
|
+
n = _to_int(up[3:], f"[[learn]] {up[:2]}")
|
|
530
|
+
vmax = 191 if up.startswith("AA:") else _MAX_SA_ID
|
|
531
|
+
if not 0 <= n <= vmax:
|
|
532
|
+
raise CharacterDeltaError(f"[[learn]] {up[:3]}{n} out of range (0-{vmax})")
|
|
533
|
+
return f"{up[:3]}{n}"
|
|
534
|
+
said = _SA_BY_NORM.get(_norm_sa(s)) # an SA name (committed) -> SA:id
|
|
535
|
+
if said is not None:
|
|
536
|
+
return f"SA:{said}"
|
|
537
|
+
from . import actiondelta as _ad # else an active-ability name -> AA:id (live Actions.csv)
|
|
538
|
+
try:
|
|
539
|
+
_o, _l, cols, rows = _ad._read_raw(_ad._csv_path("Actions.csv", game))
|
|
540
|
+
aid = _ad._resolve_id(s, rows, _ad._name_index(rows, cols), kind="learn.ability", max_id=191)
|
|
541
|
+
except _ad.ActionDeltaError as ex:
|
|
542
|
+
raise CharacterDeltaError(f"[[learn.ability]] {ex}")
|
|
543
|
+
except (FileNotFoundError, OSError, RuntimeError) as ex:
|
|
544
|
+
raise CharacterDeltaError(f"[[learn.ability]] {s!r}: an active-ability name needs the install to resolve "
|
|
545
|
+
f"via Actions.csv ({ex})")
|
|
546
|
+
return f"AA:{aid}"
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _group_learns(learns):
|
|
550
|
+
"""``[[learn]]`` blocks -> ``{preset_name: {abilities:[...], removes:[...]}}`` (blocks for the same preset MERGE)."""
|
|
551
|
+
grouped: dict = {}
|
|
552
|
+
for n, e in enumerate(learns if isinstance(learns, list) else [learns]):
|
|
553
|
+
if not isinstance(e, dict):
|
|
554
|
+
raise CharacterDeltaError(f"[[learn]] #{n} must be a table (got {type(e).__name__})")
|
|
555
|
+
_pid, pname = _resolve_preset(e.get("preset"), "[[learn]]")
|
|
556
|
+
g = grouped.setdefault(pname, {"abilities": [], "removes": []})
|
|
557
|
+
abil = e.get("ability", [])
|
|
558
|
+
g["abilities"] += abil if isinstance(abil, list) else [abil]
|
|
559
|
+
rem = e.get("remove", [])
|
|
560
|
+
g["removes"] += rem if isinstance(rem, list) else [rem]
|
|
561
|
+
return grouped
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def build_learn_file(preset_name, abilities, removes, *, game=None) -> tuple:
|
|
565
|
+
"""Read Abilities/<preset_name>.csv + apply the learn edits -> (WHOLE-FILE text, warnings). Override an
|
|
566
|
+
existing token's AP, append a new token, drop a removed token, re-emit ALL rows (the whole file replaces the
|
|
567
|
+
base, highest-priority-wins). Rows are ``<token>;<ap>;# <name>``."""
|
|
568
|
+
try:
|
|
569
|
+
header, _cols, rows = _read_csv(_csv_path(f"Abilities/{preset_name}.csv", game))
|
|
570
|
+
except (FileNotFoundError, OSError, RuntimeError) as ex:
|
|
571
|
+
raise CharacterDeltaError(f"[[learn]] preset {preset_name}: can't read Abilities/{preset_name}.csv -- "
|
|
572
|
+
f"presets 0-15 must exist; Stage* (16-19) have no base file ({ex})")
|
|
573
|
+
by_token: dict = {}
|
|
574
|
+
order: list = []
|
|
575
|
+
for cells in rows:
|
|
576
|
+
tok = cells[0].strip() if cells else ""
|
|
577
|
+
if tok and tok not in by_token:
|
|
578
|
+
by_token[tok] = [c.strip() for c in cells]
|
|
579
|
+
order.append(tok)
|
|
580
|
+
warnings: list = []
|
|
581
|
+
for r in removes or []: # drop removed tokens
|
|
582
|
+
tok = _resolve_learn_token(r, game=game)
|
|
583
|
+
if tok in by_token:
|
|
584
|
+
del by_token[tok]
|
|
585
|
+
order.remove(tok)
|
|
586
|
+
else:
|
|
587
|
+
warnings.append(f"[[learn]] {preset_name}: remove {r!r} ({tok}) is not in the base list -- ignored")
|
|
588
|
+
for ab in abilities or []: # override AP / append new
|
|
589
|
+
if not isinstance(ab, dict):
|
|
590
|
+
raise CharacterDeltaError(f"[[learn]] {preset_name}: each [[learn.ability]] must be a table")
|
|
591
|
+
tok = _resolve_learn_token(ab.get("ability"), game=game)
|
|
592
|
+
ap = _to_int(ab.get("ap", 0), f"[[learn]] {preset_name} {tok} ap")
|
|
593
|
+
if not 0 <= ap <= _I32: # CharacterAbility.Ap is Int32 (CsvParser.Int32), NOT UInt32
|
|
594
|
+
raise CharacterDeltaError(f"[[learn]] {preset_name} {tok}: ap {ap} out of range (0-{_I32})")
|
|
595
|
+
if tok in by_token:
|
|
596
|
+
cells = by_token[tok]
|
|
597
|
+
while len(cells) < 2:
|
|
598
|
+
cells.append("0")
|
|
599
|
+
cells[1] = str(ap)
|
|
600
|
+
else:
|
|
601
|
+
name = str(ab.get("name", "")).strip()
|
|
602
|
+
by_token[tok] = [tok, str(ap), f"# {name}" if name else f"# {tok}"]
|
|
603
|
+
order.append(tok)
|
|
604
|
+
note = f"# ff9mapkit [[learn]] -- the COMPLETE {preset_name} learn list (whole-file; highest-priority-wins)."
|
|
605
|
+
warnings.append(f"[[learn]] -> Abilities/{preset_name}.csv is WHOLE-FILE: it REPLACES the entire learn list, "
|
|
606
|
+
f"and a stacked higher-priority mod folder's {preset_name}.csv would SHADOW it")
|
|
607
|
+
return "\n".join([note] + header + [";".join(by_token[t]) for t in order]) + "\n", warnings
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def validate_learn(entry) -> list:
|
|
611
|
+
"""Offline structural problems for ``[[learn]]`` (empty => OK). Token FORMS (0/AA:/SA:/SA-name) check offline;
|
|
612
|
+
an active-ability NAME defers to build (it needs the install's Actions.csv)."""
|
|
613
|
+
if not isinstance(entry, dict):
|
|
614
|
+
return ["[[learn]] must be a table (preset = \"...\", [[learn.ability]] blocks)"]
|
|
615
|
+
problems: list = []
|
|
616
|
+
try:
|
|
617
|
+
_resolve_preset(entry.get("preset"), "[[learn]]")
|
|
618
|
+
except CharacterDeltaError as ex:
|
|
619
|
+
problems.append(str(ex))
|
|
620
|
+
abil = entry.get("ability", [])
|
|
621
|
+
abil = abil if isinstance(abil, list) else [abil]
|
|
622
|
+
if not abil and not entry.get("remove"):
|
|
623
|
+
problems.append("[[learn]] sets nothing (add a [[learn.ability]] block or remove = [...])")
|
|
624
|
+
for ab in abil:
|
|
625
|
+
if not isinstance(ab, dict) or ab.get("ability") is None:
|
|
626
|
+
problems.append("[[learn.ability]] needs an 'ability' (0, AA:n, SA:n, or a name)")
|
|
627
|
+
continue
|
|
628
|
+
s = str(ab.get("ability")).strip().upper()
|
|
629
|
+
if s == "0" or s.startswith(("AA:", "SA:")) or _SA_BY_NORM.get(_norm_sa(s)) is not None:
|
|
630
|
+
try:
|
|
631
|
+
_resolve_learn_token(ab.get("ability")) # offline-resolvable form -> check the range now
|
|
632
|
+
except CharacterDeltaError as ex:
|
|
633
|
+
problems.append(str(ex))
|
|
634
|
+
# else: an active-ability name -> resolution (+ presence) deferred to build (needs the install)
|
|
635
|
+
return problems
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
# ---- mod-write stage -------------------------------------------------------------------------------------
|
|
639
|
+
def write_character_data(layout, *, characters=None, levelings=None, ability_gems=None, character_params=None,
|
|
640
|
+
command_sets=None, learns=None, game=None) -> list:
|
|
641
|
+
"""Emit BaseStats / Leveling / AbilityGems / CharacterParameters / CommandSets (per-id deltas) + the per-preset
|
|
642
|
+
Abilities/<Name>.csv learn lists into ``layout``. cp1252 + LF."""
|
|
643
|
+
warnings: list = []
|
|
644
|
+
for entries, path, builder in ((characters, layout.base_stats_csv, build_basestats_delta),
|
|
645
|
+
(levelings, layout.leveling_csv, build_leveling_file),
|
|
646
|
+
(ability_gems, layout.ability_gems_csv, build_ability_gems_delta),
|
|
647
|
+
(character_params, layout.character_parameters_csv, build_character_params_delta),
|
|
648
|
+
(command_sets, layout.command_sets_csv, build_command_set_delta)):
|
|
649
|
+
if entries:
|
|
650
|
+
text, w = builder(entries, game=game)
|
|
651
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
652
|
+
path.write_text(text, encoding="cp1252", errors="replace", newline="\n")
|
|
653
|
+
warnings += w
|
|
654
|
+
if learns: # the learn lists are a FILE SET (one whole file per preset)
|
|
655
|
+
for pname, g in _group_learns(learns).items():
|
|
656
|
+
text, w = build_learn_file(pname, g["abilities"], g["removes"], game=game)
|
|
657
|
+
p = layout.abilities_csv(pname)
|
|
658
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
659
|
+
p.write_text(text, encoding="cp1252", errors="replace", newline="\n")
|
|
660
|
+
warnings += w
|
|
661
|
+
return warnings
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def validate_character_param(entry) -> list:
|
|
665
|
+
"""Offline structural problems for ``[[character_param]]`` (empty => OK; field resolution at build)."""
|
|
666
|
+
if not isinstance(entry, dict):
|
|
667
|
+
return ["[[character_param]] must be a table (character = \"...\", a field = value)"]
|
|
668
|
+
problems = []
|
|
669
|
+
if entry.get("character") is None:
|
|
670
|
+
problems.append("[[character_param]] needs a 'character' (a name or a 0-11 id)")
|
|
671
|
+
overrides = [k for k in entry if k != "character"]
|
|
672
|
+
if not overrides:
|
|
673
|
+
problems.append("[[character_param]] sets no fields (e.g. row = 1, menu_type = \"Steiner\")")
|
|
674
|
+
for k in overrides:
|
|
675
|
+
if k not in CHARACTER_PARAM_FIELDS:
|
|
676
|
+
problems.append(f"[[character_param]]: unknown field {k!r} (known: {', '.join(sorted(CHARACTER_PARAM_FIELDS))})")
|
|
677
|
+
continue
|
|
678
|
+
ci, kind, vmax = CHARACTER_PARAM_FIELDS[k]
|
|
679
|
+
try:
|
|
680
|
+
_encode_param(entry[k], kind, vmax, f"[[character_param]] {k}")
|
|
681
|
+
except CharacterDeltaError as ex:
|
|
682
|
+
problems.append(str(ex))
|
|
683
|
+
return problems
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def validate_command_set(entry) -> list:
|
|
687
|
+
"""Offline structural problems for ``[[command_set]]`` (empty => OK)."""
|
|
688
|
+
if not isinstance(entry, dict):
|
|
689
|
+
return ["[[command_set]] must be a table (preset = \"...\", a slot = command id)"]
|
|
690
|
+
problems = []
|
|
691
|
+
try:
|
|
692
|
+
_resolve_preset(entry.get("preset"), "[[command_set]]")
|
|
693
|
+
except CharacterDeltaError as ex:
|
|
694
|
+
problems.append(str(ex))
|
|
695
|
+
overrides = [k for k in entry if k != "preset"]
|
|
696
|
+
if not overrides:
|
|
697
|
+
problems.append("[[command_set]] sets no slots (e.g. ability1 = 8)")
|
|
698
|
+
for k in overrides:
|
|
699
|
+
if k not in COMMANDSET_SLOTS:
|
|
700
|
+
problems.append(f"[[command_set]]: unknown slot {k!r} (known: {', '.join(sorted(COMMANDSET_SLOTS))})")
|
|
701
|
+
continue
|
|
702
|
+
try:
|
|
703
|
+
cid = _to_int(entry[k], f"[[command_set]] {k}")
|
|
704
|
+
if not 0 <= cid <= _MAX_COMMAND_ID:
|
|
705
|
+
problems.append(f"[[command_set]] {k}={cid} out of range (0-{_MAX_COMMAND_ID})")
|
|
706
|
+
except CharacterDeltaError as ex:
|
|
707
|
+
problems.append(str(ex))
|
|
708
|
+
return problems
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
# ---- offline (no-install) structural + range validation --------------------------------------------------
|
|
712
|
+
def validate_character(entry) -> list:
|
|
713
|
+
problems: list = []
|
|
714
|
+
if not isinstance(entry, dict):
|
|
715
|
+
return ["[[character]] must be a table (character = \"...\", a stat = value)"]
|
|
716
|
+
ch = entry.get("character")
|
|
717
|
+
if ch is None or isinstance(ch, bool):
|
|
718
|
+
problems.append("[[character]] needs a 'character' (a name or a 0-11 id)")
|
|
719
|
+
elif not isinstance(ch, (int, str)):
|
|
720
|
+
problems.append(f"[[character]] character must be a name or a 0-11 id (got {type(ch).__name__})")
|
|
721
|
+
elif isinstance(ch, str) and not ch.strip().lstrip("-").isdigit() and ch.strip().lower() not in CHARACTER_IDS:
|
|
722
|
+
problems.append(f"[[character]] unknown character {ch!r}")
|
|
723
|
+
overrides = [k for k in entry if k != "character"]
|
|
724
|
+
if not overrides:
|
|
725
|
+
problems.append(f"[[character]] {entry.get('character')!r} sets no stats (give e.g. strength = 30)")
|
|
726
|
+
for k in overrides:
|
|
727
|
+
spec = CHARACTER_FIELDS.get(k)
|
|
728
|
+
if spec is None:
|
|
729
|
+
problems.append(f"[[character]] {entry.get('character')!r}: unknown field {k!r}")
|
|
730
|
+
continue
|
|
731
|
+
try:
|
|
732
|
+
_range(_to_int(entry[k], k), spec[1], f"[[character]] {entry.get('character')!r} {k}")
|
|
733
|
+
except CharacterDeltaError as ex:
|
|
734
|
+
problems.append(str(ex))
|
|
735
|
+
return problems
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def validate_leveling(entry) -> list:
|
|
739
|
+
problems: list = []
|
|
740
|
+
if not isinstance(entry, dict):
|
|
741
|
+
return ["[[leveling]] must be a table (level = N, a field = value)"]
|
|
742
|
+
lvl = entry.get("level")
|
|
743
|
+
if lvl is None or isinstance(lvl, bool) or not isinstance(lvl, (int, str)):
|
|
744
|
+
problems.append("[[leveling]] needs a 'level' (1-99)")
|
|
745
|
+
else:
|
|
746
|
+
try:
|
|
747
|
+
lv = int(lvl)
|
|
748
|
+
if not 1 <= lv <= _LEVEL_COUNT:
|
|
749
|
+
problems.append(f"[[leveling]] level {lv} out of range (1-{_LEVEL_COUNT})")
|
|
750
|
+
except (ValueError, TypeError):
|
|
751
|
+
problems.append(f"[[leveling]] level must be an integer 1-{_LEVEL_COUNT} (got {lvl!r})")
|
|
752
|
+
overrides = [k for k in entry if k != "level"]
|
|
753
|
+
if not overrides:
|
|
754
|
+
problems.append("[[leveling]] sets no fields (give exp / bonus_hp / bonus_mp)")
|
|
755
|
+
for k in overrides:
|
|
756
|
+
spec = LEVELING_FIELDS.get(k)
|
|
757
|
+
if spec is None:
|
|
758
|
+
problems.append(f"[[leveling]] level {entry.get('level')}: unknown field {k!r} (known: exp, bonus_hp, bonus_mp)")
|
|
759
|
+
continue
|
|
760
|
+
try:
|
|
761
|
+
_range(_to_int(entry[k], k), spec[1], f"[[leveling]] {k}")
|
|
762
|
+
except CharacterDeltaError as ex:
|
|
763
|
+
problems.append(str(ex))
|
|
764
|
+
return problems
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def validate_ability_gem(entry) -> list:
|
|
768
|
+
problems: list = []
|
|
769
|
+
if not isinstance(entry, dict):
|
|
770
|
+
return ["[[ability_gem]] must be a table (ability = \"...\", gems = N)"]
|
|
771
|
+
ab = entry.get("ability")
|
|
772
|
+
if ab is None or isinstance(ab, bool):
|
|
773
|
+
problems.append("[[ability_gem]] needs an 'ability' (a SupportAbility name or a 0-63 id)")
|
|
774
|
+
elif not isinstance(ab, (int, str)):
|
|
775
|
+
problems.append(f"[[ability_gem]] ability must be a name or a 0-{_MAX_SA_ID} id (got {type(ab).__name__})")
|
|
776
|
+
elif isinstance(ab, str) and not ab.strip().lstrip("-").isdigit() and _norm_sa(ab) not in _SA_BY_NORM:
|
|
777
|
+
problems.append(f"[[ability_gem]] unknown ability {ab!r}")
|
|
778
|
+
overrides = [k for k in entry if k != "ability"]
|
|
779
|
+
if not overrides:
|
|
780
|
+
problems.append(f"[[ability_gem]] {entry.get('ability')!r} sets no fields (give gems = N)")
|
|
781
|
+
for k in overrides:
|
|
782
|
+
if k != "gems":
|
|
783
|
+
problems.append(f"[[ability_gem]] {entry.get('ability')!r}: unknown field {k!r} (known: gems)")
|
|
784
|
+
continue
|
|
785
|
+
try:
|
|
786
|
+
_range(_to_int(entry[k], k), _I32, f"[[ability_gem]] {entry.get('ability')!r} gems")
|
|
787
|
+
except CharacterDeltaError as ex:
|
|
788
|
+
problems.append(str(ex))
|
|
789
|
+
return problems
|