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
ff9mapkit/deploystack.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Mod-folder STACK analysis -- the text-block SHADOW guard.
|
|
2
|
+
|
|
3
|
+
A field loads its dialogue by **mesID** (``text_block``), and the engine reads that ``.mes`` from the
|
|
4
|
+
**first** mod folder in ``Memoria.ini``'s ``FolderNames`` that provides ``field/<mesID>.mes`` (earliest =
|
|
5
|
+
highest priority). When several stacked mod folders -- e.g. per-worktree ``FF9CustomMap-*`` test slots --
|
|
6
|
+
all define the kit-default block **1073**, a *lower*-priority folder's text is **shadowed**: the field
|
|
7
|
+
renders some *other* folder's block-1073 text, not yours. (This bit an ``[[on_entry]]`` playtest: a probe
|
|
8
|
+
in ``FF9CustomMap-sf`` showed ``FF9CustomMap``'s stale "Rally-ho!" instead of its authored line.)
|
|
9
|
+
|
|
10
|
+
This module catches it at **deploy time** -- offline, by reading ``Memoria.ini`` + the folders'
|
|
11
|
+
``field/*.mes`` -- and suggests a concrete fix: a real mesID (one some stacked folder defines, hence a
|
|
12
|
+
valid ``MesDB`` id) that **no higher-priority folder** defines, so your folder's override wins.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .config import ModLayout
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ShadowReport:
|
|
25
|
+
"""The result of :func:`check_text_block_shadow`. ``shadowed_by`` is the first higher-priority folder
|
|
26
|
+
that also defines ``text_block`` (``None`` => clear, your text wins). ``suggestions`` are valid mesIDs
|
|
27
|
+
no higher-priority folder defines (safe alternatives). ``order`` is the parsed ``FolderNames`` stack."""
|
|
28
|
+
target_folder: str
|
|
29
|
+
text_block: int
|
|
30
|
+
lang: str
|
|
31
|
+
shadowed_by: str | None
|
|
32
|
+
suggestions: list
|
|
33
|
+
order: list
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def ok(self) -> bool:
|
|
37
|
+
return self.shadowed_by is None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_folder_names(memoria_ini_text: str) -> list:
|
|
41
|
+
"""The ordered mod-folder priority list from a ``Memoria.ini``'s ``FolderNames = "A", "B", ...`` line
|
|
42
|
+
(highest priority first). ``[]`` if the key is absent. Skips comment lines (``;``) so the commented
|
|
43
|
+
"Priorities is only a hint" note above the real line is ignored."""
|
|
44
|
+
for line in memoria_ini_text.splitlines():
|
|
45
|
+
s = line.strip()
|
|
46
|
+
if s.startswith(";") or "=" not in s:
|
|
47
|
+
continue
|
|
48
|
+
key, _, value = s.partition("=")
|
|
49
|
+
if key.strip().lower() == "foldernames":
|
|
50
|
+
return re.findall(r'"([^"]+)"', value)
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def blocks_at(root, lang: str = "us") -> set:
|
|
55
|
+
"""The field text-block ids (``.mes`` file stems) a mod/dist ROOT defines for ``lang`` -- mirrors
|
|
56
|
+
:func:`eb_names_at` / :func:`scene_names_at`, so a built campaign dist can be checked before install."""
|
|
57
|
+
d = ModLayout(Path(root)).text_field_dir(lang)
|
|
58
|
+
out = set()
|
|
59
|
+
if d.is_dir():
|
|
60
|
+
for p in d.glob("*.mes"):
|
|
61
|
+
try:
|
|
62
|
+
out.add(int(p.stem))
|
|
63
|
+
except ValueError:
|
|
64
|
+
pass
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _blocks_in(game_dir: Path, folder: str, lang: str) -> set:
|
|
69
|
+
"""The field text-block ids (``.mes`` file stems) a mod folder defines for ``lang``."""
|
|
70
|
+
return blocks_at(Path(game_dir) / folder, lang)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def check_text_block_shadow(game_dir, target_folder: str, text_block: int, lang: str = "us",
|
|
74
|
+
folder_names: list | None = None) -> ShadowReport:
|
|
75
|
+
"""Will a field's ``text_block`` deployed into ``target_folder`` be SHADOWED by a higher-priority mod
|
|
76
|
+
folder? Reads ``Memoria.ini`` ``FolderNames`` (unless ``folder_names`` is passed) + each folder's
|
|
77
|
+
``field/*.mes``. Degrades gracefully (``shadowed_by=None``) when the stack can't be read; if the target
|
|
78
|
+
isn't listed in ``FolderNames`` nothing is treated as higher-priority (no false alarm)."""
|
|
79
|
+
game_dir = Path(game_dir)
|
|
80
|
+
order = folder_names
|
|
81
|
+
if order is None:
|
|
82
|
+
ini = game_dir / "Memoria.ini"
|
|
83
|
+
order = parse_folder_names(ini.read_text(encoding="utf-8", errors="ignore")) if ini.is_file() else []
|
|
84
|
+
higher = order[:order.index(target_folder)] if target_folder in order else []
|
|
85
|
+
blocks = {f: _blocks_in(game_dir, f, lang) for f in order}
|
|
86
|
+
higher_blocks = set().union(*(blocks[f] for f in higher)) if higher else set()
|
|
87
|
+
shadowed_by = next((f for f in higher if text_block in blocks.get(f, set())), None)
|
|
88
|
+
# valid alternatives = real mesIDs (some stacked folder ships them, so they're in MesDB) that no
|
|
89
|
+
# higher-priority folder defines -> your override wins. Exclude the current (shadowed) block.
|
|
90
|
+
valid = set().union(*blocks.values()) if blocks else set()
|
|
91
|
+
suggestions = sorted(valid - higher_blocks - {text_block})[:6]
|
|
92
|
+
return ShadowReport(target_folder, text_block, lang, shadowed_by, suggestions, order)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def shadow_warning(report: ShadowReport, mod_folder: str | None = None) -> str | None:
|
|
96
|
+
"""A human-readable one-block warning for a shadowed deploy, or ``None`` when the block is clear.
|
|
97
|
+
``mod_folder`` overrides the report's ``target_folder`` label (they match in normal use)."""
|
|
98
|
+
if report.ok:
|
|
99
|
+
return None
|
|
100
|
+
target = mod_folder or report.target_folder
|
|
101
|
+
fix = (f" Use a real block no higher-priority folder defines -- e.g. text_block = {report.suggestions[0]}"
|
|
102
|
+
f" (try: {', '.join(str(s) for s in report.suggestions[:4])})."
|
|
103
|
+
if report.suggestions else
|
|
104
|
+
" Set text_block to a real mesID that no higher-priority folder defines.")
|
|
105
|
+
return (f"TEXT SHADOWED: block {report.text_block} is also defined by '{report.shadowed_by}', which is "
|
|
106
|
+
f"HIGHER priority than '{target}' in Memoria.ini FolderNames -- the engine will show "
|
|
107
|
+
f"'{report.shadowed_by}'s text, not yours.{fix}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def check_text_block_shadows(game_dir, target_folder: str, text_blocks, lang: str = "us",
|
|
111
|
+
folder_names: list | None = None) -> list:
|
|
112
|
+
"""Batch :func:`check_text_block_shadow` -- which of ``text_blocks`` deployed into ``target_folder`` would be
|
|
113
|
+
SHADOWED by a higher-priority folder? Returns only the SHADOWED :class:`ShadowReport`\\ s (``[]`` => all
|
|
114
|
+
clear). Reads the ``FolderNames`` stack ONCE (vs once per block). Used by ``deploy_campaign`` to give every
|
|
115
|
+
member's text block the shadow guard a single-field ``deploy_field`` already runs. Degrades to ``[]`` when the
|
|
116
|
+
stack can't be read or the target isn't listed (no false alarm)."""
|
|
117
|
+
game_dir = Path(game_dir)
|
|
118
|
+
order = folder_names
|
|
119
|
+
if order is None:
|
|
120
|
+
ini = game_dir / "Memoria.ini"
|
|
121
|
+
order = parse_folder_names(ini.read_text(encoding="utf-8", errors="ignore")) if ini.is_file() else []
|
|
122
|
+
higher = order[:order.index(target_folder)] if target_folder in order else []
|
|
123
|
+
blocks_by_folder = {f: _blocks_in(game_dir, f, lang) for f in order}
|
|
124
|
+
higher_blocks = set().union(*(blocks_by_folder[f] for f in higher)) if higher else set()
|
|
125
|
+
valid = set().union(*blocks_by_folder.values()) if blocks_by_folder else set()
|
|
126
|
+
out: list = []
|
|
127
|
+
for tb in sorted({int(b) for b in text_blocks}):
|
|
128
|
+
shadowed_by = next((f for f in higher if tb in blocks_by_folder.get(f, set())), None)
|
|
129
|
+
if shadowed_by is not None:
|
|
130
|
+
suggestions = sorted(valid - higher_blocks - {tb})[:6]
|
|
131
|
+
out.append(ShadowReport(target_folder, tb, lang, shadowed_by, suggestions, order))
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def text_shadow_warning(reports: list, target_folder: str) -> str | None:
|
|
136
|
+
"""A human-readable multi-line warning for shadowed text blocks (the batch analogue of
|
|
137
|
+
:func:`shadow_warning`), or ``None`` when clear."""
|
|
138
|
+
if not reports:
|
|
139
|
+
return None
|
|
140
|
+
lines = [f"TEXT SHADOWED: {len(reports)} text block(s) this deploy puts in '{target_folder}' are ALSO "
|
|
141
|
+
f"defined by a HIGHER-priority Memoria.ini FolderNames folder -- the engine serves that folder's "
|
|
142
|
+
f".mes, not yours, so dialogue (incl. [[logic_edit]] text rewrites) shows the OLD text:"]
|
|
143
|
+
for r in reports:
|
|
144
|
+
alt = f" -- safe alt: text_block {r.suggestions[0]}" if r.suggestions else ""
|
|
145
|
+
lines.append(f" - block {r.text_block} shadowed by '{r.shadowed_by}'{alt}")
|
|
146
|
+
lines.append("Fix: deploy this campaign's folder HIGHER in Memoria.ini FolderNames, remove the higher folder's "
|
|
147
|
+
"copy of the block, or re-fork with a text_block no higher folder defines.")
|
|
148
|
+
return "\n".join(lines)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# Relative paths (within a mod folder) of the CSVs the engine reads HIGHEST-PRIORITY-WINS (whole-file): the
|
|
152
|
+
# starting bag (GetCsvWithHighestPriority) and the 99-row growth curve (Leveling.csv, ff9level.cs:53). A single
|
|
153
|
+
# folder's whole file wins outright -> a higher-priority stacked folder SHADOWS the lower one's copy entirely.
|
|
154
|
+
# The other CSVs (ShopItems / DefaultEquipment / BaseStats / Actions / StatusData) MERGE low->high by id, so a
|
|
155
|
+
# stacked folder only collides per-id, NOT whole-file -- they are deliberately NOT listed here.
|
|
156
|
+
_PRESET_STEMS = ("Zidane", "Vivi", "Garnet", "Steiner", "Freya", "Quina", "Eiko", "Amarant", "Cinna1", "Cinna2",
|
|
157
|
+
"Marcus1", "Marcus2", "Blank1", "Blank2", "Beatrix1", "Beatrix2", "StageZidane", "StageCinna",
|
|
158
|
+
"StageMarcus", "StageBlank")
|
|
159
|
+
HIGHEST_WINS_CSVS = ("StreamingAssets/Data/Items/InitialItems.csv",
|
|
160
|
+
"StreamingAssets/Data/Characters/Leveling.csv",
|
|
161
|
+
*(f"StreamingAssets/Data/Characters/Abilities/{s}.csv" for s in _PRESET_STEMS)) # [[learn]] lists
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def check_csv_shadow(game_dir, target_folder: str, csv_relpath: str,
|
|
165
|
+
folder_names: list | None = None) -> str | None:
|
|
166
|
+
"""Will a HIGHEST-PRIORITY-WINS CSV (``InitialItems.csv``) deployed into ``target_folder`` be SHADOWED by a
|
|
167
|
+
higher-priority mod folder that also ships it? Unlike a ``.mes`` (per-block) or a MERGED CSV (per-id), a
|
|
168
|
+
highest-wins WHOLE file is silently ignored if ANY higher folder ships its own copy -> the starting bag is
|
|
169
|
+
dropped with no error. Mirrors :func:`check_text_block_shadow`. Returns a one-line warning, or ``None`` when
|
|
170
|
+
clear / unreadable / the target isn't in the stack (no false alarm). The MERGED CSVs need no such check."""
|
|
171
|
+
game_dir = Path(game_dir)
|
|
172
|
+
order = folder_names
|
|
173
|
+
if order is None:
|
|
174
|
+
ini = game_dir / "Memoria.ini"
|
|
175
|
+
order = parse_folder_names(ini.read_text(encoding="utf-8", errors="ignore")) if ini.is_file() else []
|
|
176
|
+
if target_folder not in order:
|
|
177
|
+
return None
|
|
178
|
+
higher = order[:order.index(target_folder)]
|
|
179
|
+
rel = csv_relpath.replace("\\", "/")
|
|
180
|
+
shadowed_by = next((f for f in higher if (game_dir / f / rel).is_file()), None)
|
|
181
|
+
if shadowed_by is None:
|
|
182
|
+
return None
|
|
183
|
+
name = rel.rsplit("/", 1)[-1]
|
|
184
|
+
return (f"{name} SHADOWED: '{shadowed_by}' is HIGHER priority than '{target_folder}' in Memoria.ini "
|
|
185
|
+
f"FolderNames and also ships {name}, which is read HIGHEST-PRIORITY-WINS -- the engine uses "
|
|
186
|
+
f"'{shadowed_by}'s {name}, not yours (your starting bag is silently dropped). Deploy to the "
|
|
187
|
+
f"highest-priority folder, or remove the higher folder's {name}.")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---- cross-folder NAME-collision guard (scene/.eb files resolve BY NAME, highest-folder-wins) ----
|
|
191
|
+
# A field's scene (``FBG_*``) and event script (``EVT_*.eb.bytes``) are looked up by NAME, and -- exactly like
|
|
192
|
+
# the highest-wins CSV above -- the engine serves the copy from the FIRST FolderNames folder that has it. So two
|
|
193
|
+
# worktrees/campaigns that fork the SAME source field deploy IDENTICALLY-named FBG/EVT into different folders;
|
|
194
|
+
# the higher folder's (WRONG) fork wins -> a torn load / black screen. (This bit the Dali chain until
|
|
195
|
+
# ``import-chain --name-prefix`` namespaced every member.) This guard catches the collision at deploy time.
|
|
196
|
+
|
|
197
|
+
@dataclass
|
|
198
|
+
class NameCollision:
|
|
199
|
+
"""One scene/.eb name a deploy would put in ``target_folder`` that ANOTHER live FolderNames folder already
|
|
200
|
+
ships. ``kind`` is ``"eb"`` or ``"scene"``; ``name`` is the on-disk base name (``EVT_DC_DL_ENT`` /
|
|
201
|
+
``FBG_N11_DC_DL_ENT``). ``relation``: ``"shadows_us"`` (the other folder is higher priority -> it serves its
|
|
202
|
+
copy, ours is dead), ``"we_shadow"`` (we are higher -> we break the other), ``"ambiguous"`` (the target isn't
|
|
203
|
+
listed in FolderNames yet, so whichever order it lands in decides the winner)."""
|
|
204
|
+
kind: str
|
|
205
|
+
name: str
|
|
206
|
+
other_folder: str
|
|
207
|
+
relation: str
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def eb_names_at(root) -> set:
|
|
211
|
+
"""The EVT base names (``EVT_*.eb.bytes`` stems, extension stripped) a mod/dist root ships, across all langs."""
|
|
212
|
+
d = ModLayout(Path(root)).eventbinary_field_dir
|
|
213
|
+
return {p.name[:-len(".eb.bytes")] for p in d.rglob("*.eb.bytes")} if d.is_dir() else set()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def scene_names_at(root) -> set:
|
|
217
|
+
"""The FBG scene-dir names a mod/dist root ships."""
|
|
218
|
+
d = ModLayout(Path(root)).fieldmaps_dir
|
|
219
|
+
return {p.name for p in d.iterdir() if p.is_dir()} if d.is_dir() else set()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def check_name_collisions(game_dir, target_folder: str, eb_names, scene_names,
|
|
223
|
+
folder_names: list | None = None) -> list:
|
|
224
|
+
"""Do any EVT/.eb or FBG-scene names a deploy puts in ``target_folder`` collide (same name) with one a
|
|
225
|
+
DIFFERENT live FolderNames folder already ships? Returns a list of :class:`NameCollision` (``[]`` => clear).
|
|
226
|
+
Reads ``Memoria.ini`` ``FolderNames`` (unless ``folder_names`` is passed); degrades to ``[]`` when the stack
|
|
227
|
+
can't be read. The TARGET folder is EXCLUDED -- a redeploy of the same campaign replaces its own files in
|
|
228
|
+
place, which is not a collision. Only folders actually in the stack are checked (others aren't loaded)."""
|
|
229
|
+
game_dir = Path(game_dir)
|
|
230
|
+
order = folder_names
|
|
231
|
+
if order is None:
|
|
232
|
+
ini = game_dir / "Memoria.ini"
|
|
233
|
+
order = parse_folder_names(ini.read_text(encoding="utf-8", errors="ignore")) if ini.is_file() else []
|
|
234
|
+
others = [f for f in order if f != target_folder]
|
|
235
|
+
if not others:
|
|
236
|
+
return []
|
|
237
|
+
ti = order.index(target_folder) if target_folder in order else None
|
|
238
|
+
want = {"eb": set(eb_names), "scene": set(scene_names)}
|
|
239
|
+
out: list = []
|
|
240
|
+
for f in others:
|
|
241
|
+
have = {"eb": eb_names_at(game_dir / f), "scene": scene_names_at(game_dir / f)}
|
|
242
|
+
rel = "ambiguous" if ti is None else ("shadows_us" if order.index(f) < ti else "we_shadow")
|
|
243
|
+
for kind in ("eb", "scene"):
|
|
244
|
+
for nm in sorted(want[kind] & have[kind]):
|
|
245
|
+
out.append(NameCollision(kind, nm, f, rel))
|
|
246
|
+
return out
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def name_collision_warning(collisions: list, target_folder: str) -> str | None:
|
|
250
|
+
"""A human-readable multi-line warning for cross-folder name collisions, or ``None`` when clear."""
|
|
251
|
+
if not collisions:
|
|
252
|
+
return None
|
|
253
|
+
by_folder: dict = {}
|
|
254
|
+
for c in collisions:
|
|
255
|
+
by_folder.setdefault(c.other_folder, []).append(c)
|
|
256
|
+
rel_tag = {
|
|
257
|
+
"shadows_us": "is HIGHER priority -> it shadows YOURS (your fields won't load)",
|
|
258
|
+
"we_shadow": "is LOWER priority -> YOURS shadows it (you break that campaign)",
|
|
259
|
+
"ambiguous": f"is in the stack ('{target_folder}' isn't listed yet -> FolderNames order decides)",
|
|
260
|
+
}
|
|
261
|
+
lines = [f"NAME COLLISION: {len(collisions)} scene/.eb name(s) this deploy puts in '{target_folder}' are "
|
|
262
|
+
f"ALSO shipped by another Memoria.ini FolderNames folder -- these resolve BY NAME, "
|
|
263
|
+
f"highest-folder-wins, so the WRONG fork loads (a silent shadow -> torn load / black screen):"]
|
|
264
|
+
for f, cs in by_folder.items():
|
|
265
|
+
names = ", ".join(c.name for c in cs[:8]) + (" ..." if len(cs) > 8 else "")
|
|
266
|
+
lines.append(f" - vs '{f}' ({rel_tag[cs[0].relation]}): {names}")
|
|
267
|
+
lines.append("Fix: re-fork the chain with a campaign-unique prefix -- `ff9mapkit import-chain <seed> "
|
|
268
|
+
"--name-prefix <TAG>` -- so every FBG/EVT name is globally unique; or drop the colliding "
|
|
269
|
+
"folder from Memoria.ini FolderNames.")
|
|
270
|
+
return "\n".join(lines)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ---- cross-folder ID-collision guard (FF9DBAll.EventDB[id] is GLOBAL across folders) ----
|
|
274
|
+
# A field/battle id is the KEY into the global ``FF9DBAll.EventDB`` (id -> eb-name), which DataPatchers populates
|
|
275
|
+
# from EVERY FolderNames folder's ``DictionaryPatch.txt`` at boot. Two folders that register the SAME id collide:
|
|
276
|
+
# ``EventDB[id]`` ends up holding ONE name, so the OTHER registration's field/battle loads the WRONG ``.eb`` ->
|
|
277
|
+
# ``loadEventData`` null -> ``EventEngine.StartEvents(ebFileData=null)`` -> black screen. This cost a multi-hour
|
|
278
|
+
# debug: ``-ate``'s ``FieldScene 30011 TEST30011`` collided with ``-bb``'s ``BattleScene 30011 CAMKEYS``, so
|
|
279
|
+
# warping to field 30011 tried to load ``EVT_BATTLE_CAMKEYS`` from the FIELD path (not there) -> null. Because the
|
|
280
|
+
# names DIFFER (``TEST30011`` vs ``CAMKEYS``), the NAME guard above does NOT catch it -- this id guard does.
|
|
281
|
+
# NOTE: which registration "wins" ``EventDB[id]`` is DataPatchers processing-order dependent (in the 30011 case
|
|
282
|
+
# the battle won despite ``-bb`` being a higher-priority folder), so EITHER side can break -> flag ANY collision
|
|
283
|
+
# and don't assert a winner. (Memory: project-ff9-eventdb-id-collision.)
|
|
284
|
+
|
|
285
|
+
@dataclass
|
|
286
|
+
class IdCollision:
|
|
287
|
+
"""One field/scene id a deploy registers that ANOTHER live FolderNames folder's ``DictionaryPatch.txt``
|
|
288
|
+
already uses. ``other_kind`` is ``"FieldScene"``/``"BattleScene"``; ``other_name`` is that line's MAPID /
|
|
289
|
+
scene name (for the message)."""
|
|
290
|
+
field_id: int
|
|
291
|
+
other_folder: str
|
|
292
|
+
other_kind: str
|
|
293
|
+
other_name: str
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def dictionary_ids_at(root) -> dict:
|
|
297
|
+
"""Map ``id -> (kind, name)`` for every ``FieldScene``/``BattleScene`` line in a mod/dist root's
|
|
298
|
+
``DictionaryPatch.txt`` (``{}`` if absent/unreadable). ``kind`` = the leading token; ``name`` = the MAPID
|
|
299
|
+
(``FieldScene`` field 3) or scene name (``BattleScene`` field 2). On a duplicate id within one file the last
|
|
300
|
+
line wins (mirrors the engine's last-writer-wins)."""
|
|
301
|
+
out: dict = {}
|
|
302
|
+
p = Path(root) / "DictionaryPatch.txt"
|
|
303
|
+
if not p.is_file():
|
|
304
|
+
return out
|
|
305
|
+
for line in p.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
306
|
+
parts = line.split()
|
|
307
|
+
if len(parts) < 2 or parts[0] not in ("FieldScene", "BattleScene"):
|
|
308
|
+
continue
|
|
309
|
+
try:
|
|
310
|
+
fid = int(parts[1])
|
|
311
|
+
except ValueError:
|
|
312
|
+
continue
|
|
313
|
+
# the human name sits at a DIFFERENT field by kind: FieldScene <id> <area> <MAPID> <NAME> <txt> (field 3
|
|
314
|
+
# = MAPID); BattleScene <id> <NAME> <BBG> (field 2 = scene name).
|
|
315
|
+
name = (parts[3] if len(parts) > 3 else "") if parts[0] == "FieldScene" else (parts[2] if len(parts) > 2 else "")
|
|
316
|
+
out[fid] = (parts[0], name)
|
|
317
|
+
return out
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def check_id_collisions(game_dir, target_folder: str, ids, folder_names: list | None = None) -> list:
|
|
321
|
+
"""Do any field/scene ``ids`` a deploy registers into ``target_folder`` collide with an id ANOTHER live
|
|
322
|
+
FolderNames folder's ``DictionaryPatch`` already uses? (``EventDB`` is GLOBAL -> a shared id makes one side
|
|
323
|
+
load the wrong ``.eb`` -> black screen.) Returns a list of :class:`IdCollision` (``[]`` => clear). Reads
|
|
324
|
+
``Memoria.ini`` ``FolderNames`` (unless ``folder_names`` passed); degrades to ``[]`` when unreadable. The
|
|
325
|
+
TARGET folder is EXCLUDED (a redeploy reuses its own id, not a collision); only folders actually in the stack
|
|
326
|
+
are checked (others aren't loaded). Distinct from :func:`check_name_collisions` -- that catches same-NAME
|
|
327
|
+
files; this catches same-ID registrations whose names may DIFFER (the case that guard misses)."""
|
|
328
|
+
game_dir = Path(game_dir)
|
|
329
|
+
order = folder_names
|
|
330
|
+
if order is None:
|
|
331
|
+
ini = game_dir / "Memoria.ini"
|
|
332
|
+
order = parse_folder_names(ini.read_text(encoding="utf-8", errors="ignore")) if ini.is_file() else []
|
|
333
|
+
others = [f for f in order if f != target_folder]
|
|
334
|
+
if not others:
|
|
335
|
+
return []
|
|
336
|
+
want = sorted({int(i) for i in ids})
|
|
337
|
+
out: list = []
|
|
338
|
+
for f in others:
|
|
339
|
+
their = dictionary_ids_at(game_dir / f)
|
|
340
|
+
for i in want:
|
|
341
|
+
if i in their:
|
|
342
|
+
kind, name = their[i]
|
|
343
|
+
out.append(IdCollision(i, f, kind, name))
|
|
344
|
+
return out
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def id_collision_warning(collisions: list, target_folder: str) -> str | None:
|
|
348
|
+
"""A human-readable multi-line warning for cross-folder id collisions, or ``None`` when clear."""
|
|
349
|
+
if not collisions:
|
|
350
|
+
return None
|
|
351
|
+
lines = [f"ID COLLISION: {len(collisions)} id(s) this deploy registers in '{target_folder}' are ALSO used by "
|
|
352
|
+
f"another Memoria.ini FolderNames folder. FF9DBAll.EventDB is GLOBAL across folders, so a shared id "
|
|
353
|
+
f"maps to ONE .eb -- one side loads the WRONG script (null .eb -> StartEvents(null) -> black screen):"]
|
|
354
|
+
for c in collisions:
|
|
355
|
+
lines.append(f" - id {c.field_id} vs '{c.other_folder}' ({c.other_kind} '{c.other_name}')")
|
|
356
|
+
lines.append("Fix: use an id no other stacked folder registers -- e.g. your worktree's `.ff9deploy.toml` "
|
|
357
|
+
"scratch/campaign band. (Diagnose: grep -rn '<id>' FF9CustomMap*/DictionaryPatch.txt.)")
|
|
358
|
+
return "\n".join(lines)
|