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,395 @@
|
|
|
1
|
+
"""``[[battle_patch]]`` / ``[[battle_enemy]]`` / ``[[battle_attack]]`` -- author ``BattlePatch.txt`` by NAME or
|
|
2
|
+
index: the reflection channel for the enemy/attack/scene combat data that CSV can't reach and raw16 can only
|
|
3
|
+
reach by FORKING the scene. This is the Phase-4 emitter (see ``docs/BATTLE_DESIGN.md`` §2a/§8).
|
|
4
|
+
|
|
5
|
+
WHY a BattlePatch channel (vs the raw16 ``[scene]`` tuner):
|
|
6
|
+
* ``[scene]`` byte-patches a FORKED scene's ``dbfile0000.raw16`` -> it only works on a scene you ship a
|
|
7
|
+
modified raw16 for, and it can't reach fields that aren't in the raw16 disk layout at all.
|
|
8
|
+
* ``BattlePatch.txt`` patches ANY scene IN PLACE by reflection AFTER ``ReadBattleScene`` (no fork, no
|
|
9
|
+
repack), and it reaches the ``[Memoria.PatchableField]``-flagged fields -- INCLUDING ones with no raw16
|
|
10
|
+
slot: the drop/steal RATE arrays, ``BonusElement``, ``MaxDamageLimit``/``MaxMpDamageLimit``,
|
|
11
|
+
``WinCardRate`` (``SB2_MON_PARM.cs:53-179``) -- and the per-enemy ATTACK table (``AA_DATA``/``BTL_REF``,
|
|
12
|
+
``BTL_SCENE.cs:127-153``), which the kit could not touch at all before.
|
|
13
|
+
* The by-NAME selectors (``AnyEnemyByName:`` / ``AnyAttackByName:``) patch EVERY enemy/attack of that name
|
|
14
|
+
across ALL scenes -- the campaign-wide WIN over Hades Workshop ("buff every Goblin across the chain").
|
|
15
|
+
|
|
16
|
+
THE ENGINE FORMAT (``Memoria.DataPatchers.PatchBattles`` / ``ApplyBattlePatch``, ``DataPatchers.cs:538-682``):
|
|
17
|
+
``BattlePatch.txt`` is a STATEFUL line list. A *selector* line opens a patch context; subsequent *field*
|
|
18
|
+
lines (``FieldName value``) set the named ``[PatchableField]`` on the struct for that context's token type:
|
|
19
|
+
* ``Battle: <id|name>`` -> a SCENE patch (sets ``BTL_SCENE_INFO`` scene flags). Narrow it with:
|
|
20
|
+
``Pattern: <i>`` -> a PATTERN patch (``SB2_PATTERN``: Rate/Camera/AP)
|
|
21
|
+
``Enemy: <i>`` / ``EnemyByName: <n>`` -> an ENEMY patch (``SB2_MON_PARM`` + ``SB2_ELEMENT``)
|
|
22
|
+
``Attack: <i>`` / ``AttackByName: <n>`` -> an ATTACK patch (``AA_DATA`` + ``BTL_REF`` + cmd info)
|
|
23
|
+
* ``AnyEnemyByName: <name>`` -> a global ENEMY patch (every scene)
|
|
24
|
+
* ``AnyAttackByName: <name>`` -> a global ATTACK patch (every scene)
|
|
25
|
+
Each narrower REUSES the current patch's scene-applicability, so within a ``Battle:`` block the order is:
|
|
26
|
+
scene flags first (they bind to the ``Battle:`` Scene patch), THEN the ``Pattern:``/``Enemy:``/``Attack:``
|
|
27
|
+
sub-blocks -- which is exactly how this module emits.
|
|
28
|
+
|
|
29
|
+
VALUE ENCODING (``ExtensionMethodsString.TryTypeParse`` / ``TryArrayParse``, ``DataPatchers.cs:572-581``):
|
|
30
|
+
a field's value string is parsed by its C# type -- ``String`` verbatim, an ``enum`` via ``Enum.Parse`` (which
|
|
31
|
+
accepts EITHER flag names OR a plain integer), numerics/Boolean via ``TryParse``, an array space-separated.
|
|
32
|
+
Because ``Enum.Parse`` takes integers, we emit INTEGER masks for every enum/flags/element/status/item field
|
|
33
|
+
(via the committed :mod:`battlecsv` name<->bit tables + :func:`ff9mapkit.items.resolve`) -- so NO new enum-name
|
|
34
|
+
table is committed (provenance: only your authored overrides live in the toml; the emitted ``BattlePatch.txt``
|
|
35
|
+
is mod build-output, never committed). Booleans emit ``True``/``False``. Narrow engine column types are
|
|
36
|
+
RANGE-CHECKED offline (a value past a Byte/UInt16/UInt32 cap would otherwise be silently dropped by the
|
|
37
|
+
engine's ``TryParse``).
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from .. import items
|
|
42
|
+
from . import battlecsv
|
|
43
|
+
|
|
44
|
+
_U16 = 0xFFFF
|
|
45
|
+
_U32 = 0xFFFFFFFF
|
|
46
|
+
_I32 = 2 ** 31 - 1
|
|
47
|
+
_U64 = 2 ** 64 - 1
|
|
48
|
+
_STATUS_SET_MAX = 38 # the highest StatusSetId the base engine defines (StatusSetId.cs: None=0 .. =38)
|
|
49
|
+
|
|
50
|
+
# A field spec = (EngineFieldName, encoder, max). `max` is None for the multi-value encoders (items/rates),
|
|
51
|
+
# which range-check each element themselves. Encoders: int / bool / elements / status / script / items / rates.
|
|
52
|
+
# The EngineFieldName is the EXACT C# field name DataPatchers matches by reflection (case-sensitive).
|
|
53
|
+
|
|
54
|
+
# ---- ENEMY token: SB2_MON_PARM + SB2_ELEMENT [PatchableField]s (SB2_MON_PARM.cs:33-179, SB2_ELEMENT.cs) ----
|
|
55
|
+
ENEMY_FIELDS = {
|
|
56
|
+
"max_hp": ("MaxHP", "int", _U32), "max_mp": ("MaxMP", "int", _U32),
|
|
57
|
+
"gil": ("WinGil", "int", _U32), "exp": ("WinExp", "int", _U32),
|
|
58
|
+
"level": ("Level", "int", 0xFF), "category": ("Category", "int", 0xFF),
|
|
59
|
+
"hit_rate": ("HitRate", "int", 0xFF),
|
|
60
|
+
"phys_def": ("PhysicalDefence", "int", _I32), "phys_evade": ("PhysicalEvade", "int", _I32),
|
|
61
|
+
"mag_def": ("MagicalDefence", "int", _I32), "mag_evade": ("MagicalEvade", "int", _I32),
|
|
62
|
+
"blue_magic": ("BlueMagic", "int", _I32),
|
|
63
|
+
# SB2_ELEMENT (the 4 core battle stats; reflection routes these via scene.MonAddr[i].Element)
|
|
64
|
+
"speed": ("Speed", "int", 0xFF), "strength": ("Strength", "int", 0xFF),
|
|
65
|
+
"magic": ("Magic", "int", 0xFF), "spirit": ("Spirit", "int", 0xFF),
|
|
66
|
+
# element-affinity bitmasks (Byte each; element NAMES -> bitmask). `null`/`guard` = nullified/immune.
|
|
67
|
+
"null": ("GuardElement", "elements", 0xFF), "guard": ("GuardElement", "elements", 0xFF),
|
|
68
|
+
"absorb": ("AbsorbElement", "elements", 0xFF), "half": ("HalfElement", "elements", 0xFF),
|
|
69
|
+
"weak": ("WeakElement", "elements", 0xFF),
|
|
70
|
+
"bonus_element": ("BonusElement", "elements", 0xFF), # BP-only: the element the enemy's OWN attacks carry
|
|
71
|
+
# status masks (BattleStatus, a 64-bit [Flags] enum; status NAMES -> bitmask)
|
|
72
|
+
"resist_status": ("ResistStatus", "status", _U64), "auto_status": ("AutoStatus", "status", _U64),
|
|
73
|
+
"initial_status": ("InitialStatus", "status", _U64),
|
|
74
|
+
# rewards: 4-item drop/steal lists (names/ids; "none"->255) + their odds arrays + the Tetra card
|
|
75
|
+
"drop": ("WinItems", "items", None), "drop_rates": ("WinItemRates", "rates", None),
|
|
76
|
+
"steal": ("StealItems", "items", None), "steal_rates": ("StealItemRates", "rates", None),
|
|
77
|
+
"win_card": ("WinCard", "int", 0xFF), "win_card_rate": ("WinCardRate", "int", _U16),
|
|
78
|
+
"max_damage_limit": ("MaxDamageLimit", "int", _U32), # BP-only: per-enemy >9999 break
|
|
79
|
+
"max_mp_damage_limit": ("MaxMpDamageLimit", "int", _U32),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# ---- ATTACK token: BTL_REF + AA_DATA [PatchableField]s (BTL_REF.cs, AA_DATA.cs:30-39) ----
|
|
83
|
+
ATTACK_FIELDS = {
|
|
84
|
+
"power": ("Power", "int", _I32), # BTL_REF (single BYTE on disk, but Int32 in mem)
|
|
85
|
+
"element": ("Elements", "elements", 0xFF), "elements": ("Elements", "elements", 0xFF),
|
|
86
|
+
"rate": ("Rate", "int", _I32),
|
|
87
|
+
"script": ("ScriptId", "script", _I32), "script_id": ("ScriptId", "script", _I32),
|
|
88
|
+
"mp": ("MP", "int", _I32), # AA_DATA
|
|
89
|
+
"category": ("Category", "int", 0xFF), "type": ("Type", "int", 0xFF),
|
|
90
|
+
# AddStatusNo is a StatusSetId enum (a StatusSets.csv ROW id, NOT a status bitmask). The engine parses it
|
|
91
|
+
# via Enum.Parse, which casts ANY integer through WITHOUT bounds-checking, then indexes it with a RAW
|
|
92
|
+
# Dictionary get (FF9Battle.add_status[...] / StatusSets[...]) built only from the 0..38 base rows -> an
|
|
93
|
+
# undefined id (39+) is a KeyNotFoundException CRASH at command-build, not a no-op. So cap at the engine max.
|
|
94
|
+
"status_set": ("AddStatusNo", "int", _STATUS_SET_MAX),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# ---- PATTERN token: SB2_PATTERN [PatchableField]s (SB2_PATTERN.cs:12-24; MonsterCount/Monster are NOT) ----
|
|
98
|
+
PATTERN_FIELDS = {
|
|
99
|
+
"rate": ("Rate", "int", 0xFF), "camera": ("Camera", "int", 0xFF), "ap": ("AP", "int", _U32),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# ---- SCENE token: BTL_SCENE_INFO [PatchableField] Booleans (BTL_SCENE_INFO.cs:7-47; SB2_HEAD has none) ----
|
|
103
|
+
SCENE_FLAGS = {
|
|
104
|
+
"special_start": ("SpecialStart", "bool", None), "preemptive": ("Preemptive", "bool", None),
|
|
105
|
+
"back_attack": ("BackAttack", "bool", None), "no_game_over": ("NoGameOver", "bool", None),
|
|
106
|
+
"no_exp": ("NoExp", "bool", None), "win_pose": ("WinPose", "bool", None),
|
|
107
|
+
"runaway": ("Runaway", "bool", None), "can_escape": ("Runaway", "bool", None),
|
|
108
|
+
"no_neighboring": ("NoNeighboring", "bool", None), "no_magical": ("NoMagical", "bool", None),
|
|
109
|
+
"reverse_attack": ("ReverseAttack", "bool", None),
|
|
110
|
+
"fixed_camera1": ("FixedCamera1", "bool", None), "fixed_camera2": ("FixedCamera2", "bool", None),
|
|
111
|
+
"after_event": ("AfterEvent", "bool", None), "field_bgm": ("FieldBGM", "bool", None),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# keys that select/structure a block rather than set a field
|
|
115
|
+
_ENEMY_SEL = {"index", "name"}
|
|
116
|
+
_ATTACK_SEL = {"index", "name"}
|
|
117
|
+
_PATTERN_SEL = {"index"}
|
|
118
|
+
_SCENE_STRUCT = {"scene", "enemy", "attack", "pattern"}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class BattlePatchError(ValueError):
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---- value encoding (shared by build + offline validate) ---------------------------------------------
|
|
126
|
+
def _to_int(value, key) -> int:
|
|
127
|
+
if isinstance(value, bool) or not isinstance(value, (int, str)):
|
|
128
|
+
raise BattlePatchError(f"{key} must be an integer (got {value!r})")
|
|
129
|
+
try:
|
|
130
|
+
return int(value)
|
|
131
|
+
except ValueError:
|
|
132
|
+
raise BattlePatchError(f"{key} must be an integer (got {value!r})")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _resolve_items(value, key) -> list[int]:
|
|
136
|
+
"""4 drop/steal slots: each a name/id (engine RegularItem) or "none"/""/"-" -> 255 (NoItem). Mirrors
|
|
137
|
+
scene_data._resolve_items (in-game proven on the Phase-1 Goblin drop)."""
|
|
138
|
+
if not isinstance(value, (list, tuple)) or len(value) != 4:
|
|
139
|
+
raise BattlePatchError(f"{key} must be a list of exactly 4 items (name/id; \"none\" or 255 = empty)")
|
|
140
|
+
out = []
|
|
141
|
+
for it in value:
|
|
142
|
+
if isinstance(it, str) and it.strip().lower() in ("none", "", "-"):
|
|
143
|
+
out.append(255)
|
|
144
|
+
else:
|
|
145
|
+
try:
|
|
146
|
+
out.append(items.resolve(it))
|
|
147
|
+
except (ValueError, TypeError) as ex:
|
|
148
|
+
raise BattlePatchError(f"{key}: {ex}")
|
|
149
|
+
return out
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _resolve_rates(value, key) -> list[int]:
|
|
153
|
+
"""4 drop/steal ODDS (UInt16 each). The engine reads the WHOLE array, so all 4 are required."""
|
|
154
|
+
if not isinstance(value, (list, tuple)) or len(value) != 4:
|
|
155
|
+
raise BattlePatchError(f"{key} must be a list of exactly 4 rates (UInt16 0-{_U16}; the engine reads "
|
|
156
|
+
f"all 4 -- defaults are drop {{256,96,32,1}} / steal {{256,64,16,1}})")
|
|
157
|
+
out = []
|
|
158
|
+
for r in value:
|
|
159
|
+
v = _to_int(r, key)
|
|
160
|
+
if not 0 <= v <= _U16:
|
|
161
|
+
raise BattlePatchError(f"{key} value {v} out of range (0-{_U16})")
|
|
162
|
+
out.append(v)
|
|
163
|
+
return out
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def encode_field(key, value, spec, *, warnings=None) -> str:
|
|
167
|
+
"""Resolve + RANGE-CHECK one override value -> its BattlePatch value string (space-joined for arrays).
|
|
168
|
+
Raises BattlePatchError offline so a bad value fails the lint/build, never the running game."""
|
|
169
|
+
engine_name, enc, vmax = spec
|
|
170
|
+
if enc == "int":
|
|
171
|
+
v = _to_int(value, key)
|
|
172
|
+
elif enc == "bool":
|
|
173
|
+
if not isinstance(value, bool):
|
|
174
|
+
raise BattlePatchError(f"{key} must be true or false (got {value!r})")
|
|
175
|
+
return "True" if value else "False"
|
|
176
|
+
elif enc == "elements":
|
|
177
|
+
try:
|
|
178
|
+
v = battlecsv.encode_elements(value)
|
|
179
|
+
except (ValueError, TypeError) as ex:
|
|
180
|
+
raise BattlePatchError(f"{key}: {ex}")
|
|
181
|
+
elif enc == "status":
|
|
182
|
+
try:
|
|
183
|
+
v = battlecsv.encode_status(value)
|
|
184
|
+
except (ValueError, TypeError) as ex:
|
|
185
|
+
raise BattlePatchError(f"{key}: {ex}")
|
|
186
|
+
elif enc == "script":
|
|
187
|
+
if isinstance(value, str) and not value.strip().lstrip("-").isdigit():
|
|
188
|
+
sid = {n.lower(): i for i, n in battlecsv.SCRIPT_IDS.items()}.get(value.strip().lower())
|
|
189
|
+
if sid is None:
|
|
190
|
+
raise BattlePatchError(f"{key}: unknown scriptId formula {value!r} "
|
|
191
|
+
f"(see `ff9mapkit battle-actions --script-ids`)")
|
|
192
|
+
v = sid
|
|
193
|
+
else:
|
|
194
|
+
v = _to_int(value, key)
|
|
195
|
+
if warnings is not None and not battlecsv.is_stock_script(v):
|
|
196
|
+
warnings.append(f"{key}: scriptId {v} is not in the externalized formula catalog -- re-pointing an "
|
|
197
|
+
f"attack at an existing formula is data, but a BRAND-NEW formula needs a "
|
|
198
|
+
f"Memoria.Scripts.<Mod>.dll (not the engine DLL)")
|
|
199
|
+
elif enc == "items":
|
|
200
|
+
return " ".join(str(i) for i in _resolve_items(value, key))
|
|
201
|
+
elif enc == "rates":
|
|
202
|
+
return " ".join(str(i) for i in _resolve_rates(value, key))
|
|
203
|
+
else:
|
|
204
|
+
raise BattlePatchError(f"internal: bad encoder {enc!r}")
|
|
205
|
+
if vmax is not None and not 0 <= v <= vmax:
|
|
206
|
+
raise BattlePatchError(f"{key}={v} out of range (0-{vmax})")
|
|
207
|
+
return str(v)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---- field-line emission for one token's override dict ------------------------------------------------
|
|
211
|
+
def _field_lines(overrides, fields_map, *, ctx, warnings) -> list[str]:
|
|
212
|
+
"""``FieldName value`` lines for every override key found in ``fields_map`` (skipping the selector keys
|
|
213
|
+
already consumed by the caller). Raises on an unknown field key."""
|
|
214
|
+
lines: list[str] = []
|
|
215
|
+
for k, val in overrides.items():
|
|
216
|
+
spec = fields_map.get(k)
|
|
217
|
+
if spec is None:
|
|
218
|
+
raise BattlePatchError(f"{ctx}: unknown field {k!r} (known: {', '.join(sorted(fields_map))})")
|
|
219
|
+
try:
|
|
220
|
+
lines.append(f"{spec[0]} {encode_field(k, val, spec, warnings=warnings)}")
|
|
221
|
+
except BattlePatchError as ex:
|
|
222
|
+
raise BattlePatchError(f"{ctx} {ex}")
|
|
223
|
+
return lines
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _selector_name(token, value, key) -> str:
|
|
227
|
+
"""A by-name selector arg -- the US battle-text name, verbatim (spaces kept; the engine matches the WHOLE
|
|
228
|
+
remainder of the line)."""
|
|
229
|
+
if not isinstance(value, str) or not value.strip():
|
|
230
|
+
raise BattlePatchError(f"{key} must be a non-empty enemy/attack name (a string)")
|
|
231
|
+
return value.strip()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _require_table(blk, ctx) -> dict:
|
|
235
|
+
"""A battle block must be a table (TOML dict). Raise BattlePatchError (NOT a TypeError/AttributeError) so a
|
|
236
|
+
malformed toml fails the lint/build cleanly -- the linter must never traceback on bad input."""
|
|
237
|
+
if not isinstance(blk, dict):
|
|
238
|
+
raise BattlePatchError(f"{ctx} must be a table (got {type(blk).__name__}) -- "
|
|
239
|
+
f"e.g. {{ name = \"Goblin\", max_hp = 500 }}")
|
|
240
|
+
return blk
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _scene_selector(value, ctx) -> str:
|
|
244
|
+
"""The ``Battle:`` selector arg: an int scene id (the engine parses it with Int32.TryParse) OR a non-empty
|
|
245
|
+
BSC_ scene name. A float/list/over-Int32 value would emit a DEAD ``Battle:`` line the engine never matches
|
|
246
|
+
(the block silently no-ops + is pruned) -- exactly the silent-drop this module exists to prevent."""
|
|
247
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
248
|
+
if not 0 <= value <= _I32:
|
|
249
|
+
raise BattlePatchError(f"{ctx} scene id {value} out of range (a battle scene id, 0-{_I32})")
|
|
250
|
+
return str(value)
|
|
251
|
+
if isinstance(value, str) and value.strip():
|
|
252
|
+
return value.strip()
|
|
253
|
+
raise BattlePatchError(f"{ctx} scene must be an int scene id or a \"BSC_...\" name (got {value!r})")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _enemy_block(e, *, ctx, warnings, scoped) -> list[str]:
|
|
257
|
+
"""Emit one enemy patch: a selector (``Enemy: i`` / ``EnemyByName: n`` within a scene, or
|
|
258
|
+
``AnyEnemyByName: n`` globally) + its SB2_MON_PARM/SB2_ELEMENT field lines."""
|
|
259
|
+
_require_table(e, ctx)
|
|
260
|
+
has_idx, has_name = "index" in e, "name" in e
|
|
261
|
+
if scoped:
|
|
262
|
+
if has_idx == has_name:
|
|
263
|
+
raise BattlePatchError(f"{ctx} needs exactly one of index = <type 0..> or name = \"<enemy name>\"")
|
|
264
|
+
sel = f"Enemy: {_to_int(e['index'], ctx + ' index')}" if has_idx \
|
|
265
|
+
else f"EnemyByName: {_selector_name('enemy', e['name'], ctx + ' name')}"
|
|
266
|
+
else: # global [[battle_enemy]] -> AnyEnemyByName
|
|
267
|
+
if not has_name or has_idx:
|
|
268
|
+
raise BattlePatchError(f"{ctx} is global (every scene) -- it needs name = \"<enemy name>\" "
|
|
269
|
+
f"(use a scene-scoped [[battle_patch.enemy]] with index = N to target a slot)")
|
|
270
|
+
sel = f"AnyEnemyByName: {_selector_name('enemy', e['name'], ctx + ' name')}"
|
|
271
|
+
body = {k: v for k, v in e.items() if k not in _ENEMY_SEL}
|
|
272
|
+
if not body:
|
|
273
|
+
raise BattlePatchError(f"{ctx} sets no fields (give e.g. max_hp = 500 or weak = [\"Fire\"])")
|
|
274
|
+
return [sel, *_field_lines(body, ENEMY_FIELDS, ctx=ctx, warnings=warnings)]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _attack_block(a, *, ctx, warnings, scoped) -> list[str]:
|
|
278
|
+
_require_table(a, ctx)
|
|
279
|
+
has_idx, has_name = "index" in a, "name" in a
|
|
280
|
+
if scoped:
|
|
281
|
+
if has_idx == has_name:
|
|
282
|
+
raise BattlePatchError(f"{ctx} needs exactly one of index = <attack 0..> or name = \"<attack name>\"")
|
|
283
|
+
sel = f"Attack: {_to_int(a['index'], ctx + ' index')}" if has_idx \
|
|
284
|
+
else f"AttackByName: {_selector_name('attack', a['name'], ctx + ' name')}"
|
|
285
|
+
else:
|
|
286
|
+
if not has_name or has_idx:
|
|
287
|
+
raise BattlePatchError(f"{ctx} is global (every scene) -- it needs name = \"<attack name>\"")
|
|
288
|
+
sel = f"AnyAttackByName: {_selector_name('attack', a['name'], ctx + ' name')}"
|
|
289
|
+
body = {k: v for k, v in a.items() if k not in _ATTACK_SEL}
|
|
290
|
+
if not body:
|
|
291
|
+
raise BattlePatchError(f"{ctx} sets no fields (give e.g. power = 40 or element = [\"Fire\"])")
|
|
292
|
+
return [sel, *_field_lines(body, ATTACK_FIELDS, ctx=ctx, warnings=warnings)]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _pattern_block(p, *, ctx, warnings) -> list[str]:
|
|
296
|
+
_require_table(p, ctx)
|
|
297
|
+
if "index" not in p:
|
|
298
|
+
raise BattlePatchError(f"{ctx} needs index = <pattern 0..> (which formation)")
|
|
299
|
+
sel = f"Pattern: {_to_int(p['index'], ctx + ' index')}"
|
|
300
|
+
body = {k: v for k, v in p.items() if k not in _PATTERN_SEL}
|
|
301
|
+
if not body:
|
|
302
|
+
raise BattlePatchError(f"{ctx} sets no fields (give e.g. rate = 16 or ap = 12)")
|
|
303
|
+
return [sel, *_field_lines(body, PATTERN_FIELDS, ctx=ctx, warnings=warnings)]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _scene_block(blk, n, *, warnings) -> list[str]:
|
|
307
|
+
"""One ``[[battle_patch]]`` -> ``Battle: <id>`` + scene flags + nested pattern/enemy/attack sub-blocks."""
|
|
308
|
+
ctx = f"[[battle_patch]] #{n}"
|
|
309
|
+
_require_table(blk, ctx)
|
|
310
|
+
scene = blk.get("scene")
|
|
311
|
+
if scene is None or isinstance(scene, bool):
|
|
312
|
+
raise BattlePatchError(f"{ctx} needs scene = <id or BSC_ name> (the BTL_SCENE to patch)")
|
|
313
|
+
lines = [f"Battle: {_scene_selector(scene, ctx)}"]
|
|
314
|
+
# scene flags bind to the Battle (Scene) patch -> they MUST come before any Pattern/Enemy/Attack narrower
|
|
315
|
+
flags = {k: v for k, v in blk.items() if k not in _SCENE_STRUCT}
|
|
316
|
+
lines += _field_lines(flags, SCENE_FLAGS, ctx=ctx, warnings=warnings)
|
|
317
|
+
for i, p in enumerate(_as_list(blk.get("pattern"), f"{ctx} [[battle_patch.pattern]]")):
|
|
318
|
+
lines += _pattern_block(p, ctx=f"{ctx} pattern #{i}", warnings=warnings)
|
|
319
|
+
for i, e in enumerate(_as_list(blk.get("enemy"), f"{ctx} [[battle_patch.enemy]]")):
|
|
320
|
+
lines += _enemy_block(e, ctx=f"{ctx} enemy #{i}", warnings=warnings, scoped=True)
|
|
321
|
+
for i, a in enumerate(_as_list(blk.get("attack"), f"{ctx} [[battle_patch.attack]]")):
|
|
322
|
+
lines += _attack_block(a, ctx=f"{ctx} attack #{i}", warnings=warnings, scoped=True)
|
|
323
|
+
if len(lines) == 1: # only "Battle: X", no fields anywhere -> a no-op block
|
|
324
|
+
raise BattlePatchError(f"{ctx} sets nothing -- add a scene flag, or a [[battle_patch.enemy]] / "
|
|
325
|
+
f"[[battle_patch.attack]] / [[battle_patch.pattern]] sub-block")
|
|
326
|
+
return lines
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _as_list(value, ctx):
|
|
330
|
+
if value is None:
|
|
331
|
+
return []
|
|
332
|
+
if not isinstance(value, list):
|
|
333
|
+
raise BattlePatchError(f"{ctx} must be a list of tables")
|
|
334
|
+
return value
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ---- public: build the BattlePatch lines from aggregated toml blocks ----------------------------------
|
|
338
|
+
def build_lines(scene_patches=None, enemies=None, attacks=None) -> tuple[list[str], list[str]]:
|
|
339
|
+
"""Aggregate ``[[battle_patch]]`` (scene-scoped) + ``[[battle_enemy]]`` / ``[[battle_attack]]`` (global
|
|
340
|
+
by-name) blocks -> (battle_patch_lines, warnings). Pure + offline (no install needed -- names/ids are the
|
|
341
|
+
author's, masks come from the committed tables)."""
|
|
342
|
+
warnings: list[str] = []
|
|
343
|
+
lines: list[str] = []
|
|
344
|
+
for n, blk in enumerate(_as_list(scene_patches, "[[battle_patch]]")):
|
|
345
|
+
lines += _scene_block(blk, n, warnings=warnings)
|
|
346
|
+
for n, e in enumerate(_as_list(enemies, "[[battle_enemy]]")):
|
|
347
|
+
lines += _enemy_block(e, ctx=f"[[battle_enemy]] #{n}", warnings=warnings, scoped=False)
|
|
348
|
+
for n, a in enumerate(_as_list(attacks, "[[battle_attack]]")):
|
|
349
|
+
lines += _attack_block(a, ctx=f"[[battle_attack]] #{n}", warnings=warnings, scoped=False)
|
|
350
|
+
return lines, warnings
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ---- offline structural + range validation (for `lint`, no install) ----------------------------------
|
|
354
|
+
def validate_blocks(scene_patches=None, enemies=None, attacks=None) -> list[str]:
|
|
355
|
+
"""Re-run the emission on a copy and surface every BattlePatchError as a message (empty => OK). All checks
|
|
356
|
+
are install-free: structure, the field-name/range/encoder guards, and the selector rules."""
|
|
357
|
+
problems: list[str] = []
|
|
358
|
+
try:
|
|
359
|
+
build_lines(scene_patches, enemies, attacks)
|
|
360
|
+
except BattlePatchError as ex:
|
|
361
|
+
# build_lines stops at the first error; surface it (the author fixes one at a time, like the scene lint)
|
|
362
|
+
problems.append(str(ex))
|
|
363
|
+
return problems
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ---- non-clobbering merge into a live BattlePatch.txt (deploy) ---------------------------------------
|
|
367
|
+
def _markers(field_id):
|
|
368
|
+
return (f"// >>> ff9mapkit field {field_id} BattlePatch (auto -- edit the field.toml, not here)",
|
|
369
|
+
f"// <<< ff9mapkit field {field_id}")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def merge_battle_patch(live_text: str, block_lines, field_id) -> str:
|
|
373
|
+
"""Splice ``block_lines`` into ``live_text`` between this field's ``//`` sentinel markers, REPLACING any
|
|
374
|
+
prior block for the same id and PRESERVING every other line (a co-deployed battle's BGM/repoint lines, a
|
|
375
|
+
stacked worktree's lines). The engine skips ``//`` lines (``DataPatchers.cs:551``), so the markers are inert.
|
|
376
|
+
An empty ``block_lines`` just strips our prior block (a redeploy after the toml's battle blocks were removed).
|
|
377
|
+
Idempotent: re-merging the same block yields the same text."""
|
|
378
|
+
begin, end = _markers(field_id)
|
|
379
|
+
kept, skip = [], False
|
|
380
|
+
for ln in live_text.splitlines():
|
|
381
|
+
if ln.strip() == begin:
|
|
382
|
+
skip = True
|
|
383
|
+
continue
|
|
384
|
+
if ln.strip() == end:
|
|
385
|
+
skip = False
|
|
386
|
+
continue
|
|
387
|
+
if not skip:
|
|
388
|
+
kept.append(ln)
|
|
389
|
+
while kept and not kept[-1].strip(): # trim trailing blank lines before re-appending
|
|
390
|
+
kept.pop()
|
|
391
|
+
block = [ln for ln in (block_lines or []) if ln.strip()]
|
|
392
|
+
out = list(kept)
|
|
393
|
+
if block:
|
|
394
|
+
out += [begin, *block, end]
|
|
395
|
+
return ("\n".join(out) + "\n") if out else ""
|