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.
Files changed (193) hide show
  1. ff9mapkit/__init__.py +18 -0
  2. ff9mapkit/__main__.py +36 -0
  3. ff9mapkit/_animdb.py +2994 -0
  4. ff9mapkit/_animdb_all.py +14125 -0
  5. ff9mapkit/_fieldtable.py +1516 -0
  6. ff9mapkit/_fieldtext.py +845 -0
  7. ff9mapkit/_held_poses.py +44 -0
  8. ff9mapkit/_itemdb.py +65 -0
  9. ff9mapkit/_modeldb.py +725 -0
  10. ff9mapkit/_narrowmap_data.py +10 -0
  11. ff9mapkit/_npcparams.py +634 -0
  12. ff9mapkit/_regen_animdb.py +72 -0
  13. ff9mapkit/_regen_animdb_all.py +66 -0
  14. ff9mapkit/_regen_fieldtable.py +95 -0
  15. ff9mapkit/_regen_fieldtext.py +66 -0
  16. ff9mapkit/_regen_modeldb.py +67 -0
  17. ff9mapkit/_regen_npcparams.py +123 -0
  18. ff9mapkit/_regen_scenedb.py +57 -0
  19. ff9mapkit/_scenedb.py +869 -0
  20. ff9mapkit/abilities.py +225 -0
  21. ff9mapkit/animations.py +120 -0
  22. ff9mapkit/archetypes.py +218 -0
  23. ff9mapkit/areatitle.py +76 -0
  24. ff9mapkit/battle/__init__.py +21 -0
  25. ff9mapkit/battle/abilityfeatures.py +294 -0
  26. ff9mapkit/battle/actiondelta.py +441 -0
  27. ff9mapkit/battle/aiauthor.py +305 -0
  28. ff9mapkit/battle/ailint.py +140 -0
  29. ff9mapkit/battle/aipatch.py +175 -0
  30. ff9mapkit/battle/battleai.py +148 -0
  31. ff9mapkit/battle/battlecsv.py +390 -0
  32. ff9mapkit/battle/battlepatch.py +395 -0
  33. ff9mapkit/battle/build.py +558 -0
  34. ff9mapkit/battle/camera_codec.py +332 -0
  35. ff9mapkit/battle/camera_data.py +128 -0
  36. ff9mapkit/battle/characterdelta.py +789 -0
  37. ff9mapkit/battle/event_data.py +72 -0
  38. ff9mapkit/battle/extract.py +540 -0
  39. ff9mapkit/battle/fbx.py +223 -0
  40. ff9mapkit/battle/reskin.py +149 -0
  41. ff9mapkit/battle/scene_codec.py +314 -0
  42. ff9mapkit/battle/scene_data.py +369 -0
  43. ff9mapkit/battle/scenelint.py +125 -0
  44. ff9mapkit/battle/seqasm.py +131 -0
  45. ff9mapkit/battle/seqauthor.py +220 -0
  46. ff9mapkit/battle/seqcodec.py +300 -0
  47. ff9mapkit/battle/seqdis.py +106 -0
  48. ff9mapkit/battle/seqpatch.py +137 -0
  49. ff9mapkit/battle_bgm.py +133 -0
  50. ff9mapkit/binutils.py +60 -0
  51. ff9mapkit/build.py +5445 -0
  52. ff9mapkit/campaign.py +1276 -0
  53. ff9mapkit/catalog.py +316 -0
  54. ff9mapkit/chain.py +358 -0
  55. ff9mapkit/cli.py +3114 -0
  56. ff9mapkit/config.py +360 -0
  57. ff9mapkit/content/__init__.py +13 -0
  58. ff9mapkit/content/areatitle.py +36 -0
  59. ff9mapkit/content/ate.py +118 -0
  60. ff9mapkit/content/camera.py +123 -0
  61. ff9mapkit/content/chest.py +186 -0
  62. ff9mapkit/content/choice.py +163 -0
  63. ff9mapkit/content/conductor.py +217 -0
  64. ff9mapkit/content/cutscene.py +290 -0
  65. ff9mapkit/content/encounter.py +41 -0
  66. ff9mapkit/content/entry_settle.py +50 -0
  67. ff9mapkit/content/equipment.py +93 -0
  68. ff9mapkit/content/event.py +191 -0
  69. ff9mapkit/content/gateway.py +101 -0
  70. ff9mapkit/content/inventory.py +59 -0
  71. ff9mapkit/content/itemdata.py +644 -0
  72. ff9mapkit/content/itemtext.py +168 -0
  73. ff9mapkit/content/jump.py +114 -0
  74. ff9mapkit/content/ladder.py +633 -0
  75. ff9mapkit/content/movement.py +53 -0
  76. ff9mapkit/content/music.py +97 -0
  77. ff9mapkit/content/npc.py +348 -0
  78. ff9mapkit/content/object.py +340 -0
  79. ff9mapkit/content/onentry.py +135 -0
  80. ff9mapkit/content/party.py +111 -0
  81. ff9mapkit/content/pathfind.py +138 -0
  82. ff9mapkit/content/platform.py +314 -0
  83. ff9mapkit/content/player.py +168 -0
  84. ff9mapkit/content/prop.py +75 -0
  85. ff9mapkit/content/region.py +340 -0
  86. ff9mapkit/content/reinit.py +59 -0
  87. ff9mapkit/content/savepoint.py +90 -0
  88. ff9mapkit/content/shop.py +178 -0
  89. ff9mapkit/content/sps_trigger.py +66 -0
  90. ff9mapkit/content/startup.py +71 -0
  91. ff9mapkit/content/synthesis.py +106 -0
  92. ff9mapkit/content/text.py +183 -0
  93. ff9mapkit/content/textcarry.py +290 -0
  94. ff9mapkit/content/verbatim.py +86 -0
  95. ff9mapkit/content/walkmesh_hotfix.py +38 -0
  96. ff9mapkit/data/__init__.py +48 -0
  97. ff9mapkit/data/_regen_provenance.py +142 -0
  98. ff9mapkit/data/provenance/blank.es.patch +1 -0
  99. ff9mapkit/data/provenance/blank.fr.patch +1 -0
  100. ff9mapkit/data/provenance/blank.gr.patch +1 -0
  101. ff9mapkit/data/provenance/blank.it.patch +1 -0
  102. ff9mapkit/data/provenance/blank.jp.patch +1 -0
  103. ff9mapkit/data/provenance/blank.uk.patch +1 -0
  104. ff9mapkit/data/provenance/blank.us.patch +1 -0
  105. ff9mapkit/data/provenance/manifest.json +65 -0
  106. ff9mapkit/data/provenance/region_template.patch +1 -0
  107. ff9mapkit/data/reference_arcs.toml +89 -0
  108. ff9mapkit/data/region_catalog.toml +593 -0
  109. ff9mapkit/deploystack.py +358 -0
  110. ff9mapkit/dialogue.py +803 -0
  111. ff9mapkit/eb/__init__.py +12 -0
  112. ff9mapkit/eb/_exprtable.py +59 -0
  113. ff9mapkit/eb/_membertable.py +38 -0
  114. ff9mapkit/eb/_optables.py +537 -0
  115. ff9mapkit/eb/_regen_optables.py +76 -0
  116. ff9mapkit/eb/cmdasm.py +323 -0
  117. ff9mapkit/eb/disasm.py +332 -0
  118. ff9mapkit/eb/edit.py +439 -0
  119. ff9mapkit/eb/exprasm.py +158 -0
  120. ff9mapkit/eb/model.py +178 -0
  121. ff9mapkit/eb/opcodes.py +463 -0
  122. ff9mapkit/eblint.py +177 -0
  123. ff9mapkit/editor/__init__.py +20 -0
  124. ff9mapkit/editor/app.py +950 -0
  125. ff9mapkit/editor/battle_forms.py +240 -0
  126. ff9mapkit/editor/breadcrumb.py +89 -0
  127. ff9mapkit/editor/dialogs.py +116 -0
  128. ff9mapkit/editor/feedback.py +208 -0
  129. ff9mapkit/editor/forms.py +632 -0
  130. ff9mapkit/editor/graphview.py +350 -0
  131. ff9mapkit/editor/jobs.py +342 -0
  132. ff9mapkit/editor/model.py +243 -0
  133. ff9mapkit/editor/picker.py +120 -0
  134. ff9mapkit/editor/theme.py +212 -0
  135. ff9mapkit/eventscan.py +1441 -0
  136. ff9mapkit/extract.py +2279 -0
  137. ff9mapkit/flags.py +693 -0
  138. ff9mapkit/forkreport.py +1383 -0
  139. ff9mapkit/hub.py +477 -0
  140. ff9mapkit/idgated.py +101 -0
  141. ff9mapkit/infohub.py +580 -0
  142. ff9mapkit/items.py +63 -0
  143. ff9mapkit/itemstats.py +346 -0
  144. ff9mapkit/journey.py +1902 -0
  145. ff9mapkit/keyitems.py +93 -0
  146. ff9mapkit/logic_add.py +632 -0
  147. ff9mapkit/logic_edit.py +728 -0
  148. ff9mapkit/logic_map.py +526 -0
  149. ff9mapkit/pack.py +175 -0
  150. ff9mapkit/playerswap.py +231 -0
  151. ff9mapkit/prop_archetypes.py +228 -0
  152. ff9mapkit/provision.py +282 -0
  153. ff9mapkit/refarc.py +825 -0
  154. ff9mapkit/save.py +337 -0
  155. ff9mapkit/save_items.py +1673 -0
  156. ff9mapkit/scene/__init__.py +11 -0
  157. ff9mapkit/scene/arena.py +63 -0
  158. ff9mapkit/scene/bgart.py +140 -0
  159. ff9mapkit/scene/bgi.py +732 -0
  160. ff9mapkit/scene/bgs.py +174 -0
  161. ff9mapkit/scene/bgx.py +185 -0
  162. ff9mapkit/scene/cam.py +345 -0
  163. ff9mapkit/scene/guide.py +311 -0
  164. ff9mapkit/scene/paint.py +506 -0
  165. ff9mapkit/scene/placeholder.py +107 -0
  166. ff9mapkit/sjbinary.py +285 -0
  167. ff9mapkit/sps/__init__.py +17 -0
  168. ff9mapkit/sps/author.py +294 -0
  169. ff9mapkit/sps/catalog.py +88 -0
  170. ff9mapkit/sps/codec.py +264 -0
  171. ff9mapkit/sps/edit.py +184 -0
  172. ff9mapkit/sps/lint.py +58 -0
  173. ff9mapkit/sps/render.py +116 -0
  174. ff9mapkit/sps/templates.py +47 -0
  175. ff9mapkit/sps/texture.py +131 -0
  176. ff9mapkit/walkmesh_hotfixes.py +163 -0
  177. ff9mapkit/workspace/__init__.py +18 -0
  178. ff9mapkit/workspace/battledoc.py +985 -0
  179. ff9mapkit/workspace/builddoc.py +607 -0
  180. ff9mapkit/workspace/forms_qt.py +586 -0
  181. ff9mapkit/workspace/importdoc.py +665 -0
  182. ff9mapkit/workspace/mapview.py +131 -0
  183. ff9mapkit/workspace/palette.py +85 -0
  184. ff9mapkit/workspace/savedoc.py +664 -0
  185. ff9mapkit/workspace/shell.py +6907 -0
  186. ff9mapkit/workspace/style.py +105 -0
  187. ff9mapkit/workspace/tuningdialog.py +223 -0
  188. ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
  189. ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
  190. ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
  191. ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
  192. ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
  193. ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
@@ -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)