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,558 @@
|
|
|
1
|
+
"""Compile a battle.toml into a Memoria mod (custom battle map). Offline + deterministic (stdlib only).
|
|
2
|
+
|
|
3
|
+
Mirrors build.FieldProject / build.build_mod. A battle map ships as a loose FBX (+ image#.png textures)
|
|
4
|
+
at ModLayout.battlemap_dir(bbg); registration has three modes:
|
|
5
|
+
* default -- bbg = an existing real slot -> the FBX OVERRIDES that map (no patch line, no relaunch).
|
|
6
|
+
* repoint -- repoint_scene = <id> -> a BattlePatch.txt 'BattleBackground' line points that scene's bg
|
|
7
|
+
at `bbg` (relaunch).
|
|
8
|
+
* MINT (tier c, in-game proven) -- scene_id + scene_name + a forked `scene/` dir (raw16/raw17/eb/mes,
|
|
9
|
+
produced by `battle-import --fork-scene`) -> a net-new, independently-triggerable battle:
|
|
10
|
+
a DictionaryPatch 'BattleScene <id> <NAME> <BBG>' line + the scene's gameplay/sequence/
|
|
11
|
+
camera/text assets, and (for a new BBG_B<N> number) a static INB. No camera authoring is
|
|
12
|
+
needed -- the donor's raw17 carries a working camera. Trigger it with a field encounter
|
|
13
|
+
pointing at scene_id (deploy_battle.py --trigger-field, or a field.toml [encounter]).
|
|
14
|
+
|
|
15
|
+
The scene assets are SE-derived (forked from the user's install into a gitignored project dir); this
|
|
16
|
+
module only COPIES them, staying stdlib-only. The INB is authored here (pure struct.pack).
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
import shutil
|
|
22
|
+
import struct
|
|
23
|
+
import tomllib
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from ..config import LANGS, ModLayout
|
|
28
|
+
from . import camera_codec as _camera_codec
|
|
29
|
+
from . import camera_data as _camera_data
|
|
30
|
+
from . import event_data as _event_data
|
|
31
|
+
from . import fbx as _fbx
|
|
32
|
+
from . import scene_codec as _scene_codec
|
|
33
|
+
from . import scene_data as _scene_data
|
|
34
|
+
from . import scenelint as _scenelint
|
|
35
|
+
from . import seqauthor as _seqauthor
|
|
36
|
+
from . import seqpatch as _seqpatch
|
|
37
|
+
|
|
38
|
+
_BBG_RE = re.compile(r"^BBG_[A-Z]\d+$")
|
|
39
|
+
# Real shipping battle maps are BBG_B001..177; a NEW number (>= this) = a wholly custom map that needs
|
|
40
|
+
# its own static INB authored. Below it, a mint reuses the real slot's bundled INB.
|
|
41
|
+
_REAL_BBG_MAX = 177
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BattleBuildError(RuntimeError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _bbg_number(bbg: str) -> int:
|
|
49
|
+
return int(re.sub(r"\D", "", bbg.split("_")[-1])) # 'BBG_B200' -> 200
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class BattleProject:
|
|
54
|
+
raw: dict
|
|
55
|
+
base_dir: Path
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def load(cls, toml_path) -> "BattleProject":
|
|
59
|
+
p = Path(toml_path)
|
|
60
|
+
with p.open("rb") as fh:
|
|
61
|
+
raw = tomllib.load(fh)
|
|
62
|
+
return cls(raw, p.parent)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def bm(self) -> dict:
|
|
66
|
+
return self.raw.get("battlemap", {})
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def bbg(self) -> str:
|
|
70
|
+
return self.bm["bbg"]
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def fbx_rel(self) -> str:
|
|
74
|
+
return self.bm.get("fbx", f"{self.bbg}.fbx")
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def scene_id(self):
|
|
78
|
+
return self.bm.get("scene_id")
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def scene_name(self):
|
|
82
|
+
return self.bm.get("scene_name")
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def is_mint(self) -> bool:
|
|
86
|
+
return self.scene_id is not None and bool(self.scene_name)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def scene_dir(self) -> Path:
|
|
90
|
+
return self.base_dir / "scene"
|
|
91
|
+
|
|
92
|
+
def path(self, rel: str) -> Path:
|
|
93
|
+
return (self.base_dir / rel).resolve()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _ai_entries(scene_cfg: dict, mc: int):
|
|
97
|
+
"""``[[scene.enemy]]`` -> a per-slot AI-entry override list (parallel to the ``mc`` spawned slots; a None element
|
|
98
|
+
keeps ``rewrite_main_init``'s default ``1+type`` binding). Raises TypeError/ValueError on a non-int ``ai_entry``
|
|
99
|
+
(a TOML array/table etc) -- the callers turn that into a clean problem/BattleBuildError, never a raw traceback."""
|
|
100
|
+
by_slot = {int(e["slot"]): int(e["ai_entry"])
|
|
101
|
+
for e in scene_cfg.get("enemy", []) if isinstance(e, dict) and "ai_entry" in e and "slot" in e}
|
|
102
|
+
return [by_slot.get(s) for s in range(mc)] if by_slot else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _resolve_reskins(scene_cfg: dict, *, game=None):
|
|
106
|
+
"""Resolve any ``[[scene.enemy]]`` re-skin (``model =`` / ``model_scene =``) to a REAL donor monster block,
|
|
107
|
+
injecting it as ``_reskin_block`` so ``scene_data.apply_scene_edits`` transplants it. Returns
|
|
108
|
+
(scene_cfg-or-copy, warnings). Install-gated, but ONLY enemies WITH a re-skin trigger the read -- a
|
|
109
|
+
re-skin-free scene is returned untouched (so non-re-skin builds/tests never touch the install)."""
|
|
110
|
+
from . import reskin as _reskin
|
|
111
|
+
enemies = scene_cfg.get("enemy") or []
|
|
112
|
+
resolved, warns, touched = [], [], False
|
|
113
|
+
for e in enemies:
|
|
114
|
+
spec = _reskin.reskin_spec(e) if isinstance(e, dict) else None
|
|
115
|
+
if spec is not None:
|
|
116
|
+
block, prov = _reskin.resolve_donor_block(spec, game=game)
|
|
117
|
+
e = dict(e, _reskin_block=block)
|
|
118
|
+
warns.append(f"slot {e.get('slot')}: re-skinned BODY to {prov} -- the new model's idle/damage/death "
|
|
119
|
+
f"animations play, but the ATTACK animation stays the target enemy's (raw17-bound, "
|
|
120
|
+
f"retargeted onto the new mesh; a full re-skin would also need the donor's raw17 + AA_DATA)")
|
|
121
|
+
touched = True
|
|
122
|
+
resolved.append(e)
|
|
123
|
+
return (dict(scene_cfg, enemy=resolved), warns) if touched else (scene_cfg, [])
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# the mod-GLOBAL player/ability CSV-delta blocks a battle.toml may ALSO carry (the same blocks a field.toml
|
|
127
|
+
# can -- build._emit_battle_data / _emit_character_data / _emit_ability_features). Carried here so a battle
|
|
128
|
+
# fork can tune the PARTY that fights it in the SAME deployable doc; emitted by build_battle_mod.
|
|
129
|
+
_PLAYER_CSV_KEYS = ("battle_action", "status", "status_set", "magic_sword_set", "character", "leveling",
|
|
130
|
+
"ability_gem", "command_set", "character_param", "learn", "ability_feature")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _aslist(v):
|
|
134
|
+
"""A TOML block that may be a single table or a list of tables -> always a list (never traceback)."""
|
|
135
|
+
return v if isinstance(v, list) else ([] if v is None else [v])
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def player_csv_problems(raw: dict) -> list[str]:
|
|
139
|
+
"""Offline structural + range lint for the player/ability CSV-delta blocks on a battle.toml (empty => OK).
|
|
140
|
+
Mirrors the field build's block (``build.lint_logic``); the validators are install-free (name->id + base-row
|
|
141
|
+
resolution happens at build, which has the install). Reused so the field side and the battle side stay in
|
|
142
|
+
lockstep."""
|
|
143
|
+
from . import abilityfeatures as _af
|
|
144
|
+
from . import actiondelta as _ad
|
|
145
|
+
from . import characterdelta as _cd
|
|
146
|
+
problems: list[str] = []
|
|
147
|
+
for q, ba in enumerate(_aslist(raw.get("battle_action"))):
|
|
148
|
+
problems += [f"[[battle_action]] #{q}: {p}" for p in _ad.validate_entry(ba, kind="battle_action")]
|
|
149
|
+
for q, st in enumerate(_aslist(raw.get("status"))):
|
|
150
|
+
problems += [f"[[status]] #{q}: {p}" for p in _ad.validate_entry(st, kind="status")]
|
|
151
|
+
if raw.get("status_set"):
|
|
152
|
+
problems += [f"[[status_set]]: {p}" for p in _ad.validate_status_sets(raw.get("status_set"))]
|
|
153
|
+
if raw.get("magic_sword_set"):
|
|
154
|
+
problems += [f"[[magic_sword_set]]: {p}" for p in _ad.validate_magic_sword_sets(raw.get("magic_sword_set"))]
|
|
155
|
+
for q, c in enumerate(_aslist(raw.get("character"))):
|
|
156
|
+
problems += [f"[[character]] #{q}: {p}" for p in _cd.validate_character(c)]
|
|
157
|
+
for q, lv in enumerate(_aslist(raw.get("leveling"))):
|
|
158
|
+
problems += [f"[[leveling]] #{q}: {p}" for p in _cd.validate_leveling(lv)]
|
|
159
|
+
for q, ag in enumerate(_aslist(raw.get("ability_gem"))):
|
|
160
|
+
problems += [f"[[ability_gem]] #{q}: {p}" for p in _cd.validate_ability_gem(ag)]
|
|
161
|
+
for q, cp in enumerate(_aslist(raw.get("character_param"))):
|
|
162
|
+
problems += [f"[[character_param]] #{q}: {p}" for p in _cd.validate_character_param(cp)]
|
|
163
|
+
for q, cs in enumerate(_aslist(raw.get("command_set"))):
|
|
164
|
+
problems += [f"[[command_set]] #{q}: {p}" for p in _cd.validate_command_set(cs)]
|
|
165
|
+
for q, ln in enumerate(_aslist(raw.get("learn"))):
|
|
166
|
+
problems += [f"[[learn]] #{q}: {p}" for p in _cd.validate_learn(ln)]
|
|
167
|
+
if raw.get("ability_feature"):
|
|
168
|
+
problems += _af.validate_blocks(raw.get("ability_feature"))
|
|
169
|
+
return problems
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def validate_battle(project: BattleProject) -> list[str]:
|
|
173
|
+
"""Return human-readable problems (empty => OK)."""
|
|
174
|
+
problems: list[str] = []
|
|
175
|
+
bm = project.bm
|
|
176
|
+
if not bm:
|
|
177
|
+
return ["[battlemap] section is required"]
|
|
178
|
+
bbg = bm.get("bbg")
|
|
179
|
+
if not bbg:
|
|
180
|
+
problems.append("[battlemap] missing 'bbg' (the slot this map ships as, e.g. BBG_B013)")
|
|
181
|
+
elif not _BBG_RE.match(bbg):
|
|
182
|
+
problems.append(f"[battlemap] bbg {bbg!r} must look like BBG_B013 (BBG_<letter><digits>)")
|
|
183
|
+
if not project.path(project.fbx_rel).is_file():
|
|
184
|
+
problems.append(f"[battlemap] fbx not found: {project.fbx_rel}")
|
|
185
|
+
if "scene_id" in bm and "repoint_scene" in bm:
|
|
186
|
+
problems.append("[battlemap] set only ONE of scene_id (mint) or repoint_scene")
|
|
187
|
+
if project.scene_id is not None and not project.scene_name:
|
|
188
|
+
problems.append("[battlemap] scene_id (mint) also needs scene_name")
|
|
189
|
+
if project.raw.get("scene") and not project.is_mint: # the silent-no-op footgun the user hit
|
|
190
|
+
problems.append("[scene] tuning (formation / stats / camera / flags) needs a MINTED scene -- this "
|
|
191
|
+
"battle.toml has no scene_id+scene_name, so it's a bare-BBG OVERRIDE (map geometry only) "
|
|
192
|
+
"and every [scene] edit is SILENTLY IGNORED. Re-fork WITH a 'Fork scene' (e.g. EF_R007) "
|
|
193
|
+
"to mint a tuneable scene, then your stats/camera/flags apply.")
|
|
194
|
+
if project.is_mint:
|
|
195
|
+
sd = project.scene_dir
|
|
196
|
+
need = [sd / "dbfile0000.raw16.bytes", sd / "btlseq.raw17.bytes"]
|
|
197
|
+
need += [sd / "eb" / f"{l}.eb.bytes" for l in LANGS]
|
|
198
|
+
need += [sd / "mes" / f"{l}.mes" for l in LANGS]
|
|
199
|
+
missing = [str(p.relative_to(project.base_dir)) for p in need if not p.is_file()]
|
|
200
|
+
if missing:
|
|
201
|
+
problems.append("[battlemap] mint needs forked scene assets (run `battle-import --fork-scene "
|
|
202
|
+
"<donor>`); missing: " + ", ".join(missing[:4])
|
|
203
|
+
+ (" …" if len(missing) > 4 else ""))
|
|
204
|
+
elif "scene" in project.raw: # tune-the-fight overrides -> validate vs the raw16
|
|
205
|
+
problems += _scene_data.validate_scene(
|
|
206
|
+
(sd / "dbfile0000.raw16.bytes").read_bytes(), project.raw["scene"])
|
|
207
|
+
sc = project.raw["scene"] if isinstance(project.raw["scene"], dict) else {}
|
|
208
|
+
if isinstance(sc.get("camera_zoom"), (int, float)) and not isinstance(sc["camera_zoom"], bool) \
|
|
209
|
+
and sc["camera_zoom"] <= 0: # tweak_opening raises on zoom<=0 -> catch it offline
|
|
210
|
+
problems.append("[scene] camera_zoom must be > 0 (1.0 = unchanged)")
|
|
211
|
+
from . import reskin as _reskin # re-skin SHAPE check (model vs model_scene); install-free
|
|
212
|
+
for e in sc.get("enemy", []): # (the donor read/name resolution happens at build -- needs the install)
|
|
213
|
+
if isinstance(e, dict) and any(e.get(k) is not None for k in ("model", "model_scene", "model_type")):
|
|
214
|
+
try:
|
|
215
|
+
_reskin.reskin_spec(e)
|
|
216
|
+
except _scene_data.SceneEditError as ex:
|
|
217
|
+
problems.append(str(ex))
|
|
218
|
+
ai_patches, ai_funcs = sc.get("ai_patch"), sc.get("ai_function")
|
|
219
|
+
ai_phases, ai_inserts = sc.get("ai_phase"), sc.get("ai_insert")
|
|
220
|
+
eb0 = sd / "eb" / f"{LANGS[0]}.eb.bytes"
|
|
221
|
+
has_ai_override = any(isinstance(e, dict) and "ai_entry" in e for e in sc.get("enemy", []))
|
|
222
|
+
if has_ai_override and sc.get("monster_count") is None: # ai_entry only takes effect via the
|
|
223
|
+
problems.append("[[scene.enemy]] ai_entry has no effect without [scene] monster_count -- the " # rebind
|
|
224
|
+
"AI-binding override is applied only when monster_count re-authors Main_Init")
|
|
225
|
+
if sc.get("monster_count") is not None and eb0.is_file(): # dry-run the Main_Init AI-binding rebind so a
|
|
226
|
+
try: # bad ai_entry / non-standard donor is caught
|
|
227
|
+
patched16, _ = _scene_data.apply_scene_edits( # offline (it raises a clean BattleBuildError
|
|
228
|
+
(sd / "dbfile0000.raw16.bytes").read_bytes(), sc) # at build time, but validate is friendlier)
|
|
229
|
+
mc = patched16[9]
|
|
230
|
+
slot_types = [patched16[8 + 8 + 12 * s] for s in range(mc)]
|
|
231
|
+
_event_data.rewrite_main_init(eb0.read_bytes(), slot_types, _ai_entries(sc, mc))
|
|
232
|
+
except (ValueError, TypeError, _scene_data.SceneEditError) as ex: # TypeError: a non-int (list/table)
|
|
233
|
+
problems.append(f"[[scene]] monster_count AI-binding: {ex}") # ai_entry -> a clean problem, not a crash
|
|
234
|
+
if (ai_patches or ai_funcs or ai_phases or ai_inserts) and eb0.is_file(): # Phase-6b/6c: validate +
|
|
235
|
+
from . import aipatch as _aipatch, aiauthor as _aiauthor, ailint as _ailint # LINT the COMPOSED eb
|
|
236
|
+
atk = None
|
|
237
|
+
try: # the scene attack count enables the Attack-index lint check
|
|
238
|
+
atk = _scene_data.parse_counts((sd / "dbfile0000.raw16.bytes").read_bytes())[2]
|
|
239
|
+
except Exception: # noqa: BLE001 -- optional
|
|
240
|
+
atk = None
|
|
241
|
+
composed = eb0.read_bytes()
|
|
242
|
+
if ai_patches: # same-length first (its offsets stay valid), then length-changing
|
|
243
|
+
problems += [f"[[scene.ai_patch]]: {p}" for p in _aipatch.validate_patches(composed, ai_patches)]
|
|
244
|
+
try:
|
|
245
|
+
composed, _ = _aipatch.apply_ai_patches(composed, ai_patches)
|
|
246
|
+
except _aipatch.AiPatchError: # the spec error is already reported by validate_patches
|
|
247
|
+
pass
|
|
248
|
+
if ai_funcs:
|
|
249
|
+
try:
|
|
250
|
+
composed = _aiauthor.apply_ai_functions(composed, ai_funcs)
|
|
251
|
+
except _aiauthor.AiAuthorError as ex:
|
|
252
|
+
problems.append(f"[[scene.ai_function]]: {ex}")
|
|
253
|
+
# ai_phase / ai_insert (length-changing splices) compose + lint the SAME way the per-lang build ships
|
|
254
|
+
# (ai_phase gets atk_count so out-of-range then/else -- invisible to the composed lint -- is caught here)
|
|
255
|
+
if sc.get("ai_phase"):
|
|
256
|
+
try:
|
|
257
|
+
composed = _aiauthor.apply_ai_phases(composed, sc["ai_phase"], atk_count=atk)
|
|
258
|
+
except _aiauthor.AiAuthorError as ex:
|
|
259
|
+
problems.append(f"[[scene.ai_phase]]: {ex}")
|
|
260
|
+
if sc.get("ai_insert"):
|
|
261
|
+
try:
|
|
262
|
+
composed = _aiauthor.apply_ai_inserts(composed, sc["ai_insert"])
|
|
263
|
+
except _aiauthor.AiAuthorError as ex:
|
|
264
|
+
problems.append(f"[[scene.ai_insert]]: {ex}")
|
|
265
|
+
# lint the FINAL composed bytecode -- EXACTLY what the per-lang build ships, so an ai_patch / ai_function
|
|
266
|
+
# / ai_phase / ai_insert that puts a jump / Attack index out of range (or a runaway branch) is caught.
|
|
267
|
+
problems += [f"[[scene.ai]] lint: {i}" for i in _ailint.lint_ai(composed, atk_count=atk)]
|
|
268
|
+
seq_patches, seq_replaces, seq_inserts = sc.get("seq_patch"), sc.get("seq_replace"), sc.get("seq_insert")
|
|
269
|
+
raw17_f = sd / "btlseq.raw17.bytes"
|
|
270
|
+
if (seq_patches or seq_replaces or seq_inserts) and raw17_f.is_file():
|
|
271
|
+
r17 = raw17_f.read_bytes() # compose in the SAME order the build applies (patch first,
|
|
272
|
+
if seq_patches: # then length-changing replace, then insert) so later
|
|
273
|
+
problems += [f"[[scene.seq_patch]]: {p}" for p in _seqpatch.validate_patches(r17, seq_patches)]
|
|
274
|
+
try: # validations see the post-step raw17
|
|
275
|
+
r17, _ = _seqpatch.apply_seq_patches(r17, seq_patches)
|
|
276
|
+
except _seqpatch.SeqPatchError: # the error is already reported by validate_patches
|
|
277
|
+
pass
|
|
278
|
+
if seq_replaces:
|
|
279
|
+
problems += [f"[[scene.seq_replace]]: {p}" for p in _seqauthor.validate_replaces(r17, seq_replaces)]
|
|
280
|
+
try:
|
|
281
|
+
r17, _ = _seqauthor.apply_seq_replaces(r17, seq_replaces)
|
|
282
|
+
except _seqauthor.SeqAuthorError:
|
|
283
|
+
pass
|
|
284
|
+
if seq_inserts:
|
|
285
|
+
problems += [f"[[scene.seq_insert]]: {p}" for p in _seqauthor.validate_inserts(r17, seq_inserts)]
|
|
286
|
+
problems += player_csv_problems(project.raw) # mod-global player/ability CSV deltas (optional)
|
|
287
|
+
return problems
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _author_inb(bbg: str, tint=(128, 128, 128), shadow: int = 32) -> bytes:
|
|
291
|
+
"""A static BBGINFO (.inb): bbgnumber from `bbg`, all anim flags 0 (texanim/objanim/uvcount), a char
|
|
292
|
+
light tint + shadow. 16 bytes, layout per BBGINFO.cs. Static dodges the hardcoded per-id anim tables."""
|
|
293
|
+
r, g, b = (list(tint) + [128, 128, 128])[:3]
|
|
294
|
+
return struct.pack("<6h4B", _bbg_number(bbg), 0, 0, 0, 0, 0,
|
|
295
|
+
r & 255, g & 255, b & 255, shadow & 255)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@dataclass
|
|
299
|
+
class BattleResult:
|
|
300
|
+
bbg: str
|
|
301
|
+
dict_line: str | None
|
|
302
|
+
battle_patch_lines: list # list[str]
|
|
303
|
+
warnings: list # list[str]
|
|
304
|
+
written: list = field(default_factory=list) # list[Path] -- every file emitted into the layout
|
|
305
|
+
lint: list = field(default_factory=list) # list[scenelint.Finding] -- offline balance notes
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def build_battlemap(project: BattleProject, layout: ModLayout, *, game=None) -> BattleResult:
|
|
309
|
+
problems = validate_battle(project)
|
|
310
|
+
if problems:
|
|
311
|
+
raise BattleBuildError("battle.toml problems:\n " + "\n ".join(problems))
|
|
312
|
+
bbg = project.bbg
|
|
313
|
+
written: list[Path] = []
|
|
314
|
+
|
|
315
|
+
# 1) the map: loose FBX + its textures
|
|
316
|
+
dst = layout.battlemap_dir(bbg)
|
|
317
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
318
|
+
shutil.copyfile(project.path(project.fbx_rel), dst / f"{bbg}.fbx")
|
|
319
|
+
written.append(dst / f"{bbg}.fbx")
|
|
320
|
+
for png in sorted(project.base_dir.glob("*.png")):
|
|
321
|
+
shutil.copyfile(png, dst / png.name)
|
|
322
|
+
written.append(dst / png.name)
|
|
323
|
+
|
|
324
|
+
bm = project.bm
|
|
325
|
+
dict_line = None
|
|
326
|
+
bp: list[str] = []
|
|
327
|
+
warnings: list[str] = []
|
|
328
|
+
lint: list = []
|
|
329
|
+
|
|
330
|
+
# 2) MINT: copy the forked scene assets + author a static INB for a new bbg number + register
|
|
331
|
+
if project.is_mint:
|
|
332
|
+
name, sid = project.scene_name, int(project.scene_id)
|
|
333
|
+
sd = project.scene_dir
|
|
334
|
+
scene_out = layout.battle_scene_dir(name)
|
|
335
|
+
scene_out.mkdir(parents=True, exist_ok=True)
|
|
336
|
+
scene_cfg = project.raw.get("scene") if isinstance(project.raw.get("scene"), dict) else None
|
|
337
|
+
raw16 = (sd / "dbfile0000.raw16.bytes").read_bytes()
|
|
338
|
+
if scene_cfg: # tune the fight (positions/stats/rewards/camera selector)
|
|
339
|
+
try: # re-skin: resolve donor model blocks (install I/O) first
|
|
340
|
+
scene_cfg, reskin_warns = _resolve_reskins(scene_cfg, game=game)
|
|
341
|
+
except _scene_data.SceneEditError as ex:
|
|
342
|
+
raise BattleBuildError(f"re-skin: {ex}")
|
|
343
|
+
warnings += reskin_warns
|
|
344
|
+
raw16, scene_warns = _scene_data.apply_scene_edits(raw16, scene_cfg)
|
|
345
|
+
warnings += scene_warns
|
|
346
|
+
(scene_out / "dbfile0000.raw16.bytes").write_bytes(raw16)
|
|
347
|
+
# offline BALANCE lint of the final (tuned) scene -- "I can't see the game" leverage. Advisory only:
|
|
348
|
+
# a lint failure must NEVER crash the build, so degrade to no findings on ANY error.
|
|
349
|
+
try:
|
|
350
|
+
lint = _scenelint.lint_scene(_scene_codec.parse_scene(raw16))
|
|
351
|
+
except Exception: # noqa: BLE001 -- best-effort, build must not fail on lint
|
|
352
|
+
lint = []
|
|
353
|
+
# raw17: tweak the OPENING camera's keyframes IN PLACE (yaw/pitch/zoom) -- no offset repack. Which
|
|
354
|
+
# camera plays = raw16 pattern Camera byte (the `[scene] camera` selector); tweak that one (0-2) or
|
|
355
|
+
# all of 0/1/2 if it's random/unpinned.
|
|
356
|
+
raw17 = (sd / "btlseq.raw17.bytes").read_bytes()
|
|
357
|
+
cam_idx = _camera_data.opening_indices(scene_cfg.get("camera")) if scene_cfg else []
|
|
358
|
+
if scene_cfg and scene_cfg.get("camera_keyframes"): # tier ii: author the opening from scratch
|
|
359
|
+
try:
|
|
360
|
+
raw17 = _camera_codec.author_opening(raw17, cam_idx, scene_cfg["camera_keyframes"])
|
|
361
|
+
except ValueError as ex:
|
|
362
|
+
raise BattleBuildError(f"camera keyframe authoring failed: {ex}")
|
|
363
|
+
if scene_cfg and any(k in scene_cfg for k in ("camera_yaw", "camera_pitch", "camera_zoom")):
|
|
364
|
+
raw17, cam_report = _camera_data.tweak_opening( # tier i: offset (composes over keyframes)
|
|
365
|
+
raw17, cam_idx,
|
|
366
|
+
yaw_deg=float(scene_cfg.get("camera_yaw", 0)),
|
|
367
|
+
pitch_deg=float(scene_cfg.get("camera_pitch", 0)),
|
|
368
|
+
zoom=float(scene_cfg.get("camera_zoom", 1.0)))
|
|
369
|
+
warnings += [f"camera tweak: {r}" for r in cam_report] # so it's NOT silent (the user's debug pain)
|
|
370
|
+
if scene_cfg.get("camera") is None and len(raw16) > 10 and raw16[10] >= 3:
|
|
371
|
+
warnings.append("camera tweak: [scene] camera is unpinned and this scene's opening camera is "
|
|
372
|
+
"RANDOM -- the tweak hits cameras 0/1/2 but the engine may play another; pin "
|
|
373
|
+
"[scene] camera = 0/1/2 so the tweak lands on the camera that actually plays")
|
|
374
|
+
if scene_cfg and scene_cfg.get("seq_patch"): # same-length attack-sequence operand patches FIRST
|
|
375
|
+
try: # (its offsets are into the un-repacked body region)
|
|
376
|
+
raw17, sp_warns = _seqpatch.apply_seq_patches(raw17, scene_cfg["seq_patch"])
|
|
377
|
+
except _seqpatch.SeqPatchError as ex:
|
|
378
|
+
raise BattleBuildError(f"[[scene.seq_patch]]: {ex}")
|
|
379
|
+
warnings += sp_warns
|
|
380
|
+
if scene_cfg and scene_cfg.get("seq_replace"): # length-changing: replace a whole sequence body
|
|
381
|
+
try: # (repacks -> every seqOffset/camOffset recomputed)
|
|
382
|
+
raw17, sr_warns = _seqauthor.apply_seq_replaces(raw17, scene_cfg["seq_replace"])
|
|
383
|
+
except _seqauthor.SeqAuthorError as ex:
|
|
384
|
+
raise BattleBuildError(f"[[scene.seq_replace]]: {ex}")
|
|
385
|
+
warnings += sr_warns
|
|
386
|
+
if scene_cfg and scene_cfg.get("seq_insert"): # length-changing: splice a fragment into a sequence
|
|
387
|
+
try:
|
|
388
|
+
raw17, si_warns = _seqauthor.apply_seq_inserts(raw17, scene_cfg["seq_insert"])
|
|
389
|
+
except _seqauthor.SeqAuthorError as ex:
|
|
390
|
+
raise BattleBuildError(f"[[scene.seq_insert]]: {ex}")
|
|
391
|
+
warnings += si_warns
|
|
392
|
+
(scene_out / f"{sid}.raw17.bytes").write_bytes(raw17)
|
|
393
|
+
written += [scene_out / "dbfile0000.raw16.bytes", scene_out / f"{sid}.raw17.bytes"]
|
|
394
|
+
|
|
395
|
+
# spawn composition re-authors the eb's Main_Init to bind one enemy-AI object per spawned slot, so
|
|
396
|
+
# the AI binding matches the (now-uniform) pattern -- this is what lets a mint EXCEED the donor's
|
|
397
|
+
# natural enemy count without the player-model twitch. slot types come from the patched raw16.
|
|
398
|
+
slot_types = ai_entries = None
|
|
399
|
+
if scene_cfg and "monster_count" in scene_cfg:
|
|
400
|
+
mc = raw16[9] # pattern 0 MonsterCount (now uniform)
|
|
401
|
+
slot_types = [raw16[8 + 8 + 12 * s] for s in range(mc)]
|
|
402
|
+
ai_entries = _ai_entries(scene_cfg, mc) # explicit per-slot AI-entry overrides (validate-gated)
|
|
403
|
+
try: # the scene attack count -> ai_phase then/else guard
|
|
404
|
+
_atk_count = _scene_data.parse_counts(raw16)[2]
|
|
405
|
+
except Exception: # noqa: BLE001 -- optional, falls back to the byte cap
|
|
406
|
+
_atk_count = None
|
|
407
|
+
for lang in LANGS:
|
|
408
|
+
eb_dst = layout.battle_eb_path(lang, name)
|
|
409
|
+
eb_dst.parent.mkdir(parents=True, exist_ok=True)
|
|
410
|
+
eb = (sd / "eb" / f"{lang}.eb.bytes").read_bytes()
|
|
411
|
+
if slot_types is not None:
|
|
412
|
+
try:
|
|
413
|
+
eb = _event_data.rewrite_main_init(eb, slot_types, ai_entries)
|
|
414
|
+
except ValueError as ex:
|
|
415
|
+
raise BattleBuildError(f"spawn composition needs a Main_Init re-author this donor "
|
|
416
|
+
f"can't support: {ex}")
|
|
417
|
+
if scene_cfg and scene_cfg.get("ai_patch"): # Phase-6b: same-length AI constant patches (eb).
|
|
418
|
+
from . import aipatch as _aipatch # The bytecode is language-identical -> same offsets.
|
|
419
|
+
try:
|
|
420
|
+
eb, ai_warns = _aipatch.apply_ai_patches(eb, scene_cfg["ai_patch"])
|
|
421
|
+
if lang == LANGS[0]:
|
|
422
|
+
warnings += ai_warns
|
|
423
|
+
except _aipatch.AiPatchError as ex:
|
|
424
|
+
raise BattleBuildError(str(ex))
|
|
425
|
+
if scene_cfg and (scene_cfg.get("ai_function") or scene_cfg.get("ai_phase")
|
|
426
|
+
or scene_cfg.get("ai_insert")): # Phase-6c: length-changing AI edits (AFTER ai_patch
|
|
427
|
+
from . import aiauthor as _aiauthor # so the same-length patch offsets stayed valid).
|
|
428
|
+
try:
|
|
429
|
+
if scene_cfg.get("ai_function"): # replace/add a WHOLE function
|
|
430
|
+
eb = _aiauthor.apply_ai_functions(eb, scene_cfg["ai_function"])
|
|
431
|
+
if scene_cfg.get("ai_phase"): # generate + splice an HP-threshold phase branch
|
|
432
|
+
eb = _aiauthor.apply_ai_phases(eb, scene_cfg["ai_phase"], atk_count=_atk_count)
|
|
433
|
+
if scene_cfg.get("ai_insert"): # splice an explicit branch fragment
|
|
434
|
+
eb = _aiauthor.apply_ai_inserts(eb, scene_cfg["ai_insert"])
|
|
435
|
+
except _aiauthor.AiAuthorError as ex:
|
|
436
|
+
raise BattleBuildError(str(ex))
|
|
437
|
+
eb_dst.write_bytes(eb)
|
|
438
|
+
mes_dst = layout.battle_text_dir(lang) / f"{sid}.mes"
|
|
439
|
+
mes_dst.parent.mkdir(parents=True, exist_ok=True)
|
|
440
|
+
shutil.copyfile(sd / "mes" / f"{lang}.mes", mes_dst)
|
|
441
|
+
written += [eb_dst, mes_dst]
|
|
442
|
+
if _bbg_number(bbg) > _REAL_BBG_MAX: # a wholly new map -> author its static INB
|
|
443
|
+
inb = _author_inb(bbg, tuple(bm.get("char_tint", (128, 128, 128))), int(bm.get("shadow", 32)))
|
|
444
|
+
inb_dst = layout.battle_info_dir / f"{bbg.replace('BBG', 'INB')}.inb.bytes"
|
|
445
|
+
inb_dst.parent.mkdir(parents=True, exist_ok=True)
|
|
446
|
+
inb_dst.write_bytes(inb)
|
|
447
|
+
written.append(inb_dst)
|
|
448
|
+
dict_line = f"BattleScene {sid} {name} {bbg}"
|
|
449
|
+
|
|
450
|
+
# 3) repoint an existing scene's background at this map
|
|
451
|
+
if bm.get("repoint_scene") is not None:
|
|
452
|
+
bp.append(f"Battle: {int(bm['repoint_scene'])}")
|
|
453
|
+
bp.append(f"BattleBackground {bbg}")
|
|
454
|
+
|
|
455
|
+
return BattleResult(bbg=bbg, dict_line=dict_line, battle_patch_lines=bp, warnings=warnings,
|
|
456
|
+
written=written, lint=lint)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _emit_player_data(projects, layout, *, game=None) -> tuple:
|
|
460
|
+
"""Emit the mod-GLOBAL player/ability CSV deltas every battle.toml carries (``[[battle_action]]`` ..
|
|
461
|
+
``[[ability_feature]]``) into ``layout``, aggregating across all built battle maps -- the SAME emitters the
|
|
462
|
+
field build uses (``actiondelta.write_battle_data`` / ``characterdelta.write_character_data`` /
|
|
463
|
+
``abilityfeatures.write_ability_features``). Returns ``(written_paths, warnings)``. The CSV emitters READ the
|
|
464
|
+
install's base CSVs (whole-row merge); a bad block raises BattleBuildError (the build, not the game, fails)."""
|
|
465
|
+
from . import abilityfeatures as _af
|
|
466
|
+
from . import actiondelta as _ad
|
|
467
|
+
from . import characterdelta as _cd
|
|
468
|
+
|
|
469
|
+
def _all(key):
|
|
470
|
+
out = []
|
|
471
|
+
for p in projects:
|
|
472
|
+
out += _aslist(p.raw.get(key))
|
|
473
|
+
return out or None
|
|
474
|
+
|
|
475
|
+
actions, statuses = _all("battle_action"), _all("status")
|
|
476
|
+
status_sets, magic_sword_sets = _all("status_set"), _all("magic_sword_set")
|
|
477
|
+
characters, levelings, ability_gems = _all("character"), _all("leveling"), _all("ability_gem")
|
|
478
|
+
character_params, command_sets, learns = _all("character_param"), _all("command_set"), _all("learn")
|
|
479
|
+
features = _all("ability_feature")
|
|
480
|
+
if not any((actions, statuses, status_sets, magic_sword_sets, characters, levelings, ability_gems,
|
|
481
|
+
character_params, command_sets, learns, features)):
|
|
482
|
+
return [], []
|
|
483
|
+
written, warnings = [], []
|
|
484
|
+
try:
|
|
485
|
+
if actions or statuses or status_sets or magic_sword_sets:
|
|
486
|
+
warnings += _ad.write_battle_data(layout, actions=actions, statuses=statuses,
|
|
487
|
+
status_sets=status_sets, magic_sword_sets=magic_sword_sets, game=game)
|
|
488
|
+
written += [layout.actions_csv] if actions else []
|
|
489
|
+
written += [layout.status_data_csv] if statuses else []
|
|
490
|
+
written += [layout.status_sets_csv] if status_sets else []
|
|
491
|
+
written += [layout.magic_sword_sets_csv] if magic_sword_sets else []
|
|
492
|
+
if characters or levelings or ability_gems or character_params or command_sets or learns:
|
|
493
|
+
warnings += _cd.write_character_data(layout, characters=characters, levelings=levelings,
|
|
494
|
+
ability_gems=ability_gems, character_params=character_params,
|
|
495
|
+
command_sets=command_sets, learns=learns, game=game)
|
|
496
|
+
written += [layout.base_stats_csv] if characters else []
|
|
497
|
+
written += [layout.leveling_csv] if levelings else []
|
|
498
|
+
written += [layout.ability_gems_csv] if ability_gems else []
|
|
499
|
+
written += [layout.character_parameters_csv] if character_params else []
|
|
500
|
+
written += [layout.command_sets_csv] if command_sets else []
|
|
501
|
+
written += [layout.abilities_csv(pname) for pname in (_cd._group_learns(learns) if learns else ())]
|
|
502
|
+
if features:
|
|
503
|
+
warnings += _af.write_ability_features(layout, features, game=game)
|
|
504
|
+
if layout.ability_features_txt.is_file():
|
|
505
|
+
written.append(layout.ability_features_txt)
|
|
506
|
+
except (_ad.ActionDeltaError, _cd.CharacterDeltaError, _af.AbilityFeatureError) as ex:
|
|
507
|
+
raise BattleBuildError(str(ex))
|
|
508
|
+
warnings.append("player/ability CSV deltas on a battle.toml are mod-GLOBAL (always-on, not scene-scoped) and "
|
|
509
|
+
"merge with any field.toml's same blocks (highest-priority folder wins in a multi-folder "
|
|
510
|
+
"campaign). The canonical home for mod-global tuning is a field.toml or a journey "
|
|
511
|
+
"[journey.tuning] block; the battle.toml carrier is a rare edge case (tuning the party that "
|
|
512
|
+
"fights THIS battle in the same deployable doc)")
|
|
513
|
+
return [p for p in written if p.is_file()], warnings
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def build_battle_mod(projects, out_root, *, mod_name="FF9CustomMap", author="", description="", game=None) -> dict:
|
|
517
|
+
"""Build battle map(s) into a mod at ``out_root``; write/append the registration files. ``game`` (an FF9
|
|
518
|
+
install dir) is consulted by an enemy re-skin (`[[scene.enemy]] model =`, a live donor model read) AND by
|
|
519
|
+
the player/ability CSV deltas (`[[battle_action]]` .. `[[ability_feature]]`, a live base-CSV read); None =
|
|
520
|
+
the default resolution ($FF9_GAME_PATH / config / common Steam paths)."""
|
|
521
|
+
layout = ModLayout(Path(out_root).resolve())
|
|
522
|
+
layout.root.mkdir(parents=True, exist_ok=True)
|
|
523
|
+
results = [build_battlemap(p, layout, game=game) for p in projects]
|
|
524
|
+
player_written, player_warns = _emit_player_data(projects, layout, game=game)
|
|
525
|
+
|
|
526
|
+
dlines = [r.dict_line for r in results if r.dict_line]
|
|
527
|
+
if dlines:
|
|
528
|
+
# append to any existing DictionaryPatch (so a co-built field mod isn't clobbered)
|
|
529
|
+
prior = (layout.dictionary_patch.read_text(encoding="utf-8").splitlines()
|
|
530
|
+
if layout.dictionary_patch.exists() else [])
|
|
531
|
+
layout.dictionary_patch.write_text(
|
|
532
|
+
"\n".join([ln for ln in prior if ln.strip()] + dlines) + "\n",
|
|
533
|
+
encoding="utf-8", newline="\n")
|
|
534
|
+
|
|
535
|
+
bplines = [ln for r in results for ln in r.battle_patch_lines]
|
|
536
|
+
if bplines:
|
|
537
|
+
prior = (layout.battle_patch.read_text(encoding="utf-8").splitlines()
|
|
538
|
+
if layout.battle_patch.exists() else [])
|
|
539
|
+
layout.battle_patch.write_text(
|
|
540
|
+
"\n".join([ln for ln in prior if ln.strip()] + bplines) + "\n",
|
|
541
|
+
encoding="utf-8", newline="\n")
|
|
542
|
+
|
|
543
|
+
if not layout.mod_description.exists():
|
|
544
|
+
layout.mod_description.write_text(
|
|
545
|
+
"<Mod>\n"
|
|
546
|
+
f" <Name>{mod_name}</Name>\n"
|
|
547
|
+
f" <Author>{author}</Author>\n"
|
|
548
|
+
f" <InstallationPath>{mod_name}</InstallationPath>\n"
|
|
549
|
+
" <Category></Category>\n"
|
|
550
|
+
f" <Description>{description}</Description>\n"
|
|
551
|
+
"</Mod>\n",
|
|
552
|
+
encoding="utf-8", newline="\n")
|
|
553
|
+
|
|
554
|
+
return {"root": str(layout.root), "maps": [r.bbg for r in results],
|
|
555
|
+
"dictionary": dlines, "battle_patch": bplines,
|
|
556
|
+
"written": [str(p) for r in results for p in r.written] + [str(p) for p in player_written],
|
|
557
|
+
"warnings": [w for r in results for w in r.warnings] + player_warns,
|
|
558
|
+
"lint": [str(f) for r in results for f in r.lint]}
|