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,644 @@
|
|
|
1
|
+
"""``[[weapon]]`` / ``[[armor]]`` / ``[[item]]`` -- tune EXISTING item stats via partial CSV deltas (no DLL).
|
|
2
|
+
|
|
3
|
+
The engine MERGES ``Data/Items/{Weapons,Armors,Items}.csv`` by id low->high, **whole-row-wins**
|
|
4
|
+
(``AssetManager.EnumerateCsvFromLowToHigh``), so a mod ships a PARTIAL delta = the base file's header block
|
|
5
|
+
(verbatim, so ``CsvReader`` parses the same columns + ``#!`` options) + only the patched rows, each COMPLETE.
|
|
6
|
+
|
|
7
|
+
The base rows are read LIVE from the user's install (the same provenance-clean pattern as :mod:`ff9mapkit.itemstats`)
|
|
8
|
+
-- so the kit commits NO game data; the delta is GENERATED at build time into the mod folder. Item-data patches
|
|
9
|
+
therefore need a reachable install (they degrade with a clear error otherwise).
|
|
10
|
+
|
|
11
|
+
* ``[[weapon]]`` patches the item's ItemAttack (``Weapons.csv``: ``Power`` / ``Elements`` + ``category`` /
|
|
12
|
+
``status_index`` / ``rate`` -- the weapon's class, the ``StatusSets.csv`` row it inflicts on hit, and that
|
|
13
|
+
status's percent chance), located via the item's ``WeaponId`` in ``Items.csv``.
|
|
14
|
+
* ``[[armor]]`` patches its ItemDefence (``Armors.csv``: ``P.Def`` / ``P.Eva`` / ``M.Def`` / ``M.Eva``) via ``ArmorId``.
|
|
15
|
+
* ``[[item]]`` patches its ItemInfo (``Items.csv``: ``Price`` / ``SellingPrice`` + ``equippable_by`` -- the list of
|
|
16
|
+
characters who can equip it, which REWRITES the item's 12 equip-by-character bits) by item id directly.
|
|
17
|
+
|
|
18
|
+
[[weapon]]
|
|
19
|
+
name = "Mage Masher"
|
|
20
|
+
power = 30
|
|
21
|
+
elements = ["Fire"]
|
|
22
|
+
category = ["short-range", "throw"] # weapon class (here: throwable)
|
|
23
|
+
status_index = 9 # a StatusSets.csv row -> the status it can inflict on hit
|
|
24
|
+
rate = 30 # 30% chance to inflict that status
|
|
25
|
+
|
|
26
|
+
[[armor]]
|
|
27
|
+
name = "Bronze Armor"
|
|
28
|
+
p_def = 20
|
|
29
|
+
|
|
30
|
+
[[item]]
|
|
31
|
+
name = "Excalibur"
|
|
32
|
+
price = 5000
|
|
33
|
+
equippable_by = ["Vivi", "Garnet"] # exactly these characters can equip it (replaces the current set)
|
|
34
|
+
|
|
35
|
+
* ``[[equip_bonus]]`` patches the item's ItemStats (``Stats.csv``: the equip stat bonuses ``speed`` / ``strength`` /
|
|
36
|
+
``magic`` / ``spirit`` -- the input the engine's level-up accumulator reads, ``ff9play.cs:302-305`` -- plus the
|
|
37
|
+
elemental-affinity bitmasks ``attack_element`` / ``guard_element`` / ``absorb_element`` / ``half_element`` /
|
|
38
|
+
``weak_element``), located via the item's ``BonusId`` in ``Items.csv``. ★ ``BonusId`` is SHARED: ~100 items point
|
|
39
|
+
at the all-zero ``Empty`` row 0, so a block on such an item can't edit row 0 in place (it would buff every other
|
|
40
|
+
no-bonus item) -- it MINTS a fresh ``Stats.csv`` row and repoints the item's ``BonusId`` in an ``Items.csv`` delta,
|
|
41
|
+
isolating the edit. An item whose ``BonusId`` is dedicated (used by it alone) is edited in place.
|
|
42
|
+
|
|
43
|
+
[[equip_bonus]]
|
|
44
|
+
name = "Bone Wrist"
|
|
45
|
+
strength = 3
|
|
46
|
+
weak_element = ["Fire"]
|
|
47
|
+
"""
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
from .. import abilities as _abilities
|
|
51
|
+
from .. import items as _items
|
|
52
|
+
from .. import itemstats as _itemstats
|
|
53
|
+
|
|
54
|
+
POWER_CAP = 255 # weapon Power / armor defence are small byte-range values in practice
|
|
55
|
+
PRICE_CAP = 9_999_999 # gil cap; a price above it is pointless (you can't hold that much gil)
|
|
56
|
+
RATE_CAP = 100 # a weapon's status-infliction Rate is a 0-100 percent chance (the engine clamps all
|
|
57
|
+
# accuracy to 100 -- BattleCalculator.cs; physical hit is fixed 100, so Rate ONLY gates
|
|
58
|
+
# the on-hit status, applied from add_status[StatusIndex] -- SBattleCalculator.cs:188).
|
|
59
|
+
STATUS_INDEX_CAP = 65535 # StatusIndex references a StatusSets.csv row; the REAL membership check is install-gated
|
|
60
|
+
# in build.validate (an over-range id is a KeyNotFound crash, like the Phase-4 trap).
|
|
61
|
+
EFFECT_POWER_CAP = 9999 # a use-effect's Power = the heal/damage magnitude (the in-game HP cap)
|
|
62
|
+
_ELEM_BY_NAME = {name.lower(): bit for bit, name in _itemstats.ELEMENTS} # "fire" -> 1, ...
|
|
63
|
+
_STATUS_BY_NAME = {name.lower(): bit for bit, name in _itemstats.STATUSES} # "poison" -> 1<<16, ...
|
|
64
|
+
# [[item_effect]] -> the ItemEffect (ItemEffects.csv) row of a USABLE item (located via its EffectId). The gameplay
|
|
65
|
+
# knobs the kit tunes (ScriptId/AnimationId/Targets stay -- they ARE the effect's behavior/VFX): power = heal/damage
|
|
66
|
+
# magnitude; rate = status chance; element; status (the BattleStatus mask inflicted OR cured -- the DIRECTION is the
|
|
67
|
+
# effect's ScriptId, which we don't touch); for_dead = usable on a KO'd target (Phoenix-Down style).
|
|
68
|
+
_EFFECT_INT_COLS = {"power": "Power", "rate": "Rate"}
|
|
69
|
+
EFFECT_KEYS = ("power", "rate", "element", "status", "for_dead")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _norm(s) -> str:
|
|
73
|
+
"""Loose name key: lowercased, alphanumerics only -- so "short-range" / "ShortRange" / "short range" match."""
|
|
74
|
+
return "".join(ch for ch in str(s).strip().lower() if ch.isalnum())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# WeaponCategory bits (Memoria.Data.WeaponCategory) by friendly name (+ the engine enum name "OfsDim" for bit 8).
|
|
78
|
+
_CATEGORY_BY_NAME = {_norm(name): bit for bit, name in _itemstats.WEAPON_CATEGORY}
|
|
79
|
+
_CATEGORY_BY_NAME["ofsdim"] = 8
|
|
80
|
+
_CHAR_BY_NAME = {c.lower(): c for c in _itemstats.CHARS} # equip-by-character names -> canonical CHARS
|
|
81
|
+
|
|
82
|
+
# Which Items.csv FK column + which target CSV a block patches, and the editable {toml key: CSV column} maps.
|
|
83
|
+
_WEAPON_COLS = {"power": "Power", "elements": "Elements",
|
|
84
|
+
"category": "Category", "status_index": "StatusIndex", "rate": "Rate"}
|
|
85
|
+
_ARMOR_COLS = {"p_def": "P.Def", "p_eva": "P.Eva", "m_def": "M.Def", "m_eva": "M.Eva"}
|
|
86
|
+
_ITEM_COLS = {"price": "Price", "sell": "SellingPrice"} # equippable_by is handled separately (12-column rewrite)
|
|
87
|
+
|
|
88
|
+
STAT_CAP = 255 # equip stat bonuses (dex/str/mgc/wpr) are Byte columns in Stats.csv (ItemStats.cs)
|
|
89
|
+
# [[equip_bonus]] -> the ItemStats (Stats.csv) row of an EQUIPPABLE item: the 4 growth-stat bonuses (the input
|
|
90
|
+
# the 32-level level-up accumulator reads, ff9play.cs:302-305) + the 5 elemental-affinity bitmask columns. Keys
|
|
91
|
+
# map to the Stats.csv legend (★ Dexterity = FF9 "Speed", Will = FF9 "Spirit").
|
|
92
|
+
_EQUIP_BONUS_STATS = {"speed": "Dexterity", "strength": "Strength", "magic": "Magic", "spirit": "Will"}
|
|
93
|
+
# Keys 1:1 with the Stats.csv column names (so the emitted delta matches the file the user can inspect). Engine
|
|
94
|
+
# meaning (ItemStats.cs raw[6..10] -> p_up_attr/def_attr): attack_element = STRENGTHENS attacks/magic of that
|
|
95
|
+
# element (a damage boost while worn), NOT "adds the element on hit"; guard_element = NULLIFY (immune); the other
|
|
96
|
+
# three = absorb (heal from) / take half / take extra damage. All are Byte element bitmasks.
|
|
97
|
+
_EQUIP_BONUS_ELEMS = {"attack_element": "AttackElement", "guard_element": "GuardElement",
|
|
98
|
+
"absorb_element": "AbsorbElement", "half_element": "HalfElement",
|
|
99
|
+
"weak_element": "WeakElement"}
|
|
100
|
+
EQUIP_BONUS_KEYS = (*_EQUIP_BONUS_STATS, *_EQUIP_BONUS_ELEMS)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def encode_elements(names) -> int:
|
|
104
|
+
"""A list of element names (or a 0-255 bitmask int) -> the element bitmask. Raises ValueError on an unknown
|
|
105
|
+
name, an out-of-range / wrong-typed value. ★ Range-checked: the Elements column is a Byte (element bits sum to
|
|
106
|
+
255), so a bare int MUST be 0..255 -- else the engine's ``Byte.Parse`` OverflowExceptions and HARD-QUITS at
|
|
107
|
+
weapon load. Every bad input raises ValueError so the single ``except ValueError`` in build/validate suffices."""
|
|
108
|
+
if isinstance(names, bool): # bool is an int subclass -- reject before the int path
|
|
109
|
+
raise ValueError("elements must be a list of element names or a 0-255 bitmask, not a bool")
|
|
110
|
+
if isinstance(names, int):
|
|
111
|
+
if not 0 <= names <= 255:
|
|
112
|
+
raise ValueError(f"element bitmask {names} out of range 0..255")
|
|
113
|
+
return names
|
|
114
|
+
if names is None:
|
|
115
|
+
return 0
|
|
116
|
+
if not isinstance(names, (list, tuple)):
|
|
117
|
+
raise ValueError(f"elements must be a list of element names (or a 0-255 bitmask), got {names!r}")
|
|
118
|
+
mask = 0
|
|
119
|
+
for n in names:
|
|
120
|
+
bit = _ELEM_BY_NAME.get(str(n).strip().lower())
|
|
121
|
+
if bit is None:
|
|
122
|
+
raise ValueError(f"unknown element {n!r} (one of {', '.join(nm for _, nm in _itemstats.ELEMENTS)})")
|
|
123
|
+
mask |= bit
|
|
124
|
+
return mask
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def encode_category(names) -> int:
|
|
128
|
+
"""A list of weapon-category names (``short-range`` / ``long-range`` / ``throw`` / ``offset``) OR a 0-255
|
|
129
|
+
bitmask int -> the WeaponCategory byte. Raises ValueError on an unknown name / out-of-range value -- the
|
|
130
|
+
Category column is a ``CsvParser.Byte`` so a >255 int would OverflowException + HARD-QUIT at weapon load
|
|
131
|
+
(the same trap as ``elements``). ``throw`` makes the weapon eligible for Amarant's Throw command."""
|
|
132
|
+
if isinstance(names, bool):
|
|
133
|
+
raise ValueError("category must be a list of category names or a 0-255 bitmask, not a bool")
|
|
134
|
+
if isinstance(names, int):
|
|
135
|
+
if not 0 <= names <= 255:
|
|
136
|
+
raise ValueError(f"category bitmask {names} out of range 0..255")
|
|
137
|
+
return names
|
|
138
|
+
if names is None:
|
|
139
|
+
return 0
|
|
140
|
+
if not isinstance(names, (list, tuple)):
|
|
141
|
+
raise ValueError(f"category must be a list of category names (or a 0-255 bitmask), got {names!r}")
|
|
142
|
+
mask = 0
|
|
143
|
+
for n in names:
|
|
144
|
+
bit = _CATEGORY_BY_NAME.get(_norm(n))
|
|
145
|
+
if bit is None:
|
|
146
|
+
raise ValueError(f"unknown weapon category {n!r} "
|
|
147
|
+
f"(one of {', '.join(nm for _, nm in _itemstats.WEAPON_CATEGORY)})")
|
|
148
|
+
mask |= bit
|
|
149
|
+
return mask
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def encode_characters(names) -> list:
|
|
153
|
+
"""A list of party-character names -> the canonical :data:`itemstats.CHARS` subset (de-duped, validated).
|
|
154
|
+
Raises ValueError on a non-list or an unknown name. ``[[item]] equippable_by`` uses this to REWRITE the 12
|
|
155
|
+
equip-by-character bits of an item (the listed characters can equip it; everyone else cannot)."""
|
|
156
|
+
if not isinstance(names, (list, tuple)):
|
|
157
|
+
raise ValueError(f"equippable_by must be a list of character names (any of {', '.join(_itemstats.CHARS)})")
|
|
158
|
+
out: list = []
|
|
159
|
+
for n in names:
|
|
160
|
+
c = _CHAR_BY_NAME.get(str(n).strip().lower())
|
|
161
|
+
if c is None:
|
|
162
|
+
raise ValueError(f"unknown character {n!r} (one of {', '.join(_itemstats.CHARS)})")
|
|
163
|
+
if c not in out:
|
|
164
|
+
out.append(c)
|
|
165
|
+
return out
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def encode_statuses(names) -> int:
|
|
169
|
+
"""A list of status names (Poison/Silence/Death/...) OR a non-negative int -> the ``BattleStatus`` mask
|
|
170
|
+
(``UInt64``). Raises ValueError on an unknown name / negative / wrong type. Whether a use-effect INFLICTS or
|
|
171
|
+
CURES the masked statuses is the effect's ``ScriptId`` (which the kit doesn't touch), so this only sets WHICH
|
|
172
|
+
statuses the effect concerns."""
|
|
173
|
+
if isinstance(names, bool):
|
|
174
|
+
raise ValueError("status must be a list of status names or a non-negative bitmask, not a bool")
|
|
175
|
+
if isinstance(names, int):
|
|
176
|
+
if not 0 <= names < 2 ** 64: # Status is a UInt64 -> a >2^64-1 mask OverflowExceptions
|
|
177
|
+
raise ValueError(f"status bitmask {names} out of range 0..2**64-1 (a UInt64)") # + HARD-QUITs at load
|
|
178
|
+
return names
|
|
179
|
+
if names is None:
|
|
180
|
+
return 0
|
|
181
|
+
if not isinstance(names, (list, tuple)):
|
|
182
|
+
raise ValueError(f"status must be a list of status names (or a bitmask int), got {names!r}")
|
|
183
|
+
mask = 0
|
|
184
|
+
for n in names:
|
|
185
|
+
bit = _STATUS_BY_NAME.get(str(n).strip().lower())
|
|
186
|
+
if bit is None:
|
|
187
|
+
raise ValueError(f"unknown status {n!r} (one of {', '.join(nm for _, nm in _itemstats.STATUSES)})")
|
|
188
|
+
mask |= bit
|
|
189
|
+
return mask
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _clamp_int(value, lo, hi, what) -> int:
|
|
193
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
194
|
+
raise ValueError(f"{what} must be an int (got {value!r})")
|
|
195
|
+
return max(lo, min(hi, value))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# --- raw CSV read (preserve the header block verbatim; rows keyed by the Id column) ----------------
|
|
199
|
+
|
|
200
|
+
def read_base_csv(text: str):
|
|
201
|
+
"""Parse a Memoria item CSV TEXT into ``(header_text, cols, id_col, rows_by_id)``. ``header_text`` is every
|
|
202
|
+
leading line up to the first data row, verbatim (the ``#!`` options + ``#``-legend + separators -- so a
|
|
203
|
+
re-emit parses identically). ``cols`` = column-name -> index (from the legend). ``rows_by_id`` = {id:
|
|
204
|
+
raw_row_string}. The first data row is the first line whose first non-space char is NOT ``#`` (a Stats.csv
|
|
205
|
+
``Comment`` cell may itself contain ``#``, so only a LEADING ``#`` marks a comment)."""
|
|
206
|
+
header, cols, id_col, rows = [], None, None, {}
|
|
207
|
+
in_data = False
|
|
208
|
+
for line in text.splitlines():
|
|
209
|
+
s = line.strip()
|
|
210
|
+
if not in_data and (not s or s.startswith("#")):
|
|
211
|
+
header.append(line)
|
|
212
|
+
if cols is None and s.startswith("#"):
|
|
213
|
+
fields = [f.strip() for f in s.lstrip("#").strip().split(";")]
|
|
214
|
+
if "Id" in fields and len(fields) > 1:
|
|
215
|
+
cols = {n: i for i, n in enumerate(fields)}
|
|
216
|
+
id_col = cols["Id"]
|
|
217
|
+
continue
|
|
218
|
+
in_data = True
|
|
219
|
+
if id_col is None:
|
|
220
|
+
continue
|
|
221
|
+
parts = line.split(";")
|
|
222
|
+
try:
|
|
223
|
+
iid = int(parts[id_col].strip())
|
|
224
|
+
except (ValueError, IndexError):
|
|
225
|
+
continue
|
|
226
|
+
rows[iid] = line
|
|
227
|
+
return "\n".join(header), (cols or {}), id_col, rows
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _set_col(row: str, idx: int, value) -> str:
|
|
231
|
+
"""Replace column ``idx`` of a ``;``-joined row with ``value`` (other cells -- incl. a trailing ``# name``
|
|
232
|
+
comment -- preserved verbatim)."""
|
|
233
|
+
parts = row.split(";")
|
|
234
|
+
if idx >= len(parts):
|
|
235
|
+
raise ValueError(f"row has {len(parts)} columns; cannot set column {idx}")
|
|
236
|
+
parts[idx] = str(value)
|
|
237
|
+
return ";".join(parts)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _fk_of(items_rows, items_cols, item_id: int, fk_col: str, kind: str) -> int:
|
|
241
|
+
"""The ``WeaponId`` / ``ArmorId`` of an item (from its ``Items.csv`` row). Raises if the item is missing or
|
|
242
|
+
not of that kind (FK < 0)."""
|
|
243
|
+
row = items_rows.get(item_id)
|
|
244
|
+
if row is None:
|
|
245
|
+
raise ValueError(f"item id {item_id} has no Items.csv row")
|
|
246
|
+
parts = row.split(";")
|
|
247
|
+
idx = items_cols.get(fk_col)
|
|
248
|
+
try:
|
|
249
|
+
fk = int(parts[idx].strip())
|
|
250
|
+
except (TypeError, ValueError, IndexError):
|
|
251
|
+
fk = -1
|
|
252
|
+
if fk < 0:
|
|
253
|
+
raise ValueError(f"{_items.name_of(item_id) or item_id} is not a {kind} (no {fk_col})")
|
|
254
|
+
return fk
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _edits_for(block, col_map, cols) -> dict:
|
|
258
|
+
"""{CSV column index: new cell value} for a patch block, applying only the keys the block sets + clamps."""
|
|
259
|
+
edits = {}
|
|
260
|
+
for key, csv_col in col_map.items():
|
|
261
|
+
if key not in block:
|
|
262
|
+
continue
|
|
263
|
+
idx = cols.get(csv_col)
|
|
264
|
+
if idx is None:
|
|
265
|
+
raise ValueError(f"this install's CSV has no {csv_col!r} column")
|
|
266
|
+
v = block[key]
|
|
267
|
+
if key == "elements":
|
|
268
|
+
edits[idx] = encode_elements(v)
|
|
269
|
+
elif key == "category":
|
|
270
|
+
edits[idx] = encode_category(v)
|
|
271
|
+
elif key in ("price", "sell"):
|
|
272
|
+
edits[idx] = _clamp_int(v, 0, PRICE_CAP, key)
|
|
273
|
+
elif key == "status_index":
|
|
274
|
+
edits[idx] = _clamp_int(v, 0, STATUS_INDEX_CAP, key)
|
|
275
|
+
elif key == "rate":
|
|
276
|
+
edits[idx] = _clamp_int(v, 0, RATE_CAP, key)
|
|
277
|
+
else:
|
|
278
|
+
edits[idx] = _clamp_int(v, 0, POWER_CAP, key)
|
|
279
|
+
return edits
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _equip_mask_edits(names, cols) -> dict:
|
|
283
|
+
"""{Items.csv character-column index: 0/1} that REWRITES an item's 12 equip-by-character bits to exactly
|
|
284
|
+
``names`` (each listed character -> 1, every other -> 0). Raises if the install's Items.csv lacks a column."""
|
|
285
|
+
wanted = set(encode_characters(names))
|
|
286
|
+
edits = {}
|
|
287
|
+
for ch in _itemstats.CHARS:
|
|
288
|
+
idx = cols.get(ch)
|
|
289
|
+
if idx is None:
|
|
290
|
+
raise ValueError(f"this install's Items.csv has no {ch!r} equip column")
|
|
291
|
+
edits[idx] = 1 if ch in wanted else 0
|
|
292
|
+
return edits
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def ability_tokens(entries) -> str:
|
|
296
|
+
"""A list of ability NAMES / ``AA:X`` / ``SA:X`` tokens / numeric ids -> the ``Items.csv`` ``AbilityIds`` cell
|
|
297
|
+
text (``"AA:104, SA:19"``, or ``"0"`` for none -- the engine's no-abilities sentinel). Each entry is resolved
|
|
298
|
+
to its canonical token via :func:`abilities.resolve` + :func:`abilities.decode_token` (a NAME matched against
|
|
299
|
+
the live pools, a token/id decoded mod-agnostically); duplicates collapse, first-seen order kept. The cell is a
|
|
300
|
+
COMMA list inside one semicolon-cell, so the commas don't clash with the CSV's ``;`` delimiter -- the engine
|
|
301
|
+
reads it via ``CsvParser.AnyAbilityArray``. Raises ValueError on an unknown name / malformed token."""
|
|
302
|
+
if not isinstance(entries, (list, tuple)):
|
|
303
|
+
raise ValueError("teaches must be a list of ability names or AA:X / SA:X tokens")
|
|
304
|
+
seen, toks = set(), []
|
|
305
|
+
for e in entries:
|
|
306
|
+
tok = _abilities.decode_token(_abilities.resolve(None, e)) # name (global pool) / token / id -> token
|
|
307
|
+
if tok not in seen:
|
|
308
|
+
seen.add(tok)
|
|
309
|
+
toks.append(tok)
|
|
310
|
+
return ", ".join(toks) if toks else "0"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# --- delta builders (text) ------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
def _emit(header: str, rows_by_id: dict, banner: str) -> str:
|
|
316
|
+
body = "\n".join(rows_by_id[k] for k in sorted(rows_by_id))
|
|
317
|
+
return f"{banner}\n{header}\n{body}\n"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def build_weapons_delta(items_text: str, weapons_text: str, weapons) -> "str | None":
|
|
321
|
+
"""A partial ``Weapons.csv`` text from ``[[weapon]]`` blocks (or ``None`` if none patch). Each block:
|
|
322
|
+
``name`` (item name/id) + any of ``power`` / ``elements``."""
|
|
323
|
+
icols_t = read_base_csv(items_text)
|
|
324
|
+
wheader, wcols, _wid, wrows = read_base_csv(weapons_text)
|
|
325
|
+
_iheader, icols, _iid, irows = icols_t
|
|
326
|
+
patched: dict = {}
|
|
327
|
+
for b in weapons:
|
|
328
|
+
iid = _items.resolve(b["name"])
|
|
329
|
+
wid = _fk_of(irows, icols, iid, "WeaponId", "weapon")
|
|
330
|
+
base = patched.get(wid, wrows.get(wid))
|
|
331
|
+
if base is None:
|
|
332
|
+
raise ValueError(f"no Weapons.csv row for WeaponId {wid} ({b['name']})")
|
|
333
|
+
for idx, val in _edits_for(b, _WEAPON_COLS, wcols).items():
|
|
334
|
+
base = _set_col(base, idx, val)
|
|
335
|
+
patched[wid] = base
|
|
336
|
+
if not patched:
|
|
337
|
+
return None
|
|
338
|
+
return _emit(wheader, patched, "# ff9mapkit [[weapon]] -- Weapons.csv delta (merged by id, whole-row, over the base)")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def build_armors_delta(items_text: str, armors_text: str, armors) -> "str | None":
|
|
342
|
+
iheader_t = read_base_csv(items_text)
|
|
343
|
+
aheader, acols, _aid, arows = read_base_csv(armors_text)
|
|
344
|
+
_ih, icols, _iid, irows = iheader_t
|
|
345
|
+
patched: dict = {}
|
|
346
|
+
for b in armors:
|
|
347
|
+
iid = _items.resolve(b["name"])
|
|
348
|
+
aid = _fk_of(irows, icols, iid, "ArmorId", "armor")
|
|
349
|
+
base = patched.get(aid, arows.get(aid))
|
|
350
|
+
if base is None:
|
|
351
|
+
raise ValueError(f"no Armors.csv row for ArmorId {aid} ({b['name']})")
|
|
352
|
+
for idx, val in _edits_for(b, _ARMOR_COLS, acols).items():
|
|
353
|
+
base = _set_col(base, idx, val)
|
|
354
|
+
patched[aid] = base
|
|
355
|
+
if not patched:
|
|
356
|
+
return None
|
|
357
|
+
return _emit(aheader, patched, "# ff9mapkit [[armor]] -- Armors.csv delta (merged by id, whole-row, over the base)")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def build_items_delta(items_text: str, items, *, bonusid_repoints=None) -> "str | None":
|
|
361
|
+
"""A partial ``Items.csv`` text from ``[[item]]`` blocks (keyed by item id directly). Each block: ``name`` +
|
|
362
|
+
any of ``price`` / ``sell`` / ``equippable_by`` (REWRITES the 12 equip-by-character bits) / ``teaches``
|
|
363
|
+
(REWRITES the ``AbilityIds`` cell -- the abilities the gear teaches). ``bonusid_repoints`` ({item_id: new
|
|
364
|
+
BonusId}) additionally repoints those items' ``BonusId`` column (from :func:`build_equip_bonus_delta`'s mint
|
|
365
|
+
path) -- ALL channels compose on one row (the engine merges whole-row, so price + equippable_by + teaches +
|
|
366
|
+
a repointed BonusId must ship together in the same Items.csv row)."""
|
|
367
|
+
header, cols, _idcol, rows = read_base_csv(items_text)
|
|
368
|
+
patched: dict = {}
|
|
369
|
+
for b in items:
|
|
370
|
+
iid = _items.resolve(b["name"])
|
|
371
|
+
base = patched.get(iid, rows.get(iid))
|
|
372
|
+
if base is None:
|
|
373
|
+
raise ValueError(f"no Items.csv row for item id {iid} ({b['name']})")
|
|
374
|
+
for idx, val in _edits_for(b, _ITEM_COLS, cols).items():
|
|
375
|
+
base = _set_col(base, idx, val)
|
|
376
|
+
if "equippable_by" in b:
|
|
377
|
+
for idx, val in _equip_mask_edits(b["equippable_by"], cols).items():
|
|
378
|
+
base = _set_col(base, idx, val)
|
|
379
|
+
if "teaches" in b: # REWRITE the AbilityIds cell (the abilities gear teaches)
|
|
380
|
+
aidx = cols.get("AbilityIds")
|
|
381
|
+
if aidx is None:
|
|
382
|
+
raise ValueError("this install's Items.csv has no AbilityIds column (can't set taught abilities)")
|
|
383
|
+
base = _set_col(base, aidx, ability_tokens(b["teaches"]))
|
|
384
|
+
patched[iid] = base
|
|
385
|
+
if bonusid_repoints:
|
|
386
|
+
bcol = cols.get("BonusId")
|
|
387
|
+
if bcol is None:
|
|
388
|
+
raise ValueError("this install's Items.csv has no BonusId column (can't repoint an equip bonus)")
|
|
389
|
+
for item_id, new_bonus in bonusid_repoints.items():
|
|
390
|
+
base = patched.get(item_id, rows.get(item_id))
|
|
391
|
+
if base is None:
|
|
392
|
+
raise ValueError(f"no Items.csv row for item id {item_id} (equip-bonus repoint)")
|
|
393
|
+
patched[item_id] = _set_col(base, bcol, new_bonus)
|
|
394
|
+
if not patched:
|
|
395
|
+
return None
|
|
396
|
+
return _emit(header, patched, "# ff9mapkit [[item]] -- Items.csv delta (merged by id, whole-row, over the base)")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# --- equip stat bonuses (Stats.csv / ItemStats) ---------------------------------------------------
|
|
400
|
+
|
|
401
|
+
def _edits_for_bonus(block, scols) -> dict:
|
|
402
|
+
"""{Stats.csv column index: new cell value} for an ``[[equip_bonus]]`` block (stat ints clamped 0-255;
|
|
403
|
+
element keys via :func:`encode_elements`)."""
|
|
404
|
+
edits = {}
|
|
405
|
+
for key, csv_col in _EQUIP_BONUS_STATS.items():
|
|
406
|
+
if key in block:
|
|
407
|
+
idx = scols.get(csv_col)
|
|
408
|
+
if idx is None:
|
|
409
|
+
raise ValueError(f"this install's Stats.csv has no {csv_col!r} column")
|
|
410
|
+
edits[idx] = _clamp_int(block[key], 0, STAT_CAP, key)
|
|
411
|
+
for key, csv_col in _EQUIP_BONUS_ELEMS.items():
|
|
412
|
+
if key in block:
|
|
413
|
+
idx = scols.get(csv_col)
|
|
414
|
+
if idx is None:
|
|
415
|
+
raise ValueError(f"this install's Stats.csv has no {csv_col!r} column")
|
|
416
|
+
edits[idx] = encode_elements(block[key])
|
|
417
|
+
return edits
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _mint_comment(new_id: int, name) -> str:
|
|
421
|
+
"""The Comment cell (col 0) of a kit-minted Stats.csv row. Sanitized: ``;`` would split into extra columns
|
|
422
|
+
(shifting the Id); a leading ``#`` would make CsvReader SKIP the whole line. The "Bonus NNNN # " prefix means
|
|
423
|
+
the cell never starts with ``#``, so the row always parses as data."""
|
|
424
|
+
safe = str(name).replace(";", ",").replace("\n", " ").replace("\r", " ").strip()
|
|
425
|
+
return f"Bonus {new_id:04d} # {safe} (ff9mapkit)"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _synthetic_stat_row(width: int, new_id: int, name, id_col: int) -> str:
|
|
429
|
+
"""An all-zero Stats.csv row (for an item whose current BonusId is Empty/dangling -- nothing to seed from).
|
|
430
|
+
``width`` must hold every edited column (>= the real header width, so a >11-column modded Stats.csv is safe)."""
|
|
431
|
+
parts = ["0"] * max(width, 11)
|
|
432
|
+
parts[0] = _mint_comment(new_id, name)
|
|
433
|
+
parts[id_col] = str(new_id)
|
|
434
|
+
return ";".join(parts)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def build_equip_bonus_delta(items_text: str, stats_text: str, equip_bonuses):
|
|
438
|
+
"""A partial ``Stats.csv`` text from ``[[equip_bonus]]`` blocks + the ``{item_id: new BonusId}`` repoints its
|
|
439
|
+
mint path needs in ``Items.csv``. Returns ``(stats_delta | None, repoints)``.
|
|
440
|
+
|
|
441
|
+
Each block: ``name`` (equippable item) + any of ``speed`` / ``strength`` / ``magic`` / ``spirit`` +
|
|
442
|
+
``attack_element`` / ``guard_element`` / ``absorb_element`` / ``half_element`` / ``weak_element``. An item whose
|
|
443
|
+
``BonusId`` is DEDICATED (used by it alone, and not the shared Empty row 0) is edited in place; otherwise a fresh
|
|
444
|
+
row is minted (seeded from the item's current bonus values so unchanged stats carry) and the item is repointed,
|
|
445
|
+
so the edit can NEVER leak onto another item that shared the row."""
|
|
446
|
+
_ih, icols, _iid_col, irows = read_base_csv(items_text)
|
|
447
|
+
sheader, scols, sid_col, srows = read_base_csv(stats_text)
|
|
448
|
+
bcol = icols.get("BonusId")
|
|
449
|
+
if bcol is None:
|
|
450
|
+
raise ValueError("this install's Items.csv has no BonusId column (can't tune equip bonuses)")
|
|
451
|
+
# How many items point at each BonusId -- only a row used by exactly ONE item (and not the shared row 0) is
|
|
452
|
+
# safe to edit in place; everything else mints a fresh row.
|
|
453
|
+
users: dict = {}
|
|
454
|
+
for row in irows.values():
|
|
455
|
+
parts = row.split(";")
|
|
456
|
+
try:
|
|
457
|
+
bid = int(parts[bcol].strip())
|
|
458
|
+
except (ValueError, IndexError):
|
|
459
|
+
continue
|
|
460
|
+
users[bid] = users.get(bid, 0) + 1
|
|
461
|
+
used_ids = {0} | set(srows) | set(users) # include 0 so a mint NEVER lands on the Empty row
|
|
462
|
+
mint_next = max(used_ids) + 1
|
|
463
|
+
row_width = max((len(r.split(";")) for r in srows.values()), default=0)
|
|
464
|
+
row_width = max(row_width, len(scols), 11) # a synthetic seed must hold every edited column
|
|
465
|
+
|
|
466
|
+
# Coalesce blocks per resolved item FIRST -- so two [[equip_bonus]] on the SAME item MERGE (later block wins
|
|
467
|
+
# per column) on BOTH the in-place and the mint path. (Without this, two blocks on a shared-row item would each
|
|
468
|
+
# mint a separate row, the last repoint would win, and the first block's edits would be silently lost + orphan a
|
|
469
|
+
# half-minted row.) first-seen order keeps the minted ids deterministic.
|
|
470
|
+
per_item: dict = {}
|
|
471
|
+
order: list = []
|
|
472
|
+
for b in equip_bonuses:
|
|
473
|
+
iid = _items.resolve(b["name"])
|
|
474
|
+
e = _edits_for_bonus(b, scols)
|
|
475
|
+
if not e:
|
|
476
|
+
continue
|
|
477
|
+
if iid not in per_item:
|
|
478
|
+
per_item[iid] = {"name": b["name"], "edits": {}}
|
|
479
|
+
order.append(iid)
|
|
480
|
+
per_item[iid]["edits"].update(e)
|
|
481
|
+
|
|
482
|
+
patched: dict = {}
|
|
483
|
+
repoints: dict = {}
|
|
484
|
+
for iid in order:
|
|
485
|
+
name = per_item[iid]["name"]
|
|
486
|
+
edits = per_item[iid]["edits"]
|
|
487
|
+
irow = irows.get(iid)
|
|
488
|
+
if irow is None:
|
|
489
|
+
raise ValueError(f"no Items.csv row for item id {iid} ({name})")
|
|
490
|
+
try:
|
|
491
|
+
cur = int(irow.split(";")[bcol].strip())
|
|
492
|
+
except (ValueError, IndexError):
|
|
493
|
+
cur = 0
|
|
494
|
+
dedicated = cur != 0 and cur in srows and users.get(cur, 0) == 1
|
|
495
|
+
if dedicated:
|
|
496
|
+
base = srows[cur] # 1:1 by definition, so touched once
|
|
497
|
+
for idx, val in edits.items():
|
|
498
|
+
base = _set_col(base, idx, val)
|
|
499
|
+
patched[cur] = base
|
|
500
|
+
else:
|
|
501
|
+
new_id = mint_next
|
|
502
|
+
mint_next += 1
|
|
503
|
+
seed = srows.get(cur)
|
|
504
|
+
if seed is None:
|
|
505
|
+
seed = _synthetic_stat_row(row_width, new_id, name, sid_col)
|
|
506
|
+
else:
|
|
507
|
+
seed = _set_col(seed, sid_col, new_id)
|
|
508
|
+
seed = _set_col(seed, 0, _mint_comment(new_id, name))
|
|
509
|
+
for idx, val in edits.items():
|
|
510
|
+
seed = _set_col(seed, idx, val)
|
|
511
|
+
patched[new_id] = seed
|
|
512
|
+
repoints[iid] = new_id
|
|
513
|
+
if not patched:
|
|
514
|
+
return None, {}
|
|
515
|
+
return (_emit(sheader, patched,
|
|
516
|
+
"# ff9mapkit [[equip_bonus]] -- Stats.csv delta (ItemStats, merged by id, whole-row, over the base)"),
|
|
517
|
+
repoints)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
# --- consumable use-effects (ItemEffects.csv / ItemEffect) ----------------------------------------
|
|
521
|
+
|
|
522
|
+
def _effect_edits(block, ecols) -> dict:
|
|
523
|
+
"""{ItemEffects.csv column index: new cell value} for an ``[[item_effect]]`` block. ``power``/``rate`` clamped
|
|
524
|
+
ints; ``element`` via :func:`encode_elements` (a Byte); ``status`` via :func:`encode_statuses` (the BattleStatus
|
|
525
|
+
mask); ``for_dead`` -> the ``Dead`` bit (0/1)."""
|
|
526
|
+
edits = {}
|
|
527
|
+
for key, csv_col in _EFFECT_INT_COLS.items():
|
|
528
|
+
if key in block:
|
|
529
|
+
idx = ecols.get(csv_col)
|
|
530
|
+
if idx is None:
|
|
531
|
+
raise ValueError(f"this install's ItemEffects.csv has no {csv_col!r} column")
|
|
532
|
+
cap = EFFECT_POWER_CAP if key == "power" else RATE_CAP
|
|
533
|
+
edits[idx] = _clamp_int(block[key], 0, cap, key)
|
|
534
|
+
for key, csv_col, enc in (("element", "Element", encode_elements), ("status", "Status", encode_statuses)):
|
|
535
|
+
if key in block:
|
|
536
|
+
idx = ecols.get(csv_col)
|
|
537
|
+
if idx is None:
|
|
538
|
+
raise ValueError(f"this install's ItemEffects.csv has no {csv_col!r} column")
|
|
539
|
+
edits[idx] = enc(block[key])
|
|
540
|
+
if "for_dead" in block:
|
|
541
|
+
idx = ecols.get("Dead")
|
|
542
|
+
if idx is None:
|
|
543
|
+
raise ValueError("this install's ItemEffects.csv has no 'Dead' column")
|
|
544
|
+
edits[idx] = 1 if block["for_dead"] else 0
|
|
545
|
+
return edits
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def build_item_effects_delta(items_text: str, effects_text: str, item_effects) -> "str | None":
|
|
549
|
+
"""A partial ``ItemEffects.csv`` text from ``[[item_effect]]`` blocks (or ``None`` if none patch). Each block:
|
|
550
|
+
``name`` (a USABLE item) + any of ``power`` / ``rate`` / ``element`` / ``status`` / ``for_dead``. The item's
|
|
551
|
+
``EffectId`` (from ``Items.csv``) locates its ItemEffect row, which is edited IN PLACE -- ``EffectId`` is 1:1
|
|
552
|
+
with a usable item (no shared ``Empty`` row, unlike ``BonusId``), so an in-place edit never leaks onto another
|
|
553
|
+
item. Whole-row merge by id (``ff9item.LoadItemEffects``), so the delta carries the base header (incl.
|
|
554
|
+
``#! IncludeId``) verbatim + only the patched rows."""
|
|
555
|
+
_ih, icols, _iid, irows = read_base_csv(items_text)
|
|
556
|
+
eheader, ecols, _eidc, erows = read_base_csv(effects_text)
|
|
557
|
+
ecol = icols.get("EffectId")
|
|
558
|
+
if ecol is None:
|
|
559
|
+
raise ValueError("this install's Items.csv has no EffectId column (can't tune use-effects)")
|
|
560
|
+
patched: dict = {}
|
|
561
|
+
for b in item_effects:
|
|
562
|
+
iid = _items.resolve(b["name"])
|
|
563
|
+
irow = irows.get(iid)
|
|
564
|
+
if irow is None:
|
|
565
|
+
raise ValueError(f"no Items.csv row for item id {iid} ({b['name']})")
|
|
566
|
+
try:
|
|
567
|
+
eid = int(irow.split(";")[ecol].strip())
|
|
568
|
+
except (ValueError, IndexError):
|
|
569
|
+
eid = -1
|
|
570
|
+
if eid < 0:
|
|
571
|
+
raise ValueError(f"{_items.name_of(iid) or iid} has no use-effect (EffectId < 0 -- not a usable item)")
|
|
572
|
+
base = patched.get(eid, erows.get(eid))
|
|
573
|
+
if base is None:
|
|
574
|
+
raise ValueError(f"no ItemEffects.csv row for EffectId {eid} ({b['name']})")
|
|
575
|
+
for idx, val in _effect_edits(b, ecols).items():
|
|
576
|
+
base = _set_col(base, idx, val)
|
|
577
|
+
patched[eid] = base
|
|
578
|
+
if not patched:
|
|
579
|
+
return None
|
|
580
|
+
return _emit(eheader, patched,
|
|
581
|
+
"# ff9mapkit [[item_effect]] -- ItemEffects.csv delta (merged by id, whole-row, over the base)")
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
# --- write into the mod (reads the install's base CSVs) -------------------------------------------
|
|
585
|
+
|
|
586
|
+
def _base_dir(game=None):
|
|
587
|
+
from ..config import find_game_path
|
|
588
|
+
return find_game_path(game) / "StreamingAssets" / "Data" / "Items"
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
# The base CSVs are cp1252 (e.g. byte 0x92 = the apostrophe in "What's That!?"), NOT UTF-8 -- and a delta must
|
|
592
|
+
# round-trip those bytes so the engine (which reads them the same non-UTF-8 way) parses it identically. We
|
|
593
|
+
# decode/encode cp1252 and strip a leading UTF-8 BOM at the byte level (else it would corrupt the first header
|
|
594
|
+
# line). Edited cells are ASCII digits; unchanged cells (incl. comment names) keep their exact bytes.
|
|
595
|
+
CSV_ENCODING = "cp1252"
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _read_text(path) -> str:
|
|
599
|
+
raw = path.read_bytes()
|
|
600
|
+
if raw.startswith(b"\xef\xbb\xbf"): # a UTF-8 BOM -> drop it (cp1252 would mangle it)
|
|
601
|
+
raw = raw[3:]
|
|
602
|
+
return raw.decode(CSV_ENCODING, errors="replace")
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def write_item_data(layout, weapons=(), armors=(), items=(), equip_bonuses=(), item_effects=(), *, game=None) -> None:
|
|
606
|
+
"""Emit the ``[[weapon]]`` / ``[[armor]]`` / ``[[item]]`` / ``[[equip_bonus]]`` / ``[[item_effect]]`` deltas into
|
|
607
|
+
``layout``'s mod root. Reads the base rows from the install (raises a clear ValueError if it isn't reachable --
|
|
608
|
+
the deltas need the base columns). An ``[[equip_bonus]]`` mint repoints an item's BonusId, so its Items.csv
|
|
609
|
+
repoints merge into the same Items.csv delta as any ``[[item]]`` price edits."""
|
|
610
|
+
if not (weapons or armors or items or equip_bonuses or item_effects):
|
|
611
|
+
return
|
|
612
|
+
from ..config import ConfigError # a RuntimeError (no resolvable install), NOT OSError --
|
|
613
|
+
try: # catch it too so build.py's `except ValueError` warns+skips
|
|
614
|
+
d = _base_dir(game)
|
|
615
|
+
items_text = _read_text(d / "Items.csv")
|
|
616
|
+
except (OSError, ConfigError) as e:
|
|
617
|
+
raise ValueError("item-data patches ([[weapon]]/[[armor]]/[[item]]/[[equip_bonus]]/[[item_effect]]) need "
|
|
618
|
+
f"your FF9 install to read the base Items.csv columns: {e}") from e
|
|
619
|
+
repoints: dict = {}
|
|
620
|
+
stats_delta = None
|
|
621
|
+
if equip_bonuses:
|
|
622
|
+
try:
|
|
623
|
+
stats_text = _read_text(d / "Stats.csv")
|
|
624
|
+
except OSError as e:
|
|
625
|
+
raise ValueError("equip-bonus patches ([[equip_bonus]]) need your FF9 install to read the base "
|
|
626
|
+
f"Stats.csv columns -- couldn't read {d / 'Stats.csv'}: {e}") from e
|
|
627
|
+
stats_delta, repoints = build_equip_bonus_delta(items_text, stats_text, equip_bonuses)
|
|
628
|
+
plan = []
|
|
629
|
+
if weapons:
|
|
630
|
+
plan.append((layout.weapons_csv, build_weapons_delta(items_text, _read_text(d / "Weapons.csv"), weapons)))
|
|
631
|
+
if armors:
|
|
632
|
+
plan.append((layout.armors_csv, build_armors_delta(items_text, _read_text(d / "Armors.csv"), armors)))
|
|
633
|
+
if items or repoints:
|
|
634
|
+
plan.append((layout.items_csv, build_items_delta(items_text, items, bonusid_repoints=repoints)))
|
|
635
|
+
if stats_delta is not None:
|
|
636
|
+
plan.append((layout.stats_csv, stats_delta))
|
|
637
|
+
if item_effects:
|
|
638
|
+
plan.append((layout.item_effects_csv,
|
|
639
|
+
build_item_effects_delta(items_text, _read_text(d / "ItemEffects.csv"), item_effects)))
|
|
640
|
+
for path, text in plan:
|
|
641
|
+
if text is None:
|
|
642
|
+
continue
|
|
643
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
644
|
+
path.write_text(text, encoding=CSV_ENCODING, newline="\n")
|