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
ff9mapkit/campaign.py ADDED
@@ -0,0 +1,1276 @@
1
+ """import-chain (P2): fork a walked region into a campaign -- N retargeted field.tomls + a campaign.toml.
2
+
3
+ P1 (chain.py) walks the door graph and returns a GraphResult. P2 turns that into an authorable, buildable
4
+ campaign: it assigns each forkable member a new id (id_base + i, BFS order), forks each real field, and --
5
+ the load-bearing step -- RETARGETS every in-chain gateway so it points at the chain's own new id instead of
6
+ the live game's. Out-of-chain / scripted / overworld / menu connections are recorded in campaign.toml as
7
+ [[seam]]s (NOT live gateways -- a live door to a real id would warp the player back into the live game and
8
+ can crash, e.g. field 100). Build-all/deploy-all/flag-allocation are later phases (P3/P4/P5).
9
+
10
+ The retarget itself happens at extract._imported_content_toml's single gateway-emit site via the threaded
11
+ ``id_remap`` kwarg; this module orchestrates the id assignment, per-member fork loop, and manifest render.
12
+
13
+ Each member lands in its OWN subdir (camera.bgx/walkmesh.bgi have fixed names and would otherwise collide):
14
+ <out>/IC_ENT/IC_ENT.field.toml + camera.bgx + walkmesh.bgi
15
+ <out>/campaign.toml (references "IC_ENT/IC_ENT.field.toml")
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ import tomllib
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+
25
+ from . import chain
26
+ # Safe GLOB story-flag allocation band -- single source of truth in ``flags`` (grounded in
27
+ # research/STORY_FLAGS.md §4, a 676-field census): real FF9 uses bit-flags up to 8511 (the treasure-chest
28
+ # bitfield 8376-8511); the choice scratch is at bit 16320+; custom flags live in [8512, 16320). The old
29
+ # flag_base=8300 + 64/field collided with the chest block from member index 1 onward.
30
+ from .flags import (CHEST_FLAG_HI, CHEST_FLAG_LO, CHOICE_SCRATCH_FLOOR, FIRST_SAFE_FLAG,
31
+ collect_flag_defs, resolve_project_flags)
32
+
33
+ _MAP_SEG = re.compile(r"^map\d", re.I) # the 'map<NNN>' segment of an FBG folder
34
+
35
+
36
+ class CampaignError(ValueError):
37
+ """A campaign manifest / build-all problem (caught + printed by the CLI)."""
38
+
39
+
40
+ @dataclass
41
+ class Member:
42
+ real_id: int
43
+ new_id: int
44
+ name: str # IC_ENT, ...
45
+ mode: str # "borrow" (area>=10) | "native" (area<10 fork) | "editable" (blank room)
46
+ src_area: int
47
+ folder: str # ID_TO_FBG[real_id]
48
+ toml_rel: str # "IC_ENT/IC_ENT.field.toml"
49
+ needs_export: bool # member with no usable background art -> a logic-only stub
50
+
51
+
52
+ @dataclass
53
+ class CampaignPlan:
54
+ name: str
55
+ mod_folder: str
56
+ id_base: int
57
+ flag_base: int
58
+ flags_per_field: int
59
+ entry_name: str
60
+ entry_entrance: int
61
+ members: "list[Member]" = field(default_factory=list)
62
+ edges: list = field(default_factory=list) # {frm, to, entrance, story_conditional}
63
+ seams: list = field(default_factory=list) # {frm, to_real, kind, note, to_member?}
64
+ flags: list = field(default_factory=list) # [[flag]] shared named flags: {name, index} (cross-field)
65
+ verbatim: bool = False # forked with --verbatim: every member ships its donor's WHOLE
66
+ # .eb, so a story-conditional stacked door is carried + resolved
67
+ # by the engine at runtime (NOT re-authored from [[edge]]s).
68
+
69
+ @property
70
+ def needs_export(self):
71
+ return [m.name for m in self.members if m.needs_export]
72
+
73
+
74
+ def member_name(folder: str, idx: int, taken: set, prefix: str = "") -> str:
75
+ """Deterministic, collision-safe member name from an FBG folder. ``fbg_n05_iccv_map085_ic_ent_0`` ->
76
+ ``IC_ENT`` (the segments after ``map<NNN>``, trailing variant digit dropped). Collisions get a zone
77
+ prefix then a numeric suffix. Falls back to ``<ZONE>_<idx>`` when the folder has no map segment."""
78
+ parts = folder.split("_")
79
+ mi = next((i for i, p in enumerate(parts) if _MAP_SEG.match(p)), None)
80
+ tail = parts[mi + 1:] if mi is not None else []
81
+ if tail and tail[-1].isdigit(): # drop the trailing variant index (..._0 / ..._4)
82
+ tail = tail[:-1]
83
+ base = "_".join(tail).upper() if tail else f"{chain.zone_label(folder).upper()}_{idx:03d}"
84
+ nm = base
85
+ if nm in taken:
86
+ nm = f"{chain.zone_label(folder).upper()}_{base}"
87
+ k = 2
88
+ while nm in taken:
89
+ nm = f"{base}_{k}"
90
+ k += 1
91
+ taken.add(nm)
92
+ # A campaign-unique PREFIX makes the deployed FBG_N<area>_<NAME> + EVT_<NAME> globally unique, so two
93
+ # campaigns/worktrees that fork the SAME source field don't collide on the by-NAME, highest-folder-wins
94
+ # scene/.eb file resolution (a stacked-FolderNames shadow that serves the WRONG fork -> torn load).
95
+ pfx = prefix.strip().upper().rstrip("_")
96
+ return f"{pfx}_{nm}" if pfx else nm
97
+
98
+
99
+ def assign_ids(result, *, id_base: int, name_prefix: str = "", prior=None, reserved_ids=None):
100
+ """(members_ids, new_id, name_of) for the FORKABLE nodes of a walk, in BFS discovery order.
101
+
102
+ Without ``prior`` (a fresh fork): ``members_ids[i] -> id_base + i``; ``name_of[real]`` is the unique
103
+ member name (``name_prefix`` namespaces it globally so cross-campaign/cross-worktree forks of the same
104
+ field don't collide on the deployed names).
105
+
106
+ With ``prior`` (a ``{source_real_id: (fork_id, member_name)}`` map from an existing campaign.toml --
107
+ STABLE-ID mode, the save-survives-a-re-fork path): a re-discovered donor keeps its EXACT prior fork-id +
108
+ name, and a net-NEW donor is APPENDED at the next id ABOVE every prior id (never reusing a prior id, so a
109
+ stale save can't land on the wrong field; the append-above-max rule also keeps every prior member's
110
+ POSITION when the caller emits members id-sorted -> its position-based flag window is stable too). Names
111
+ stay stable: prior names are reused verbatim and new names are disambiguated against them. ``reserved_ids``
112
+ (the new ids of EVERY prior member, including source-less / hand-appended ones NOT in ``prior``) are
113
+ protected from re-allocation so a net-new donor can never collide with one. ``prior={}`` + no
114
+ ``reserved_ids`` reproduces the original index-based allocation byte-for-byte."""
115
+ from . import extract
116
+ members_ids = [fid for fid, info in result.nodes.items() if info.get("found")]
117
+ prior = prior or {}
118
+ taken: set = {pname for (_pid, pname) in prior.values()} # a new name can't collide with a reused one
119
+ used: set = {pid for (pid, _pname) in prior.values()} | set(reserved_ids or ()) # every prior id is off-limits
120
+ cursor = max([id_base - 1, *used]) + 1 # net-new members append above EVERY prior id
121
+ new_id: dict = {}
122
+ name_of: dict = {}
123
+ for i, real in enumerate(members_ids):
124
+ if real in prior: # re-discovered: freeze its id + name
125
+ new_id[real], name_of[real] = prior[real]
126
+ else: # net-new donor: a fresh non-colliding id
127
+ while cursor in used:
128
+ cursor += 1
129
+ new_id[real] = cursor
130
+ used.add(cursor)
131
+ cursor += 1
132
+ name_of[real] = member_name(extract.ID_TO_FBG[real], i, taken, name_prefix)
133
+ return members_ids, new_id, name_of
134
+
135
+
136
+ def _emit_logic_only_member(folder, member_dir, name, field_id, id_remap, live_seams, game):
137
+ """An editable member whose art was never [Export]'d: still emit camera.bgx + walkmesh.bgi (offline)
138
+ and a logic-only field.toml (retargeted gateways, NO [[layers]]) so the campaign STRUCTURE is complete.
139
+ The human exports the art in-game later, then re-forks with --editable to add the repaintable layers."""
140
+ from . import extract
141
+ meta = extract.extract_field(folder, member_dir, game=game) # camera.bgx + walkmesh.bgi
142
+ safe_area = extract.safe_custom_area(meta["area"])
143
+ content_blocks, control_dir, summary = extract._content_for_import(
144
+ folder, game, out_dir=member_dir, name=name, id_remap=id_remap, live_seams=live_seams)
145
+ meta["imported_content"] = summary
146
+ cm = meta["camera"]
147
+ x, z = meta["player_start"]
148
+ scroll = "[camera.scroll]\nenabled = true\n" if meta["scrolling"] else ""
149
+ control_line = f"control_direction = {control_dir}\n" if control_dir is not None else ""
150
+ toml = (
151
+ f"# EDITABLE member (logic + camera + walkmesh) of {meta['field']} (source area {meta['area']}).\n"
152
+ f"# !! NEEDS ART: export this field in-game once (Memoria.ini [Export] Field=1), then re-run\n"
153
+ f"# `ff9mapkit import {folder} --editable` to add the repaintable layer_*.png. The gateways,\n"
154
+ f"# walkmesh and camera here are correct + retargeted; only the background art is missing.\n"
155
+ f"# Camera: pitch {cm['pitch_deg']} deg, FOV {cm['fov_deg']} deg.\n\n"
156
+ f"[field]\nid = {field_id}\nname = \"{name}\"\narea = {safe_area}\ntext_block = 1073\n\n"
157
+ f"[camera]\nborrow = \"camera.bgx\"\n{control_line}{scroll}\n"
158
+ f"[walkmesh]\nbgi = \"walkmesh.bgi\"\n\n"
159
+ f"[player]\nspawn = [{x}, {z}]\n\n"
160
+ f"{extract._content_section(content_blocks, x, z)}"
161
+ )
162
+ p = Path(member_dir) / f"{name}.field.toml"
163
+ p.write_text(toml, encoding="utf-8", newline="\n")
164
+ meta["field_toml"] = str(p)
165
+ return meta, p
166
+
167
+
168
+ def _dedup(rows, keys):
169
+ seen, out = set(), []
170
+ for r in rows:
171
+ k = tuple(r.get(x) for x in keys)
172
+ if k not in seen:
173
+ seen.add(k)
174
+ out.append(r)
175
+ return out
176
+
177
+
178
+ def _collect_edges_seams(result, members_ids, new_id, name_of):
179
+ """Build the [[edge]] (in-chain walk-in) + [[seam]] (everything else) rows for the manifest."""
180
+ inchain = set(members_ids)
181
+ edges = []
182
+ for real, info in result.nodes.items():
183
+ if not info.get("found") or real not in inchain:
184
+ continue
185
+ # group walk-in edges by zone polygon to detect story-conditional stacked exits
186
+ groups: dict = {}
187
+ for e in info["edges"]:
188
+ if e["kind"] == chain.WALK_IN:
189
+ groups.setdefault(tuple(map(tuple, e.get("zone") or [])), []).append(e)
190
+ for e in info["edges"]:
191
+ if e["kind"] != chain.WALK_IN:
192
+ continue
193
+ to = int(e["to"])
194
+ if to not in inchain:
195
+ continue # out-of-chain -> a seam (below), not an edge
196
+ grp = groups[tuple(map(tuple, e.get("zone") or []))]
197
+ cond = bool(e.get("story_conditional")) and sum(1 for x in grp if int(x["to"]) in inchain) >= 2
198
+ edges.append({"frm": name_of[real], "to": name_of[to],
199
+ "entrance": int(e.get("entrance", 0)), "story_conditional": cond})
200
+
201
+ seams = []
202
+ for s in result.seams: # scripted / teleport warps
203
+ to = int(s["to"])
204
+ seams.append({"frm": name_of.get(s["from"], str(s["from"])), "to_real": to, "kind": "scripted",
205
+ "note": f"trigger:{s.get('trigger')}", "to_member": name_of.get(to)})
206
+ for real, info in result.nodes.items(): # overworld exits
207
+ if info.get("overworld_exits"):
208
+ seams.append({"frm": name_of.get(real, str(real)), "to_real": "WORLDMAP", "kind": "overworld",
209
+ "note": f"{len(info['overworld_exits'])} WorldMap op(s)"})
210
+ for p in result.portals: # out-of-scope walk-in targets
211
+ seams.append({"frm": name_of.get(p["from"], str(p["from"])), "to_real": int(p["to"]),
212
+ "kind": "portal", "note": f"zone {p.get('to_zone')}; {p.get('reason')}",
213
+ "to_member": name_of.get(int(p["to"]))})
214
+ for u in result.unforkable: # shop/menu/variant targets (no background)
215
+ seams.append({"frm": name_of.get(u["from"], str(u["from"])), "to_real": int(u["to"]),
216
+ "kind": "menu", "note": "no background (shop/menu/variant)"})
217
+ # dedup: a scripted warp can recur across cutscene variants; a double-door is one edge
218
+ return _dedup(edges, ("frm", "to", "entrance")), _dedup(seams, ("frm", "to_real", "kind"))
219
+
220
+
221
+ def write_campaign(result, out_dir, *, id_base=6000, flag_base=FIRST_SAFE_FLAG, flags_per_field=64,
222
+ name: str, mod_folder: str, game=None, live_seams=False,
223
+ entry_entrance=0, verbatim=False, swap_player=None,
224
+ neutralize_gestures=False, name_prefix="", prior_plan=None) -> CampaignPlan:
225
+ """Fork the walk into ``out_dir``: a per-member subdir each + a top-level campaign.toml. Returns the
226
+ CampaignPlan. Members in area>=10 BG-borrow; area<10 members fork as a NATIVE scene (own atlas+.bgs, no
227
+ .bgx -- seamless, no in-game export needed). Both are fully offline; a field with no usable background
228
+ atlas degrades to a logic-only stub (camera+walkmesh+retargeted gateways) flagged needs_export.
229
+
230
+ ``verbatim`` (the MOST faithful chain -- docs/FORK_FIDELITY.md): fork EVERY member native + VERBATIM --
231
+ each ships its donor's WHOLE event script (entry-0 + objects + gateways, run as-is) with the in-chain
232
+ ``Field()`` exits retargeted to this chain's own member ids, plus the donor's whole ``.mes`` at the
233
+ donor's OWN registered textid (``EVENT_ID_TO_MES`` -- a valid MesDB key, so the FieldScene registers;
234
+ same-zone members share it harmlessly, ship identical text). The chain then plays its real logic +
235
+ speaks its real lines, doors wired to each other instead of back into the live game."""
236
+ from . import extract
237
+ from ._fieldtext import EVENT_ID_TO_MES
238
+ out = Path(out_dir)
239
+ out.mkdir(parents=True, exist_ok=True)
240
+ # STABLE-ID mode: when re-forking on top of an existing campaign (``prior_plan``), freeze every
241
+ # re-discovered donor's prior fork-id + name and APPEND net-new donors above the highest prior id, so an
242
+ # in-fork SAVE survives the re-fork (it stores the field id + position-based story-flag window).
243
+ prior = {m.real_id: (m.new_id, m.name) for m in prior_plan.members if m.real_id} if prior_plan else None
244
+ # EVERY prior member's id is reserved (incl. source-less / hand-appended ones absent from `prior`), so a
245
+ # net-new donor can't collide with one even though only real donors can be re-discovered by name/id.
246
+ reserved = {m.new_id for m in prior_plan.members} if prior_plan else None
247
+ members_ids, new_id, name_of = assign_ids(result, id_base=id_base, name_prefix=name_prefix,
248
+ prior=prior, reserved_ids=reserved)
249
+ if not members_ids:
250
+ raise ValueError("no forkable fields in the walk -- nothing to fork (try a different seed/--zones)")
251
+ # Carry forward any prior member the new walk did NOT re-discover (a hand-appended out-of-band fork like a
252
+ # missed cross-zone cutscene field, OR a source-less blank-room/logic member) -- keep its files + id so
253
+ # (a) it isn't orphaned/dropped and its cross-link doesn't re-leak, and (b) other members' retargets to it
254
+ # still resolve. Carry only a member whose field.toml is still on disk; flag any whose files vanished
255
+ # (can't carry -> later members' positions/flag windows shift).
256
+ carried: list = []
257
+ carried_missing: list = []
258
+ if prior_plan:
259
+ discovered = set(members_ids)
260
+ for m in prior_plan.members:
261
+ if m.real_id and m.real_id in discovered:
262
+ continue # re-discovered -> re-forked below with its frozen id
263
+ if (out / m.toml_rel).exists():
264
+ if m.real_id:
265
+ new_id[m.real_id] = m.new_id # so re-forked members' Field(real)->fork resolve
266
+ name_of[m.real_id] = m.name # keep new_id/name_of CONSISTENT: a re-forked verbatim
267
+ # member's Field(carried) exit feeds the edge-synth `name_of[d]` below -- a carried id in
268
+ # new_id but absent from name_of crashes it (KeyError). real_id 0 never a Field() dest.
269
+ carried.append(m)
270
+ else:
271
+ carried_missing.append(m)
272
+
273
+ swap_name = None
274
+ if swap_player and verbatim: # the swap patches each member's verbatim donor .eb
275
+ from . import playerswap
276
+ swap_name, _ = playerswap.resolve_char(swap_player) # fail fast on a bad char before forking the chain
277
+ members = []
278
+ member_exits: dict = {} # real -> its donor .eb Field() dests (verbatim)
279
+ degraded: list = [] # verbatim members that fell back to declarative
280
+ swap_gesture_warn: dict = {} # mname -> scripted-gesture count (will glitch on swap)
281
+ swap_skipped: list = [] # verbatim members with no swappable player entry
282
+ for real in members_ids:
283
+ folder = extract.ID_TO_FBG[real]
284
+ donor = str(real) # identify the donor by ID, not the FBG folder --
285
+ # several field ids can SHARE one folder (the same room at a different story beat, e.g. 52/3008), so
286
+ # a folder-name lookup is ambiguous + DROPS the member; the id resolves its own scene + .eb exactly.
287
+ area, _ = extract.parse_fbg_folder(folder)
288
+ # verbatim ships its own native scene + the donor's whole .eb, so it forks NATIVE for any area
289
+ mode = "native" if verbatim else ("borrow" if area >= extract.MIN_CUSTOM_AREA else "native")
290
+ mname = name_of[real]
291
+ mdir = out / mname
292
+ mdir.mkdir(parents=True, exist_ok=True)
293
+ needs_export = False
294
+ try:
295
+ if verbatim:
296
+ # the donor's OWN registered textid (a valid MesDB key); shipping its .mes there is an
297
+ # identity override, and same-zone members share it (identical text -> harmless).
298
+ tb = EVENT_ID_TO_MES.get(real, 1073)
299
+ _meta, p = extract.write_native_project(donor, mdir, name=mname, field_id=new_id[real],
300
+ text_block=tb, game=game, id_remap=new_id,
301
+ live_seams=live_seams, verbatim=True)
302
+ member_exits[real] = _meta.get("imported_content", {}).get("field_exits", [])
303
+ if swap_name: # play as one char across the chain (per-member swap)
304
+ try:
305
+ n = extract.apply_player_swap(p, swap_name, neutralize=neutralize_gestures)
306
+ if n:
307
+ swap_gesture_warn[mname] = n
308
+ except playerswap.NoSwappablePlayer:
309
+ swap_skipped.append(mname) # no swappable player entry (e.g. a cutscene member)
310
+ # a real overflow/corruption ValueError is NOT caught here -> it propagates loudly
311
+ elif mode == "borrow":
312
+ _meta, p = extract.write_field_project(donor, mdir, name=mname, field_id=new_id[real],
313
+ game=game, id_remap=new_id, live_seams=live_seams)
314
+ else: # area<10: NATIVE fork (own atlas+.bgs, NO .bgx) -- seamless + fully offline (no [Export])
315
+ _meta, p = extract.write_native_project(donor, mdir, name=mname, field_id=new_id[real],
316
+ game=game, id_remap=new_id, live_seams=live_seams)
317
+ except RuntimeError: # a field with no usable background atlas (rare)
318
+ if mode == "borrow":
319
+ raise
320
+ # verbatim degrades to a logic-only stub too (loses the verbatim .eb for this one member)
321
+ _meta, p = _emit_logic_only_member(donor, mdir, mname, new_id[real], new_id, live_seams, game)
322
+ needs_export = True
323
+ if verbatim:
324
+ degraded.append(mname) # surfaced loudly in the CLI summary (NOT verbatim)
325
+ members.append(Member(real, new_id[real], mname, mode, area, folder,
326
+ f"{mname}/{p.name}", needs_export))
327
+
328
+ edges, seams = _collect_edges_seams(result, members_ids, new_id, name_of)
329
+ # In a verbatim chain the LIVE doors are the donor .eb's retargeted Field() exits -- which include
330
+ # scripted/self warps that aren't walk-in [[edge]]s. Surface every in-chain retarget as an edge so the
331
+ # graph/reachability reflect what was baked into the shipped .eb (else a member reachable only via a
332
+ # retargeted scripted warp reads as UNREACHABLE). Skip self-loops; dedup against the walk-in edges.
333
+ if verbatim:
334
+ have = {(e["frm"], e["to"]) for e in edges}
335
+ for real, exits in member_exits.items():
336
+ for d in exits:
337
+ if d in new_id and d != real and (name_of[real], name_of[d]) not in have:
338
+ edges.append({"frm": name_of[real], "to": name_of[d], "entrance": 0,
339
+ "story_conditional": False})
340
+ have.add((name_of[real], name_of[d]))
341
+ members.extend(carried) # keep prior forks the new walk didn't re-discover (no orphan/re-leak)
342
+ members.sort(key=lambda m: m.new_id) # id-sorted == position-stable: a re-discovered member keeps its index
343
+ # -> its position-based story-flag window (flag_base + i*K) survives too.
344
+ # Fresh fork: ids are id_base+i in walk order, so this is already sorted.
345
+ # Entry: the first-discovered (seed) member, BUT on a stable re-fork keep the PRIOR entry if that member
346
+ # still exists -- a changed discovery order must not silently repoint New Game / the journey entry.
347
+ entry_name = name_of[members_ids[0]]
348
+ if prior_plan and prior_plan.entry_name and any(m.name == prior_plan.entry_name for m in members):
349
+ entry_name = prior_plan.entry_name
350
+ plan = CampaignPlan(name=name, mod_folder=mod_folder, id_base=id_base, flag_base=flag_base,
351
+ flags_per_field=flags_per_field, entry_name=entry_name,
352
+ entry_entrance=entry_entrance, members=members, edges=edges, seams=seams)
353
+ plan.stable_ids = bool(prior_plan) # transient: re-fork reused the prior donor->id+name map
354
+ plan.reused_ids = sorted(r for r in members_ids if prior and r in prior) # re-discovered, frozen id
355
+ plan.appended_ids = sorted(r for r in members_ids if not (prior and r in prior)) # net-new this fork
356
+ plan.carried = [m.name for m in carried] # prior forks kept verbatim (not re-discovered)
357
+ plan.carried_missing = [(m.name, m.new_id) for m in carried_missing] # prior forks whose files vanished
358
+ plan.verbatim = bool(verbatim) # PERSISTED: gates the declarative-only stacked-door lint (the donor
359
+ # .eb resolves story-conditional doors itself -- nothing to re-author)
360
+ plan.verbatim_degraded = degraded # transient build-time signal (NOT persisted): verbatim members
361
+ plan.swap_player = swap_name # transient: --swap-player char applied to every member, + the
362
+ plan.swap_gesture_warn = swap_gesture_warn # members whose scripted gestures will glitch on the new rig,
363
+ plan.swap_skipped = swap_skipped # and members with no swappable player entry (left as the donor's)
364
+ plan.neutralized = bool(neutralize_gestures and swap_name) # those gestures were rewritten to idle (won't glitch)
365
+ (out / "campaign.toml").write_text(render_campaign_toml(plan), encoding="utf-8", newline="\n")
366
+ return plan
367
+
368
+
369
+ def _q(note: str) -> str:
370
+ """A TOML-safe basic string (drop the only char that would break a quoted value)."""
371
+ return str(note).replace('"', "'")
372
+
373
+
374
+ def render_campaign_toml(plan: CampaignPlan) -> str:
375
+ """The campaign.toml text -- valid TOML (array-of-tables, multi-line): [campaign] header, [[field]]
376
+ members, [[edge]] in-chain graph, [[seam]] non-gateway connections, [initial_flags]. Parseable by
377
+ tomllib (so P3's build-all can load it)."""
378
+ L = ["# Campaign manifest emitted by ff9mapkit import-chain (P2).",
379
+ "# Members are forked real fields with gateways RETARGETED to this chain's own ids.",
380
+ "# [[edge]] = in-chain connectivity (each is a live retargeted [[gateway]] in a member toml).",
381
+ "# [[seam]] = scripted/overworld/menu/portal connections that are NOT live gateways -- author by hand.",
382
+ "",
383
+ "[campaign]",
384
+ f'name = "{plan.name}"',
385
+ f'mod_folder = "{plan.mod_folder}"',
386
+ f"id_base = {plan.id_base}",
387
+ f"flag_base = {plan.flag_base}",
388
+ f"flags_per_field = {plan.flags_per_field}",
389
+ f'entry_field = "{plan.entry_name}"',
390
+ f"entry_entrance = {plan.entry_entrance}"]
391
+ if plan.verbatim: # verbatim members ship the donor .eb whole -> story-conditional
392
+ L.append("verbatim = true # every member ships its donor's whole .eb (real logic + real doors)")
393
+ L += ["",
394
+ "# Members id-sorted. Fresh fork: id = id_base + BFS-index. Stable re-fork (--out had a prior",
395
+ "# campaign.toml): a re-discovered donor keeps its prior id+name; net-new donors append above the max."]
396
+ for m in plan.members:
397
+ L.append("[[field]]")
398
+ L.append(f'name = "{m.name}"')
399
+ L.append(f"source = {m.real_id}")
400
+ L.append(f"id = {m.new_id}")
401
+ L.append(f'mode = "{m.mode}"')
402
+ L.append(f'toml = "{m.toml_rel}"')
403
+ if m.needs_export:
404
+ L.append("needs_export = true # logic-only stub: this field had no usable background atlas")
405
+ L.append("")
406
+ L += ["# In-chain connectivity (each = a retargeted live [[gateway]] in the member toml)."]
407
+ for e in plan.edges:
408
+ L.append("[[edge]]")
409
+ L.append(f'from = "{e["frm"]}"')
410
+ L.append(f'to = "{e["to"]}"')
411
+ L.append(f"entrance = {e['entrance']}")
412
+ if e.get("story_conditional"):
413
+ L.append("story_conditional = true") # explicit marker -> survives load (NOT inferred from gated_by,
414
+ # which verbatim omits) so a DEGRADED-member lint still fires
415
+ if plan.verbatim:
416
+ # the donor .eb owns this conditional door (if(flag){A}else{B}) -- it's carried verbatim and the
417
+ # engine resolves it at runtime, so there is NOTHING to gate here (informational only).
418
+ L.append("# the donor .eb resolves this story-conditional door at runtime (informational)")
419
+ else:
420
+ L.append(f'gated_by = "" # STORY-COND stacked same-zone exit -- set requires_flag '
421
+ f'(suggest {plan.flag_base + 7})')
422
+ L.append("")
423
+ if plan.seams:
424
+ L += ["# Seams: NOT live gateways -- scripted teleports / overworld exits / menu / out-of-chain."]
425
+ for s in plan.seams:
426
+ L.append("[[seam]]")
427
+ L.append(f'from = "{s["frm"]}"')
428
+ L.append('to_real = "WORLDMAP"' if s["to_real"] == "WORLDMAP" else f"to_real = {s['to_real']}")
429
+ L.append(f'kind = "{s["kind"]}"')
430
+ if s.get("to_member"):
431
+ L.append(f'to_member = "{s["to_member"]}"')
432
+ L.append(f'note = "{_q(s["note"])}"')
433
+ L.append("")
434
+ if plan.flags:
435
+ L += ["# Shared NAMED flags -- members gate by NAME (requires_flag = \"<name>\"). Place ABOVE the",
436
+ "# per-member auto-flag blocks; indices must be in [8512, 16320), clear of real-FF9 usage."]
437
+ for fdef in plan.flags:
438
+ L += ["[[flag]]", f'name = "{fdef.get("name", "")}"', f"index = {int(fdef.get('index', 0))}", ""]
439
+ L += ["[initial_flags]", "# GLOB flags pre-set at campaign entry (empty by default)", ""]
440
+ return "\n".join(L)
441
+
442
+
443
+ # ---- P3: load a campaign.toml + build all members into one mod ---------------------------
444
+ def load_campaign(path) -> CampaignPlan:
445
+ """Parse a campaign.toml back into a CampaignPlan (the inverse of render_campaign_toml). Members keep
446
+ their FINAL ids + retargeted gateways (those live in the member field.tomls, not here)."""
447
+ with open(path, "rb") as fh:
448
+ data = tomllib.load(fh)
449
+ if "campaign" not in data:
450
+ raise CampaignError(f"{path}: not a campaign manifest (no [campaign] table)")
451
+ c = data["campaign"]
452
+ members = [Member(real_id=int(f.get("source", 0)), new_id=int(f["id"]), name=f["name"],
453
+ mode=f.get("mode", "borrow"), src_area=0, folder="",
454
+ toml_rel=f["toml"], needs_export=bool(f.get("needs_export", False)))
455
+ for f in data.get("field", [])]
456
+ return CampaignPlan(
457
+ name=c.get("name", "CAMPAIGN"), mod_folder=c.get("mod_folder", "FF9CustomMap"),
458
+ id_base=int(c.get("id_base", 4000)), flag_base=int(c.get("flag_base", FIRST_SAFE_FLAG)),
459
+ flags_per_field=int(c.get("flags_per_field", 64)), entry_name=c.get("entry_field", ""),
460
+ entry_entrance=int(c.get("entry_entrance", 0)), members=members,
461
+ verbatim=bool(c.get("verbatim", False)),
462
+ flags=list(data.get("flag", [])),
463
+ edges=[{"frm": e["from"], "to": e["to"], "entrance": int(e.get("entrance", 0)),
464
+ # the explicit marker (new) OR a legacy gated_by placeholder (back-compat with older forks)
465
+ "story_conditional": bool(e.get("story_conditional")) or ("gated_by" in e)}
466
+ for e in data.get("edge", [])],
467
+ # normalize seams to the in-memory shape (from -> frm), exactly as edges above; render_campaign_toml
468
+ # writes `from`, but _collect_edges_seams/lint/campaign_graph all key on `frm`, so a raw passthrough
469
+ # left loaded seams with `from` (dropping them from the resolved graph + nulling lint messages).
470
+ seams=[{"frm": s.get("frm", s.get("from")), "to_real": s.get("to_real"), "kind": s.get("kind"),
471
+ "note": s.get("note", ""), "to_member": s.get("to_member")}
472
+ for s in data.get("seam", [])])
473
+
474
+
475
+ def validate_ids(plan: CampaignPlan):
476
+ """The one campaign-level check P3 owns: ids non-empty, distinct, and in the custom band [4000, 32767]
477
+ (>=4000 per CLAUDE.md; <=32767 because the live fldMapNo is Int16 -> a higher id is unreachable).
478
+ Per-field schema/placement validation runs later inside build_field/validate."""
479
+ if not plan.members:
480
+ raise CampaignError("campaign has no [[field]] members to build")
481
+ ids = [m.new_id for m in plan.members]
482
+ dups = sorted({i for i in ids if ids.count(i) > 1})
483
+ if dups:
484
+ raise CampaignError(f"duplicate member ids {dups} -- EventDB/SceneData are global dicts and collide at launch")
485
+ bad = [i for i in ids if not (4000 <= i <= 32767)]
486
+ if bad:
487
+ raise CampaignError(f"member ids out of range {sorted(set(bad))}: must be 4000-32767 (fldMapNo is Int16)")
488
+
489
+
490
+ def apply_seed_blocks(raw: dict, blocks: dict) -> None:
491
+ """Merge story-flags capstone blocks (``startup`` / ``party`` / ``start_inventory`` / ``equipment``) AND a
492
+ journey ``[journey.tuning]`` bundle (``player_csv``) into a member's ``FieldProject.raw`` IN PLACE -- additive
493
+ (scenario replaces, flags + party-adds union, the bag / equipment lists replace, the player CSV blocks EXTEND).
494
+ The journey assembler's levers (:func:`ff9mapkit.journey.seed_to_field_blocks` /
495
+ :func:`ff9mapkit.journey.tuning_to_field_blocks` produce ``blocks``) seed a journey's ENTRY member without
496
+ rewriting its forked field.toml on disk, so the fork stays clean and a re-deploy is idempotent. Empty
497
+ ``blocks`` -> no mutation (byte-identical build)."""
498
+ if not blocks:
499
+ return
500
+ if "startup" in blocks:
501
+ su = raw.setdefault("startup", {})
502
+ if "scenario" in blocks["startup"]:
503
+ su["scenario"] = blocks["startup"]["scenario"]
504
+ if blocks["startup"].get("flags"):
505
+ su["flags"] = list(su.get("flags", [])) + list(blocks["startup"]["flags"])
506
+ if "party" in blocks:
507
+ add = list(raw.setdefault("party", {}).get("add", []))
508
+ for m in blocks["party"].get("add", []):
509
+ if m not in add:
510
+ add.append(m)
511
+ raw["party"]["add"] = add
512
+ if "start_inventory" in blocks:
513
+ raw["start_inventory"] = blocks["start_inventory"]
514
+ if "equipment" in blocks:
515
+ raw["equipment"] = blocks["equipment"]
516
+ if "player_csv" in blocks: # [journey.tuning]: the mod-global player/ability CSV
517
+ for block, rows in blocks["player_csv"].items(): # blocks EXTEND the entry member's same-named blocks (a
518
+ raw[block] = list(raw.get(block, [])) + list(rows) # fork rarely has its own -> usually just sets them)
519
+
520
+
521
+ def _remap_text_blocks(projects, base: int) -> dict:
522
+ """JOURNEY cross-campaign text-shadow cure: remap this campaign's dialogue text blocks into a DISJOINT custom
523
+ mesID window ``[base, base + distinct_blocks)``. Each DISTINCT original block (the donor mesID, shared by
524
+ zone-mates) maps to ``base + idx``, so members that SHARED a block keep sharing the remapped one
525
+ (intra-campaign :func:`build._reconcile_mes` preserved) while no sibling campaign's window can collide. Marks
526
+ each field for MesDB registration (``build_mod`` emits a DictionaryPatch ``MessageFile`` line so DataPatchers'
527
+ FieldScene gate passes; the .mes ships at ``field/<remapped>.mes``). Returns ``{original: remapped}``."""
528
+ distinct = sorted({p.text_block for p in projects})
529
+ remap = {b: int(base) + i for i, b in enumerate(distinct)}
530
+ for p in projects:
531
+ fld = p.raw.setdefault("field", {})
532
+ fld["text_block"] = remap[p.text_block] # follows to the FieldScene textid + field/<id>.mes
533
+ fld["register_text_block"] = True
534
+ return remap
535
+
536
+
537
+ def build_campaign(campaign_path, out=None, *, author="", description="", allow_artless=False,
538
+ flag_base=None, seed_blocks=None, text_block_base=None, extra_flag_names=None) -> dict:
539
+ """Compile every member of a campaign.toml into ONE staged Memoria mod (DictionaryPatch + BattlePatch +
540
+ ModDescription + per-field assets), reusing build.build_mod. Returns build_mod's dict + ``plan``/``out``.
541
+ Does NOT deploy (P4). ``out`` defaults to ``<campaign-dir>/dist``.
542
+
543
+ ``flag_base`` (the JOURNEY assembler's lever): override the campaign's own ``flag_base`` so the journey
544
+ can hand each of its campaigns a NON-OVERLAPPING ``gEventGlobal`` flag window (two campaigns in one
545
+ journey run together -- they must not clobber each other's bits; :mod:`ff9mapkit.journey`). Applied
546
+ before lint, so the per-member auto-flag blocks + the safe-band checks both use the override.
547
+
548
+ ``seed_blocks`` (the journey ``[journey.seed]`` capstone + ``[journey.tuning]``): a dict of ``startup``/
549
+ ``party``/``start_inventory``/``equipment``/``player_csv`` blocks merged into the ENTRY member's project
550
+ before build (:func:`apply_seed_blocks`) -- so the journey boots at its seeded beat/party AND its mod-global
551
+ player tuning (the ``.eb`` + CSV channels) without rewriting the forked entry field.toml. ``None`` -> no
552
+ seeding."""
553
+ from .build import FieldProject, build_mod
554
+ campaign_path = Path(campaign_path)
555
+ manifest_dir = campaign_path.parent
556
+ plan = load_campaign(campaign_path)
557
+ if flag_base is not None: # journey-assigned disjoint flag window
558
+ plan.flag_base = int(flag_base)
559
+ lint_errors, lint_warnings = lint_campaign(plan, manifest_dir, extra_flag_names=extra_flag_names)
560
+ if lint_errors:
561
+ raise CampaignError("campaign lint failed:\n - " + "\n - ".join(lint_errors))
562
+ out = Path(out) if out else (manifest_dir / "dist")
563
+
564
+ campaign_names = collect_flag_defs({"flag": plan.flags}) # shared [[flag]] names (lint already validated)
565
+ for nm, idx in (extra_flag_names or {}).items(): # journey-GLOBAL named flags (cross-CAMPAIGN gates);
566
+ campaign_names.setdefault(nm, idx) # the campaign's own name wins on a clash (journey lint forbids it)
567
+ projects = []
568
+ for i, m in enumerate(plan.members):
569
+ toml_path = (manifest_dir / m.toml_rel).resolve() # member subdir -> sidecars resolve via base_dir
570
+ if not toml_path.is_file():
571
+ raise CampaignError(f"member {m.name}: field.toml not found at {toml_path}")
572
+ if m.needs_export and not allow_artless:
573
+ raise CampaignError(
574
+ f"member {m.name} needs in-game art before build: export it once (Memoria.ini [Export] "
575
+ f"Field=1) + re-fork --editable, or pass --allow-artless to build it with no background.")
576
+ proj = FieldProject.load(toml_path, flag_names=campaign_names) # members gate by shared flag NAME
577
+ # Per-member once-flag base so member i's auto event/cutscene/choice flags can't alias a
578
+ # sibling's (the per-field-counter-resets-per-build bug). Block = [flag_base + i*K, +K), packed
579
+ # by build._FlagAlloc. lint_campaign asserts every block is in the provably-safe band. (A [[chest]]'s
580
+ # opened-flag is NOT auto-packed here -- build.validate REQUIRES every chest to pin a DEFINED safe-band
581
+ # flag, a named [[flag]] or index; campaign members share the flag NAME space, so name it.)
582
+ proj.flag_base = plan.flag_base + i * plan.flags_per_field
583
+ proj.flags_per_field = plan.flags_per_field # the overflow guard's per-member block width
584
+ # Do NOT override text_block to a per-member id. The FieldScene textid (6th DictionaryPatch token)
585
+ # MUST already be a key in FF9DBAll.MesDB, or DataPatchers SKIPS the whole scene registration
586
+ # (DataPatchers.cs:392-395 `if (!MesDB.ContainsKey(mesID)) continue;` -- verified in-game: textid
587
+ # 30100 -> "invalid message file ID 30100" -> the field never registers, absent from F6). Empty
588
+ # members ship no .mes, so they keep the kit default 1073 (a real base block in MesDB). Distinct
589
+ # textids only become needed -- AND valid -- once a member SHIPS its own .mes for dialogue; doing
590
+ # that safely (a custom .mes that registers its id in MesDB) is a follow-up, not done here.
591
+ projects.append(proj)
592
+
593
+ if text_block_base: # JOURNEY: a disjoint custom text-block window for this
594
+ _remap_text_blocks(projects, text_block_base) # campaign (the cross-campaign text-shadow cure)
595
+
596
+ # each member's per-member flag_base was set on its FieldProject above; build_script's _FlagAlloc packs
597
+ # that member's auto event/cutscene/choice flags into its own disjoint block (no cross-field alias). A
598
+ # [[chest]] opened-flag is the exception -- it is never auto-allocated; build.validate REQUIRES every chest
599
+ # to define a safe-band flag (a named [[flag]] is campaign-unique by name), so chests can't alias.
600
+ # the entry member's project (by member index) -> precise non-entry lint for the mod-global new-game blocks
601
+ entry_project = next((projects[i] for i, m in enumerate(plan.members) if m.name == plan.entry_name), None)
602
+ if seed_blocks and entry_project is not None: # the journey [journey.seed] capstone, on the entry only
603
+ apply_seed_blocks(entry_project.raw, seed_blocks)
604
+ # the seed can introduce flag NAMES (a journey-global [[flag]] or a campaign-shared one) into the entry's
605
+ # [startup] AFTER FieldProject.load already resolved the on-disk names -> resolve again so the seeded
606
+ # names become indices too (idempotent: an already-resolved int passes through). Else build.validate
607
+ # rejects the seeded `flags = [{flag = "<name>"}]` as "unknown flag name".
608
+ resolve_project_flags(entry_project.raw, extra_names=campaign_names)
609
+ info = build_mod(projects, out, mod_name=plan.mod_folder, author=author, description=description,
610
+ entry_project=entry_project)
611
+ # [ff9mapkit] fork-fidelity: ForkDonorPatch.txt maps each custom-id fork -> its donor real field id, so
612
+ # the engine's behaviors hardcoded on a real fldMapNo (off-mesh exemptions, cutscene party-shape guards,
613
+ # scroll player-binds -- docs/FORK_IDGATE_MAP.md) still fire for the fork. Read by the patched DataPatchers
614
+ # (memoria-patches/s24-fork-donor-remap); a no-op on a stock engine that doesn't read the file.
615
+ donor_lines = [f"{m.new_id} {m.real_id}" for m in plan.members
616
+ if getattr(m, "real_id", None) and m.new_id != m.real_id]
617
+ if donor_lines:
618
+ (Path(out) / "ForkDonorPatch.txt").write_text(
619
+ "# ff9mapkit fork-fidelity: <forkId> <donorRealId>\n" + "\n".join(donor_lines) + "\n",
620
+ encoding="utf-8", newline="\n")
621
+ info["plan"] = plan
622
+ info["out"] = str(Path(out).resolve())
623
+ info["warnings"] = list(lint_warnings) + list(info.get("warnings", []))
624
+ return info
625
+
626
+
627
+ # ---- P5: campaign lint (structural + cross-field flags) ---------------------------------
628
+ _CONSUME_KEYS = ("requires_flag", "requires_flag_clear") # explicit flag READS (gates)
629
+ _PRODUCE_KEYS = ("flag", "set_flag") # explicit flag WRITES
630
+
631
+
632
+ def _collect_flags(obj, produced: set, consumed: set):
633
+ """Walk a member field.toml dict, collecting EXPLICIT GLOB flag indices (ints) under the gate/set
634
+ keys. Auto-allocated 'once' flags are NOT in the toml (computed at build) -> not seen here; per-member
635
+ auto-allocation is a deferred follow-up, so this is the explicit-flag cross-field check."""
636
+ if isinstance(obj, dict):
637
+ for k, v in obj.items():
638
+ if k in _CONSUME_KEYS and isinstance(v, int):
639
+ consumed.add(v)
640
+ elif k in _PRODUCE_KEYS and isinstance(v, int):
641
+ produced.add(v)
642
+ else:
643
+ _collect_flags(v, produced, consumed)
644
+ elif isinstance(obj, list):
645
+ for it in obj:
646
+ _collect_flags(it, produced, consumed)
647
+
648
+
649
+ def _member_flags_from_toml(member_raw: dict):
650
+ produced, consumed = set(), set()
651
+ _collect_flags(member_raw, produced, consumed)
652
+ return produced, consumed
653
+
654
+
655
+ def lint_campaign(plan: CampaignPlan, manifest_dir, *, in_journey: bool = False,
656
+ extra_flag_names=None) -> tuple:
657
+ """Validate a campaign without building. Returns ``(errors, warnings)``; errors abort build-all,
658
+ warnings are advisory. Pure manifest + member-toml read (no game install). For the empty forks
659
+ import-chain produces, the structural checks fire and the flag checks are silent (correct).
660
+
661
+ ``extra_flag_names`` (the JOURNEY tier): name->index for journey-GLOBAL ``[[flag]]``s, so a member
662
+ that gates on a cross-CAMPAIGN flag (set by a sibling campaign or the ``[journey.seed]``) RESOLVES
663
+ here instead of failing as "unknown flag name" -- mirrors ``build_campaign``'s own propagation so
664
+ lint and build AGREE. ``None`` -> campaign-own names only (the standalone ``lint-campaign`` path)."""
665
+ from collections import defaultdict
666
+ manifest_dir = Path(manifest_dir)
667
+ errors, warnings = [], []
668
+ names = {m.name for m in plan.members}
669
+
670
+ try: # (a) ids non-empty / distinct / in [4000,32767]
671
+ validate_ids(plan)
672
+ except CampaignError as e:
673
+ errors.append(str(e))
674
+
675
+ mem_names = [m.name for m in plan.members] # (a2) names distinct (they key edges/seams + the navigator)
676
+ name_dups = sorted({n for n in mem_names if mem_names.count(n) > 1})
677
+ if name_dups:
678
+ errors.append(f"duplicate member names {name_dups} -- names must be unique "
679
+ f"(edges/seams + the campaign navigator key on them)")
680
+
681
+ K = plan.flags_per_field # (a3) per-member flag blocks: in the provably-safe band,
682
+ for i, m in enumerate(plan.members): # clear of real-FF9's chest bitfield + the scratch
683
+ lo, hi = plan.flag_base + i * K, plan.flag_base + i * K + K - 1
684
+ if lo < FIRST_SAFE_FLAG:
685
+ errors.append(f"member {m.name}: flag block {lo}-{hi} dips below the safe floor "
686
+ f"{FIRST_SAFE_FLAG} (overlaps real-FF9 flags) -- raise [campaign] flag_base.")
687
+ if lo <= CHEST_FLAG_HI and hi >= CHEST_FLAG_LO:
688
+ errors.append(f"member {m.name}: flag block {lo}-{hi} intersects real-FF9's treasure-chest "
689
+ f"band {CHEST_FLAG_LO}-{CHEST_FLAG_HI} -> SAVE CORRUPTION -- set [campaign] "
690
+ f"flag_base = {FIRST_SAFE_FLAG}.")
691
+ if hi >= CHOICE_SCRATCH_FLOOR:
692
+ cap = (CHOICE_SCRATCH_FLOOR - plan.flag_base) // K
693
+ errors.append(f"member {m.name}: flag block {lo}-{hi} reaches the choice-scratch floor "
694
+ f"{CHOICE_SCRATCH_FLOOR} -- too many members for the band (max {cap} at this base/K).")
695
+
696
+ try: # (a4) shared [[flag]] names: valid + clear of member blocks
697
+ shared = collect_flag_defs({"flag": plan.flags})
698
+ except ValueError as ex:
699
+ shared, _ = {}, errors.append(f"campaign [[flag]]: {ex}")
700
+ block_hi = plan.flag_base + len(plan.members) * K - 1 # member auto-flag blocks span [flag_base, block_hi]
701
+ for nm, idx in sorted(shared.items()):
702
+ if plan.flag_base <= idx <= block_hi:
703
+ errors.append(f"shared flag {nm!r} (index {idx}) falls inside the per-member auto-flag blocks "
704
+ f"[{plan.flag_base}, {block_hi}] -- put shared flags ABOVE them (>= {block_hi + 1}).")
705
+
706
+ for e in plan.edges: # (b) edges resolve to members
707
+ if e.get("frm") not in names:
708
+ errors.append(f"edge from {e.get('frm')!r}: not a campaign member")
709
+ if e.get("to") not in names:
710
+ errors.append(f"edge to {e.get('to')!r}: not a campaign member")
711
+ if plan.members and plan.entry_name not in names:
712
+ errors.append(f"entry_field {plan.entry_name!r} is not a campaign member")
713
+
714
+ for s in plan.seams: # (c) seams: frm member; to_real int|WORLDMAP; to_member valid
715
+ tr = s.get("to_real")
716
+ if tr != "WORLDMAP" and not isinstance(tr, int):
717
+ errors.append(f"seam from {s.get('frm')!r}: to_real must be an int or 'WORLDMAP' (got {tr!r})")
718
+ if plan.members and s.get("frm") not in names:
719
+ warnings.append(f"seam from {s.get('frm')!r}: not a campaign member (stale name?)")
720
+ tm = s.get("to_member")
721
+ if tm and tm not in names:
722
+ warnings.append(f"seam from {s.get('frm')!r}: to_member {tm!r} is not a member (stale name?)")
723
+
724
+ # (c2) LEAK to an UN-FORKED field: a seam whose target no member forks (to_member is None) and that's a real
725
+ # FIELD warp -- a scripted cutscene/ATE Field() or an out-of-chain door. The verbatim fork carries that
726
+ # Field() un-remapped, so the player is sent OUT to the real (un-forked) game; a GREY/unskippable cutscene
727
+ # warp there softlocks the journey (the exact class that warped a forked Dali ATE into Qu's Marsh). Excludes
728
+ # 'menu' (shops return) and 'overworld'/WORLDMAP (an intended world-map boundary). SUPPRESSED inside a
729
+ # multi-campaign journey -- a sibling campaign's field reads as a non-member here; journey.campaign_connectivity
730
+ # is the sibling-aware check there.
731
+ if not in_journey:
732
+ bad = sorted({(int(s["to_real"]), s.get("kind"), str(s.get("frm"))) for s in plan.seams
733
+ if s.get("kind") in ("scripted", "portal")
734
+ and isinstance(s.get("to_real"), int) and s.get("to_member") is None})
735
+ forced = [(frm, tid) for tid, k, frm in bad if k == "scripted"] # carried cutscene/ATE Field() -> softlock
736
+ doors = [(frm, tid) for tid, k, frm in bad if k == "portal"] # walk-out door -> often the arc's edge
737
+ if forced:
738
+ shown = "; ".join(f"{frm}->{tid}" for frm, tid in forced[:6]) + (" ..." if len(forced) > 6 else "")
739
+ warnings.append(f"{len(forced)} FORCED leak(s) out of the campaign ({shown}) -- a carried cutscene/ATE "
740
+ f"Field() warps the player to an UN-FORKED field (the real game); a grey/unskippable "
741
+ f"one SOFTLOCKS. Fork those in (import-chain --whole-zone) or redirect the warp.")
742
+ if doors:
743
+ shown = "; ".join(f"{frm}->{tid}" for frm, tid in doors[:6]) + (" ..." if len(doors) > 6 else "")
744
+ warnings.append(f"{len(doors)} walk-out door(s) to un-forked field(s) ({shown}) -- likely the "
745
+ f"campaign's edge; fork the next zone, or accept it as the boundary.")
746
+
747
+ member_raw = {} # (e) member field.toml exists, within the campaign folder
748
+ for m in plan.members:
749
+ p = manifest_dir / m.toml_rel
750
+ if not _within(manifest_dir, p): # a crafted toml_rel ('../..') must not read outside
751
+ errors.append(f"member {m.name}: field.toml path escapes the campaign folder ({m.toml_rel})")
752
+ continue
753
+ if not p.is_file():
754
+ errors.append(f"member {m.name}: field.toml not found at {p}")
755
+ continue
756
+ try:
757
+ with open(p, "rb") as fh:
758
+ member_raw[m.name] = tomllib.load(fh)
759
+ except (OSError, tomllib.TOMLDecodeError) as ex:
760
+ errors.append(f"member {m.name}: field.toml unreadable ({ex})")
761
+
762
+ for m in plan.members: # (e2) explicit flags must avoid real-FF9's chest band
763
+ raw = member_raw.get(m.name)
764
+ if raw is None:
765
+ continue
766
+ prod, cons = _member_flags_from_toml(raw)
767
+ for idx in sorted(prod | cons):
768
+ if CHEST_FLAG_LO <= idx <= CHEST_FLAG_HI:
769
+ errors.append(f"member {m.name}: explicit flag {idx} is inside real-FF9's treasure-chest "
770
+ f"band {CHEST_FLAG_LO}-{CHEST_FLAG_HI} -> SAVE CORRUPTION -- use an index in "
771
+ f"[{FIRST_SAFE_FLAG}, {CHOICE_SCRATCH_FLOOR}).")
772
+ elif idx >= FIRST_SAFE_FLAG and idx >= CHOICE_SCRATCH_FLOOR:
773
+ warnings.append(f"member {m.name}: explicit flag {idx} is at/above the choice-scratch floor "
774
+ f"{CHOICE_SCRATCH_FLOOR} (engine-owned) -- pick a lower index.")
775
+
776
+ for nm in plan.needs_export: # (f) artless members
777
+ warnings.append(f"member {nm}: needs in-game art ([Export] Field=1) before a real build")
778
+
779
+ # (g) ungated stacked story-conditional door -- DECLARATIVE gateways only. A VERBATIM member ships the donor
780
+ # .eb whole, so its if(flag){A}else{B} door is carried + resolved by the engine (nothing authored to
781
+ # gate -> the warning would be a false positive). BUT a DEGRADED member (needs_export: a logic-only stub)
782
+ # re-authors its gateways declaratively even in a verbatim chain, so it still needs the gating advice.
783
+ degraded = set(plan.needs_export)
784
+ stacked = defaultdict(int)
785
+ for e in plan.edges:
786
+ frm = e.get("frm")
787
+ if e.get("story_conditional") and (not plan.verbatim or frm in degraded):
788
+ stacked[frm] += 1
789
+ for frm, n in stacked.items():
790
+ if n >= 2:
791
+ warnings.append(f"member {frm}: {n} stacked same-zone exits (story-conditional) -- set "
792
+ f"requires_flag on each in its field.toml, else the engine resolves only one")
793
+
794
+ if not errors: # (h) cross-field flag dependencies (NAME gates included)
795
+ # journey-GLOBAL flag names (manifest [[flag]]) are resolvable here too -- a member may gate on a
796
+ # cross-CAMPAIGN flag SET by a sibling campaign (or the [journey.seed]); without them the member's
797
+ # `requires_flag = "<journey-global>"` mis-reads as "unknown flag name" and BLOCKS the deploy (the
798
+ # exact lint/build disagreement that broke the journey-global tier). The campaign's own name wins.
799
+ jglobal = dict(extra_flag_names or {}) # name -> absolute index
800
+ known = {**jglobal, **shared}
801
+ jglobal_idx = set(jglobal.values())
802
+ producers, consumers = {}, []
803
+ for m in plan.members:
804
+ raw = member_raw.get(m.name)
805
+ if raw is None:
806
+ continue
807
+ try: # resolve member-own + shared + journey-global NAME gates ->
808
+ resolve_project_flags(raw, extra_names=known) # indices, so a name dependency is seen (not skipped)
809
+ except ValueError as ex: # a gate on a name defined NOWHERE -> the build would fail too
810
+ errors.append(f"member {m.name}: {ex}")
811
+ continue
812
+ prod, cons = _member_flags_from_toml(raw)
813
+ for idx in prod:
814
+ producers.setdefault(idx, set()).add(m.name)
815
+ for idx in cons:
816
+ consumers.append((idx, m.name))
817
+ for idx, who in consumers:
818
+ if idx not in producers and idx not in jglobal_idx: # a journey-global gate is set cross-campaign
819
+ warnings.append(f"member {who}: requires explicit flag {idx}, but no member sets it -- "
820
+ f"the gate is permanently locked")
821
+ for idx, who in sorted(producers.items()):
822
+ if len(who) >= 2:
823
+ warnings.append(f"explicit flag {idx} written by multiple members {sorted(who)} -- "
824
+ f"unintended cross-field coupling (use distinct indices)")
825
+
826
+ return errors, warnings
827
+
828
+
829
+ # ---- read-only resolved graph (the campaign-workspace view; pure, no game) ---------------
830
+ @dataclass
831
+ class GraphNode:
832
+ """A member resolved into its place in the chain: its live in/out doors (to member NAMES, not raw ids)
833
+ + onward seams, plus reachability/leaf flags. A pure derived view -- nothing here that isn't already
834
+ in the CampaignPlan."""
835
+ name: str
836
+ new_id: int
837
+ real_id: int
838
+ mode: str
839
+ needs_export: bool
840
+ is_entry: bool
841
+ reachable: bool # reachable from the entry via LIVE edges (seams don't count)
842
+ dead_end: bool # no onward connection at all (no edges, no seams)
843
+ out_edges: list # [{"to": name, "entrance": int, "gated": bool}]
844
+ in_edges: list # [{"frm": name, "entrance": int, "gated": bool}]
845
+ seams: list # [{"to_real": int|"WORLDMAP", "kind": str, "note": str, "to_member"}]
846
+
847
+
848
+ @dataclass
849
+ class CampaignGraph:
850
+ """The whole campaign resolved for navigation/visualization: members as GraphNodes (in BFS-id order) +
851
+ the campaign-level findings a workspace UI surfaces (unreachable members, dead-ends, dangling edges)."""
852
+ entry: "str | None" # resolved entry member name (falls back to first member)
853
+ entry_valid: bool # plan.entry_name actually names a member
854
+ nodes: list # list[GraphNode], in member (id) order
855
+ unreachable: list # member names with no live-door path from the entry
856
+ dead_ends: list # member names with no onward connection
857
+ dangling_edges: list # [[edge]] rows whose from/to is not a member (stale manifest)
858
+ dangling_seams: list # [[seam]] rows whose `from` is not a member (stale manifest)
859
+
860
+ @property
861
+ def by_name(self) -> dict:
862
+ return {n.name: n for n in self.nodes}
863
+
864
+
865
+ def _as_int(v, default=0):
866
+ """``int(v)`` but tolerant -- a malformed/None value (from a hand-edited manifest) degrades to
867
+ ``default`` instead of raising, so campaign_graph keeps its 'never choke on a stale toml' contract."""
868
+ try:
869
+ return int(v)
870
+ except (TypeError, ValueError):
871
+ return default
872
+
873
+
874
+ def campaign_graph(plan: CampaignPlan) -> CampaignGraph:
875
+ """Resolve a CampaignPlan into a navigable graph: every member with its in/out live doors (to member
876
+ NAMES), onward seams, and reachability from the entry. PURE over the plan -- the retargeted gateways
877
+ live in the member field.tomls, but the manifest's [[edge]] rows mirror them 1:1, so connectivity is
878
+ fully derivable here (no member-toml read, no game install). Tolerant of a stale / hand-edited
879
+ manifest: an edge to a non-member is recorded in ``dangling_edges`` rather than raising (that's
880
+ lint_campaign's job). Entry resolution mirrors deploy_campaign (explicit member name > first member)."""
881
+ names = [m.name for m in plan.members]
882
+ nameset = set(names)
883
+ out_by = {n: [] for n in names}
884
+ in_by = {n: [] for n in names}
885
+ seams_by = {n: [] for n in names}
886
+ dangling_edges, dangling_seams = [], []
887
+ for e in plan.edges:
888
+ frm, to = e.get("frm"), e.get("to")
889
+ gated = bool(e.get("story_conditional"))
890
+ ent = _as_int(e.get("entrance"))
891
+ if frm not in nameset or to not in nameset:
892
+ dangling_edges.append(dict(e))
893
+ continue
894
+ out_by[frm].append({"to": to, "entrance": ent, "gated": gated})
895
+ in_by[to].append({"frm": frm, "entrance": ent, "gated": gated})
896
+ for s in plan.seams:
897
+ frm = s.get("frm")
898
+ if frm in seams_by:
899
+ seams_by[frm].append({"to_real": s.get("to_real"), "kind": s.get("kind"),
900
+ "note": s.get("note", ""), "to_member": s.get("to_member")})
901
+ else: # a seam from a non-member -> surface it, don't drop it
902
+ dangling_seams.append(dict(s))
903
+
904
+ entry_valid = plan.entry_name in nameset
905
+ entry = plan.entry_name if entry_valid else (names[0] if names else None)
906
+
907
+ reached = set() # BFS from the entry over live edges only
908
+ if entry is not None:
909
+ reached.add(entry)
910
+ stack = [entry]
911
+ while stack:
912
+ for nxt in (oe["to"] for oe in out_by.get(stack.pop(), [])):
913
+ if nxt not in reached:
914
+ reached.add(nxt)
915
+ stack.append(nxt)
916
+ # A VERBATIM fork ships each member's WHOLE donor .eb, so its real connectivity -- story-scripted warps,
917
+ # per-door arrival tables, story-gated transitions -- is intact and runs in-game, but the static walk-in-edge
918
+ # BFS can't see it: a whole-zone fork's screens reached only by cutscene, or other-disc room variants, read
919
+ # as "unreachable" though every forked real field WAS reachable in the real game (FF9 has no unused fields).
920
+ # So don't flag verbatim members as unreachable -- it's a false-positive flood, not stranded content. (A
921
+ # DECLARATIVE campaign's reachability IS meaningful: its gateways are authored from these very edges.)
922
+ if getattr(plan, "verbatim", False):
923
+ reached = set(names)
924
+
925
+ nodes = []
926
+ for m in plan.members:
927
+ dead = not out_by[m.name] and not seams_by[m.name]
928
+ nodes.append(GraphNode(
929
+ name=m.name, new_id=m.new_id, real_id=m.real_id, mode=m.mode, needs_export=m.needs_export,
930
+ is_entry=(m.name == entry), reachable=(m.name in reached), dead_end=dead,
931
+ out_edges=out_by[m.name], in_edges=in_by[m.name], seams=seams_by[m.name]))
932
+ return CampaignGraph(
933
+ entry=entry, entry_valid=entry_valid, nodes=nodes,
934
+ unreachable=[m.name for m in plan.members if m.name not in reached],
935
+ dead_ends=[n.name for n in nodes if n.dead_end],
936
+ dangling_edges=dangling_edges, dangling_seams=dangling_seams)
937
+
938
+
939
+ def render_graph(plan: CampaignPlan) -> str:
940
+ """A human-readable view of a LOADED campaign's connectivity -- the post-fork twin of chain.render
941
+ (which only works on a fresh GraphResult). Each member, its live doors resolved to member names, onward
942
+ seams, and dead-end / unreachable / needs-export flags. Backs the `lint-campaign --graph` CLI + the
943
+ campaign workspace's text graph panel."""
944
+ g = campaign_graph(plan)
945
+ ids = [m.new_id for m in plan.members]
946
+ rng = f"{min(ids)}..{max(ids)}" if ids else "-"
947
+ note = "" if g.entry_valid or not plan.members else " (entry_field not a member -- using first)"
948
+ out = [f"campaign {plan.name} ({len(plan.members)} members, ids {rng}) "
949
+ f"entry: {g.entry or '(none)'} (entrance {plan.entry_entrance}){note}", ""]
950
+ for n in g.nodes:
951
+ tags = []
952
+ if n.is_entry:
953
+ tags.append("ENTRY")
954
+ if n.mode != "borrow":
955
+ tags.append(n.mode)
956
+ if n.needs_export:
957
+ tags.append("needs-export")
958
+ if not n.reachable:
959
+ tags.append("UNREACHABLE")
960
+ if n.dead_end:
961
+ tags.append("dead-end")
962
+ tagstr = (" [" + ", ".join(tags) + "]") if tags else ""
963
+ out.append(f"{n.name:<16} id={n.new_id} (was {n.real_id}){tagstr}")
964
+ for oe in n.out_edges:
965
+ out.append(f" -> {oe['to']} (entrance {oe['entrance']})" + (" [gated]" if oe["gated"] else ""))
966
+ for s in n.seams:
967
+ tgt = s["to_member"] or ("WORLDMAP" if s["to_real"] == "WORLDMAP" else s["to_real"])
968
+ out.append(f" ~> seam[{s['kind']}] -> {tgt}" + (f" ({s['note']})" if s.get("note") else ""))
969
+ if not n.out_edges and not n.seams:
970
+ out.append(" (no onward connections)")
971
+ out.append("")
972
+ if g.unreachable:
973
+ out.append("UNREACHABLE FROM ENTRY: " + ", ".join(g.unreachable))
974
+ if g.dangling_edges:
975
+ out.append("DANGLING EDGES (target not a member -- stale manifest?): "
976
+ + ", ".join(f"{e.get('frm')}->{e.get('to')}" for e in g.dangling_edges))
977
+ if g.dangling_seams:
978
+ out.append("DANGLING SEAMS (from not a member -- stale manifest?): "
979
+ + ", ".join(f"{s.get('frm')}->{s.get('to_real')}" for s in g.dangling_seams))
980
+ return "\n".join(out).rstrip() + "\n"
981
+
982
+
983
+ # ---- P6: mutation / creation API (author/edit a campaign WITHOUT import-chain) -----------
984
+ # import-chain FORKS a connected real-game region; this is the from-scratch / hand-edit twin -- create an
985
+ # empty campaign and add/remove/rename members + edges by hand. Every mutation re-renders campaign.toml
986
+ # through render_campaign_toml so the manifest stays the single round-trip-safe source of truth, and ids
987
+ # are next-free (never renumbered -- a renumber would have to rewrite every member's retargeted gateways).
988
+ def _save_plan(plan: CampaignPlan, manifest_dir) -> Path:
989
+ """(Re)write campaign.toml from the in-memory plan -- the single persistence point for every mutation."""
990
+ p = Path(manifest_dir) / "campaign.toml"
991
+ p.write_text(render_campaign_toml(plan), encoding="utf-8", newline="\n")
992
+ return p
993
+
994
+
995
+ def validate_shared_flags(flags) -> list:
996
+ """Normalize + validate a campaign's SHARED named flags (a list of ``{name, index}``): non-empty unique
997
+ names, integer indices that are UNIQUE and in the provably-safe custom band ``[FIRST_SAFE_FLAG,
998
+ CHOICE_SCRATCH_FLOOR)`` (so a shared flag can't collide with FF9's real bits or the choice scratch).
999
+ Returns the cleaned list; raises :class:`CampaignError` on the first problem."""
1000
+ out, seen_n, seen_i = [], set(), set()
1001
+ for f in flags or []:
1002
+ nm = str((f or {}).get("name", "")).strip()
1003
+ if not nm:
1004
+ raise CampaignError("a shared flag needs a name")
1005
+ if nm in seen_n:
1006
+ raise CampaignError(f"duplicate shared-flag name {nm!r}")
1007
+ try:
1008
+ idx = int(f.get("index"))
1009
+ except (TypeError, ValueError):
1010
+ raise CampaignError(f"shared flag {nm!r} needs an integer index")
1011
+ if not (FIRST_SAFE_FLAG <= idx < CHOICE_SCRATCH_FLOOR):
1012
+ raise CampaignError(f"shared flag {nm!r} index {idx} is outside the safe band "
1013
+ f"[{FIRST_SAFE_FLAG}, {CHOICE_SCRATCH_FLOOR})")
1014
+ if idx in seen_i:
1015
+ raise CampaignError(f"shared-flag index {idx} is used by two flags")
1016
+ seen_n.add(nm)
1017
+ seen_i.add(idx)
1018
+ out.append({"name": nm, "index": idx})
1019
+ return out
1020
+
1021
+
1022
+ def set_shared_flags(plan: CampaignPlan, manifest_dir, flags) -> Path:
1023
+ """Replace the campaign's SHARED ``[[flag]]`` table (cross-field named story flags every member gates by
1024
+ name) + re-render campaign.toml. ``flags`` = a list of ``{name, index}``; validated by
1025
+ :func:`validate_shared_flags`. The build already hands these names to every member (``flag_names``)."""
1026
+ plan.flags = validate_shared_flags(flags)
1027
+ return _save_plan(plan, manifest_dir)
1028
+
1029
+
1030
+ def _next_member_id(plan: CampaignPlan) -> int:
1031
+ """Next free member id: max existing + 1 (ids needn't be contiguous; removes leave gaps), or id_base
1032
+ for the first member. Never renumbers existing members (that would rewrite their retargeted gateways)."""
1033
+ return max((m.new_id for m in plan.members), default=plan.id_base - 1) + 1
1034
+
1035
+
1036
+ def _subdir_of(member: Member) -> str:
1037
+ """The member's on-disk subdir (the first path component of toml_rel)."""
1038
+ return Path(member.toml_rel).parts[0]
1039
+
1040
+
1041
+ def _within(base, path) -> bool:
1042
+ """True if ``path`` resolves to ``base`` itself or somewhere inside it -- the guard that keeps a
1043
+ crafted/stale ``toml_rel`` (``../..``) from letting a mutation rename/rmtree/read OUTSIDE the campaign."""
1044
+ base, path = Path(base).resolve(), Path(path).resolve()
1045
+ return path == base or base in path.parents
1046
+
1047
+
1048
+ def _validate_member_name(name: str) -> str:
1049
+ """A member name is a simple token -- it becomes an on-disk subdir + the key edges/seams reference, so
1050
+ no path separators / traversal / surrounding whitespace."""
1051
+ name = str(name)
1052
+ if not name or name != name.strip() or name in (".", "..") or any(c in name for c in "/\\"):
1053
+ raise CampaignError(f"invalid member name {name!r} (no path separators / leading-trailing space)")
1054
+ return name
1055
+
1056
+
1057
+ def _safe_member_dir(manifest_dir, member: Member) -> Path:
1058
+ """A member's subdir, RESOLVED and validated to stay within manifest_dir -- the guard before any
1059
+ destructive rename/rmtree (a crafted toml_rel must never reach outside the campaign folder)."""
1060
+ sub = Path(manifest_dir) / _subdir_of(member)
1061
+ if not _within(manifest_dir, sub):
1062
+ raise CampaignError(f"member {member.name!r}: subdir escapes the campaign folder ({member.toml_rel})")
1063
+ return sub.resolve()
1064
+
1065
+
1066
+ def _resolve_source_id(source) -> int:
1067
+ """A real field reference (an id, or a unique FBG-folder substring) -> its FIELD ID (the donor). For
1068
+ add_field's fork path. The ID is what disambiguates a folder SHARED by several fields (the same room at
1069
+ different story beats, e.g. 52/3008) -- a folder name can't, so a shared-folder substring is rejected
1070
+ (pass the id). Raises if it's not a single known field. (Mirrors import-chain's fork-by-id; the donor must
1071
+ resolve its OWN .eb, not the folder-keyed winner.)"""
1072
+ from . import extract
1073
+ try:
1074
+ fid = int(source)
1075
+ if fid in extract.ID_TO_FBG:
1076
+ return fid
1077
+ except (TypeError, ValueError):
1078
+ pass
1079
+ s = str(source).lower()
1080
+ hits = sorted(fid for fid, f in extract.ID_TO_FBG.items() if s in f.lower())
1081
+ if len(hits) == 1:
1082
+ return hits[0]
1083
+ raise CampaignError(f"source {source!r} matched {len(hits)} fields {hits[:8]} -- give a field id "
1084
+ f"(a shared FBG folder maps to several fields) or a unique FBG name")
1085
+
1086
+
1087
+ def new_campaign(name, mod_folder, manifest_dir, *, id_base=4000, flag_base=FIRST_SAFE_FLAG,
1088
+ flags_per_field=64, entry_entrance=0) -> CampaignPlan:
1089
+ """Create an EMPTY campaign (no members) and write its campaign.toml -- the from-scratch path that
1090
+ import-chain (which forks a real region) doesn't cover. Add members with :func:`add_field`. The default
1091
+ flag_base is the census-grounded safe floor (clear of real-FF9 chest flags); see :mod:`flags`."""
1092
+ if not (4000 <= id_base <= 32767):
1093
+ raise CampaignError(f"id_base {id_base} out of range (must be 4000-32767)")
1094
+ plan = CampaignPlan(name=str(name), mod_folder=str(mod_folder), id_base=int(id_base),
1095
+ flag_base=int(flag_base), flags_per_field=int(flags_per_field),
1096
+ entry_name="", entry_entrance=int(entry_entrance))
1097
+ Path(manifest_dir).mkdir(parents=True, exist_ok=True)
1098
+ _save_plan(plan, manifest_dir)
1099
+ return plan
1100
+
1101
+
1102
+ def add_field(plan: CampaignPlan, manifest_dir, *, name, source=None, game=None) -> Member:
1103
+ """Add a member to a campaign + re-render campaign.toml. ``source=None`` scaffolds a BLANK room
1104
+ (offline, via pack.new_project -- placeholder art, walkable). A ``source`` (a real field id or a unique
1105
+ FBG-folder substring) FORKS that real field (needs the game install, like import-chain), retargeting
1106
+ any gateway that points at an existing member. The member gets the next free id (no renumber). The
1107
+ first member added becomes the entry if none is set yet."""
1108
+ from . import extract
1109
+ name = _validate_member_name(name)
1110
+ manifest_dir = Path(manifest_dir)
1111
+ if any(m.name == name for m in plan.members):
1112
+ raise CampaignError(f"member name {name!r} is already in this campaign")
1113
+ new_id = _next_member_id(plan)
1114
+ if new_id > 32767:
1115
+ raise CampaignError(f"next member id {new_id} exceeds 32767 (the live fldMapNo is Int16)")
1116
+ if source is None: # blank/template member -- fully offline
1117
+ from . import pack
1118
+ pack.new_project(name, manifest_dir, field_id=new_id, area=11)
1119
+ member = Member(0, new_id, name, "editable", 11, "", f"{name}/{name.lower()}.field.toml", False)
1120
+ else: # fork a real field -- needs the game
1121
+ real_id = _resolve_source_id(source) # the donor ID (disambiguates a shared FBG folder)
1122
+ folder = extract.ID_TO_FBG[real_id]
1123
+ donor = str(real_id) # fork by ID, not the (possibly shared) folder name --
1124
+ # so the writers ship THIS field's .eb/scene, not the folder-keyed winner (mirrors write_campaign).
1125
+ area, _ = extract.parse_fbg_folder(folder)
1126
+ mode = "borrow" if area >= extract.MIN_CUSTOM_AREA else "native"
1127
+ mdir = manifest_dir / name
1128
+ mdir.mkdir(parents=True, exist_ok=True)
1129
+ remap = {m.real_id: m.new_id for m in plan.members if m.real_id}
1130
+ remap[real_id] = new_id # so a self/back-reference retargets to this member
1131
+ needs_export = False
1132
+ try: # area<10 forks NATIVE (own atlas+.bgs, no .bgx)
1133
+ fork = extract.write_field_project if mode == "borrow" else extract.write_native_project
1134
+ _meta, p = fork(donor, mdir, name=name, field_id=new_id, game=game, id_remap=remap)
1135
+ except RuntimeError: # a field with no usable background atlas (rare)
1136
+ if mode == "borrow":
1137
+ raise
1138
+ _meta, p = _emit_logic_only_member(donor, mdir, name, new_id, remap, False, game)
1139
+ needs_export = True
1140
+ member = Member(real_id, new_id, name, mode, area, folder, f"{name}/{p.name}", needs_export)
1141
+ plan.members.append(member)
1142
+ if not plan.entry_name:
1143
+ plan.entry_name = name
1144
+ _save_plan(plan, manifest_dir)
1145
+ return member
1146
+
1147
+
1148
+ def remove_field(plan: CampaignPlan, manifest_dir, name) -> None:
1149
+ """Drop a member from the campaign: remove its subdir, prune every edge/seam that referenced it, and
1150
+ re-point the entry if it was the removed member. Leaves an id gap (no renumber)."""
1151
+ import shutil
1152
+ manifest_dir = Path(manifest_dir)
1153
+ m = next((x for x in plan.members if x.name == name), None)
1154
+ if m is None:
1155
+ raise CampaignError(f"no member named {name!r}")
1156
+ mdir = _safe_member_dir(manifest_dir, m) # validate within manifest_dir BEFORE any mutation
1157
+ plan.members.remove(m)
1158
+ plan.edges = [e for e in plan.edges if e.get("frm") != name and e.get("to") != name]
1159
+ plan.seams = [s for s in plan.seams if s.get("frm") != name]
1160
+ for s in plan.seams:
1161
+ if s.get("to_member") == name:
1162
+ s["to_member"] = None
1163
+ if plan.entry_name == name:
1164
+ plan.entry_name = plan.members[0].name if plan.members else ""
1165
+ if mdir.is_dir():
1166
+ shutil.rmtree(mdir, ignore_errors=True)
1167
+ _save_plan(plan, manifest_dir)
1168
+
1169
+
1170
+ def rename_field(plan: CampaignPlan, manifest_dir, old, new) -> None:
1171
+ """Rename a member's STRUCTURAL identity: its subdir + toml_rel + campaign.toml name, and rekey every
1172
+ edge/seam/entry that referenced it. Does NOT touch the field's in-game ``[field] name`` (that's the
1173
+ separate display name the Logic Editor owns) or the inner field.toml filename -- a structural rename
1174
+ only. Ids are unchanged, so no member's gateways need rewriting."""
1175
+ manifest_dir = Path(manifest_dir)
1176
+ m = next((x for x in plan.members if x.name == old), None)
1177
+ if m is None:
1178
+ raise CampaignError(f"no member named {old!r}")
1179
+ new = _validate_member_name(new)
1180
+ if old == new:
1181
+ return
1182
+ if any(x.name == new for x in plan.members):
1183
+ raise CampaignError(f"member name {new!r} is already in this campaign")
1184
+ old_dir = _safe_member_dir(manifest_dir, m) # validated within manifest_dir before the rename
1185
+ new_dir = (manifest_dir / new).resolve()
1186
+ if old_dir.is_dir() and old_dir != new_dir:
1187
+ if new_dir.exists():
1188
+ raise CampaignError(f"cannot rename onto existing path {new_dir}")
1189
+ old_dir.rename(new_dir)
1190
+ m.toml_rel = f"{new}/{Path(m.toml_rel).name}" # keep the inner filename; swap the subdir
1191
+ m.name = new
1192
+ for e in plan.edges:
1193
+ if e.get("frm") == old:
1194
+ e["frm"] = new
1195
+ if e.get("to") == old:
1196
+ e["to"] = new
1197
+ for s in plan.seams:
1198
+ if s.get("frm") == old:
1199
+ s["frm"] = new
1200
+ if s.get("to_member") == old:
1201
+ s["to_member"] = new
1202
+ if plan.entry_name == old:
1203
+ plan.entry_name = new
1204
+ _save_plan(plan, manifest_dir)
1205
+
1206
+
1207
+ def set_entry(plan: CampaignPlan, manifest_dir, name, *, entrance=None) -> None:
1208
+ """Set the campaign's entry member (and optionally its entrance). Validates the member exists."""
1209
+ if name not in {m.name for m in plan.members}:
1210
+ raise CampaignError(f"entry {name!r} is not a campaign member")
1211
+ plan.entry_name = name
1212
+ if entrance is not None:
1213
+ plan.entry_entrance = int(entrance)
1214
+ _save_plan(plan, manifest_dir)
1215
+
1216
+
1217
+ def add_edge(plan: CampaignPlan, manifest_dir, frm, to, *, entrance=0, gated=False) -> None:
1218
+ """Record an in-chain connection in the graph (campaign.toml [[edge]]). NOTE: this is the graph-level
1219
+ reflection of connectivity -- the LIVE door is a ``[[gateway]]`` you author in the source member's
1220
+ field.toml (the Logic Editor). Both ends must be members."""
1221
+ names = {m.name for m in plan.members}
1222
+ if frm not in names or to not in names:
1223
+ raise CampaignError(f"edge {frm!r}->{to!r}: both ends must be campaign members")
1224
+ plan.edges.append({"frm": frm, "to": to, "entrance": int(entrance),
1225
+ "story_conditional": bool(gated)})
1226
+ _save_plan(plan, manifest_dir)
1227
+
1228
+
1229
+ def remove_edge(plan: CampaignPlan, manifest_dir, frm, to) -> None:
1230
+ """Remove the graph edge(s) frm->to (campaign.toml [[edge]])."""
1231
+ plan.edges = [e for e in plan.edges if not (e.get("frm") == frm and e.get("to") == to)]
1232
+ _save_plan(plan, manifest_dir)
1233
+
1234
+
1235
+ def _shared_flag_floor(plan: CampaignPlan) -> int:
1236
+ """The lowest index a shared [[flag]] may take: just ABOVE every per-member auto-flag block (which span
1237
+ [flag_base, flag_base + members*K)), and never below the census-safe floor."""
1238
+ block_hi = plan.flag_base + len(plan.members) * plan.flags_per_field - 1
1239
+ return max(block_hi + 1, FIRST_SAFE_FLAG)
1240
+
1241
+
1242
+ def add_flag(plan: CampaignPlan, manifest_dir, name, index=None) -> dict:
1243
+ """Add a shared NAMED campaign flag (a cross-field story gate) to campaign.toml's [[flag]] table; members
1244
+ then gate by NAME (``requires_flag = "<name>"``). ``index=None`` auto-picks the next free safe index
1245
+ ABOVE the per-member auto-flag blocks (inside [FIRST_SAFE_FLAG, CHOICE_SCRATCH_FLOOR)). Validates name +
1246
+ band; returns the new ``{name, index}``."""
1247
+ name = str(name).strip()
1248
+ if not name:
1249
+ raise CampaignError("a shared flag needs a name")
1250
+ floor = _shared_flag_floor(plan)
1251
+ used = {int(f.get("index", -1)) for f in plan.flags}
1252
+ if index is None:
1253
+ index = max([floor] + [i + 1 for i in used])
1254
+ index = int(index)
1255
+ if not (floor <= index < CHOICE_SCRATCH_FLOOR):
1256
+ raise CampaignError(f"flag index {index} must be in [{floor}, {CHOICE_SCRATCH_FLOOR}) -- above the "
1257
+ f"per-member auto-flag blocks, below the choice scratch")
1258
+ if index in used:
1259
+ raise CampaignError(f"flag index {index} is already used by another shared flag")
1260
+ plan.flags.append({"name": name, "index": index})
1261
+ try:
1262
+ collect_flag_defs({"flag": plan.flags}) # re-validate (dup name, safe band); rollback on failure
1263
+ except ValueError as e:
1264
+ plan.flags.pop()
1265
+ raise CampaignError(str(e))
1266
+ _save_plan(plan, manifest_dir)
1267
+ return {"name": name, "index": index}
1268
+
1269
+
1270
+ def remove_flag(plan: CampaignPlan, manifest_dir, name) -> None:
1271
+ """Remove the shared named flag ``name`` from the campaign's [[flag]] table."""
1272
+ keep = [f for f in plan.flags if f.get("name") != name]
1273
+ if len(keep) == len(plan.flags):
1274
+ raise CampaignError(f"no shared flag named {name!r}")
1275
+ plan.flags = keep
1276
+ _save_plan(plan, manifest_dir)