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/extract.py ADDED
@@ -0,0 +1,2279 @@
1
+ #!/usr/bin/env python3
2
+ """Import support: pull a REAL FF9 field's scene data out of the game's p0data bundles, OFFLINE.
3
+
4
+ This is the data-gathering half of `ff9mapkit import` ("fork any field as a base"). It reads a
5
+ field's native assets straight from StreamingAssets/p0data*.bin (UnityRaw 5.2.3 assetbundles) with
6
+ no in-game step, and hands them to the kit's existing parsers:
7
+
8
+ <fbg>.bgs.bytes -> scene.bgs.parse_cameras (the field's camera(s))
9
+ <fbg>.bgi.bytes -> scene.bgi.BgiWalkmesh (walkmesh + player start)
10
+ atlas.png -> the packed background art (only pulled when want_atlas=True)
11
+
12
+ UnityPy is imported lazily: only `extract`/`import` need it, the core kit stays pure-stdlib.
13
+
14
+ Proven against GRGR in the offline spike (2026-06-04): the decoded camera matched the engine's own
15
+ .bgx export byte-for-byte.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import glob
20
+ import json
21
+ import os
22
+ import re
23
+ from collections import OrderedDict
24
+ from pathlib import Path
25
+
26
+ from . import config
27
+ from . import eventscan
28
+ from ._fieldtable import FBG_TO_EVT, FIELD_BY_ID
29
+ from .scene import bgart, bgs, bgi, cam
30
+
31
+
32
+ def _unitypy():
33
+ try:
34
+ import UnityPy # noqa: PLC0415
35
+ return UnityPy
36
+ except ImportError as e: # pragma: no cover - environment dependent
37
+ raise RuntimeError(
38
+ "extraction needs UnityPy (reads FF9's p0data assetbundles). Install it:\n"
39
+ " py -m pip install UnityPy"
40
+ ) from e
41
+
42
+
43
+ def _raw_bytes(data):
44
+ """Raw bytes of a TextAsset across UnityPy versions."""
45
+ for attr in ("m_Script", "script"):
46
+ v = getattr(data, attr, None)
47
+ if isinstance(v, bytes):
48
+ return v
49
+ if isinstance(v, str):
50
+ return v.encode("utf-8", "surrogateescape")
51
+ return None
52
+
53
+
54
+ _FBG_RE = re.compile(r"^fbg_n(\d+)_(.+)$", re.I)
55
+
56
+
57
+ def parse_fbg_folder(folder: str):
58
+ """('fbg_n21_grgr_map420_gr_cen_0') -> (area:int=21, mapid:str='GRGR_MAP420_GR_CEN_0').
59
+
60
+ `mapid` is the DictionaryPatch BG id (the part after `FBG_N<area>_`); the engine rebuilds
61
+ `FBG_N<area>_<mapid>` for the BG lookup (proven Session-4 BG-borrow)."""
62
+ m = _FBG_RE.match(folder.strip().lower())
63
+ if not m:
64
+ raise ValueError(f"not an FBG field folder: {folder!r}")
65
+ return int(m.group(1)), m.group(2).upper()
66
+
67
+
68
+ def _streaming_assets(game=None) -> Path:
69
+ return config.find_game_path(game) / "StreamingAssets"
70
+
71
+
72
+ def _bundles(game=None):
73
+ return sorted(glob.glob(str(_streaming_assets(game) / "p0data*.bin")))
74
+
75
+
76
+ # In-process cache of loaded STATIC base-game bundles (read-only: StreamingAssets/p0dataN.bin never
77
+ # change under us at runtime). Keyed by absolute path, bounded LRU -- so a long-lived process (the test
78
+ # suite, the import-chain walk, a fork that reads the event bundle for .eb + MapConfig) loads the hot
79
+ # 68 MB event bundle ONCE instead of re-reading it on every call. Re-reading it cold is exactly what
80
+ # starves callers when other processes thrash the OS file cache. Mirrors _load_mod_bundle, but kept
81
+ # SEPARATE: mod-folder bundles DO mutate on deploy, base-game bundles never do. The cap bounds memory
82
+ # while the constantly-touched event bundle naturally stays resident (LRU recency keeps the hot one).
83
+ _STREAM_ENV_CACHE: "OrderedDict[str, object]" = OrderedDict()
84
+ _STREAM_ENV_CACHE_MAX = 8
85
+
86
+
87
+ def _load_env(path):
88
+ """``UnityPy.load`` a static base-game bundle, memoized (bounded LRU) by absolute path. Read-only
89
+ base data ONLY -- never a mod-folder bundle (those mutate on deploy; use ``_load_mod_bundle``)."""
90
+ key = os.path.abspath(str(path))
91
+ env = _STREAM_ENV_CACHE.get(key)
92
+ if env is not None:
93
+ _STREAM_ENV_CACHE.move_to_end(key)
94
+ return env
95
+ env = _unitypy().load(key)
96
+ _STREAM_ENV_CACHE[key] = env
97
+ while len(_STREAM_ENV_CACHE) > _STREAM_ENV_CACHE_MAX:
98
+ _STREAM_ENV_CACHE.popitem(last=False)
99
+ return env
100
+
101
+
102
+ # ---- field -> bundle index (built once, cached; so lookups don't rescan ~50 bundles) ----
103
+ INDEX_NAME = ".ff9mapkit-field-index.json"
104
+
105
+
106
+ def _index_path(game=None) -> Path:
107
+ return _streaming_assets(game) / INDEX_NAME
108
+
109
+
110
+ def build_field_index(game=None, *, force=False, verbose=True) -> dict:
111
+ """Map every field folder -> its p0data bundle. Cached next to the bundles; first build scans
112
+ them all (~1-2 min), then it's instant. `force=True` rebuilds."""
113
+ UnityPy = _unitypy()
114
+ idx = _index_path(game)
115
+ if idx.exists() and not force:
116
+ try:
117
+ return json.loads(idx.read_text(encoding="utf-8"))
118
+ except (OSError, ValueError):
119
+ pass
120
+ index = {}
121
+ bundles = _bundles(game)
122
+ for i, path in enumerate(bundles):
123
+ if verbose:
124
+ print(f" indexing {i + 1}/{len(bundles)} {os.path.basename(path)} ...", flush=True)
125
+ try:
126
+ env = UnityPy.load(path)
127
+ except Exception:
128
+ continue
129
+ bn = os.path.basename(path)
130
+ for k in env.container:
131
+ m = re.search(r"fieldmaps/([^/]+)/", k.lower())
132
+ if m:
133
+ index.setdefault(m.group(1), bn)
134
+ try:
135
+ idx.write_text(json.dumps(index, indent=0), encoding="utf-8")
136
+ except OSError:
137
+ pass
138
+ if verbose:
139
+ print(f" indexed {len(index)} fields -> {idx}")
140
+ return index
141
+
142
+
143
+ def resolve_field(field: str, game=None):
144
+ """(folder, bundle) for a field token via the index. A pure-DIGIT token is a FIELD ID (parity with
145
+ ``fork-report`` / ``eb_for_id`` -- so ``import <id>`` forks the SAME field the analysis commands describe),
146
+ NOT a folder substring: field ids and the folder ``map<NNN>`` numbers are UNRELATED schemes (id 100 =
147
+ Alexandria, not the ``map100`` Dali field). To match a folder by its map number, pass an FBG/mapid
148
+ substring (e.g. ``map100`` / ``vgdl_map100``)."""
149
+ index = build_field_index(game, verbose=True)
150
+ tok = field.strip()
151
+ if tok.isdigit() and int(tok) in ID_TO_FBG: # a field id -> its folder (NOT a map<NNN> substring)
152
+ folder = ID_TO_FBG[int(tok)]
153
+ if folder in index:
154
+ return folder, index[folder]
155
+ raise FileNotFoundError(f"field id {tok} ({folder}) has no live field bundle -- not a forkable field")
156
+ want = re.sub(r"^fbg_n\d+_", "", tok.lower())
157
+ if tok.lower() in index:
158
+ return tok.lower(), index[tok.lower()]
159
+ hits = [f for f in index if want in f]
160
+ if not hits:
161
+ raise FileNotFoundError(f"no field matches {field!r}. Try: ff9mapkit list-fields {want}")
162
+ if len(hits) > 1:
163
+ raise ValueError(f"{field!r} matches {len(hits)} fields; be more specific. e.g. {sorted(hits)[:8]}")
164
+ return hits[0], index[hits[0]]
165
+
166
+
167
+ def list_fields(pattern=None, game=None):
168
+ """Sorted (folder, area, mapid) for all fields (optionally filtered by substring)."""
169
+ index = build_field_index(game, verbose=False)
170
+ pat = pattern.lower() if pattern else None
171
+ out = []
172
+ for folder in sorted(index):
173
+ if pat and pat not in folder:
174
+ continue
175
+ try:
176
+ area, mapid = parse_fbg_folder(folder)
177
+ except ValueError:
178
+ continue
179
+ out.append((folder, area, mapid))
180
+ return out
181
+
182
+
183
+ def _repo_root() -> Path:
184
+ """Repo root (…/ff9mapkit/ff9mapkit/extract.py -> repo) -- to locate the user-local reference data
185
+ (the HW manifest + the import-all archive). Both are gitignored, so callers degrade if absent."""
186
+ return Path(__file__).resolve().parents[2]
187
+
188
+
189
+ def _manifest_field_names() -> dict:
190
+ """{field_id: friendly name} from the (gitignored, user-local) HW `reference/field-manifest.tsv`, or
191
+ {} if it isn't present. col2 = id, col3 = name (e.g. 'Memoria/Outside')."""
192
+ p = _repo_root() / "reference" / "field-manifest.tsv"
193
+ names: dict = {}
194
+ if p.exists():
195
+ for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
196
+ parts = line.split("\t")
197
+ if len(parts) >= 3 and parts[1].isdigit():
198
+ names.setdefault(int(parts[1]), parts[2])
199
+ return names
200
+
201
+
202
+ def _archive_folder_index(archive_dir=None) -> dict:
203
+ """{UPPER-FBG: folder path} for an `import-all` archive (default `reference/all-fields-import`), or {}
204
+ if that dir is absent -- so find_fields can show where a field was imported without requiring it."""
205
+ base = Path(archive_dir) if archive_dir else (_repo_root() / "reference" / "all-fields-import")
206
+ if not base.is_dir():
207
+ return {}
208
+ return {p.name.upper(): str(p) for p in base.glob("*/*") if p.is_dir()}
209
+
210
+
211
+ def find_fields(query, *, archive_dir=None) -> list:
212
+ """Resolve a field id / name / FBG-or-EVT substring -> the matching real fields: a list of
213
+ {id, fbg, evt, name, folder} dicts sorted by id. A DIGIT query is an EXACT id match; otherwise a
214
+ case-insensitive substring over id / FBG folder / EVT name / friendly name. `name` is the friendly
215
+ HW-manifest name when the manifest is present (else ''); `folder` is the field's subdir under the
216
+ import-all archive when present (else None). PURE table lookup (the in-package FBG_TO_EVT) -- no
217
+ install / UnityPy needed; the manifest name + archive folder are best-effort extras."""
218
+ from ._fieldtable import FBG_TO_EVT
219
+ q = str(query).strip()
220
+ by_id = q.isdigit()
221
+ qid = int(q) if by_id else None
222
+ ql = q.lower()
223
+ names = _manifest_field_names()
224
+ folders = _archive_folder_index(archive_dir)
225
+ rows = []
226
+ for fbg, (fid, evt) in FBG_TO_EVT.items():
227
+ name = names.get(fid, "")
228
+ hit = (fid == qid) if by_id else (ql in f"{fid} {fbg} {evt} {name}".lower())
229
+ if hit:
230
+ rows.append({"id": fid, "fbg": fbg, "evt": evt, "name": name,
231
+ "folder": folders.get(fbg.upper())})
232
+ rows.sort(key=lambda r: r["id"])
233
+ return rows
234
+
235
+
236
+ # ---- event script (.eb) extraction: fork a field WITH its gateways/music/encounters -----
237
+ EVT_LANG = "us" # event binaries are per-language; the bytecode we scan is identical
238
+ _EVENTS_BUNDLE_CACHE = ".ff9mapkit-events-bundle.txt"
239
+
240
+
241
+ def _events_bundle(game=None):
242
+ """The p0data bundle holding field event binaries (``eventbinary/field/...``). Cached next to the
243
+ bundles; on a miss it's detected p0data7-first (where they historically live), so the common case
244
+ loads one bundle, not all of them."""
245
+ cache = _streaming_assets(game) / _EVENTS_BUNDLE_CACHE
246
+ if cache.exists():
247
+ name = cache.read_text(encoding="utf-8").strip()
248
+ if name:
249
+ return name
250
+ UnityPy = _unitypy()
251
+ bundles = sorted(_bundles(game),
252
+ key=lambda p: (0 if "p0data7." in os.path.basename(p) else 1, p))
253
+ for path in bundles:
254
+ try:
255
+ env = UnityPy.load(path)
256
+ except Exception:
257
+ continue
258
+ if any("eventbinary/field/" in k.lower() for k in env.container):
259
+ name = os.path.basename(path)
260
+ try:
261
+ cache.write_text(name, encoding="utf-8")
262
+ except OSError:
263
+ pass
264
+ return name
265
+ return None
266
+
267
+
268
+ def event_name_for(field: str, game=None):
269
+ """The ``EVT_<name>`` event-script name for an imported field, or None if it isn't a standard field map
270
+ (world/special fields have no field event). A pure-DIGIT token resolves by FIELD ID straight from the
271
+ id-keyed table -- so a field that SHARES its FBG folder with another (the same room at a different story
272
+ beat, e.g. 52/3008) gets its OWN event, not the folder-keyed table's single winner. A name/substring token
273
+ still goes through the folder-keyed table."""
274
+ tok = str(field).strip()
275
+ if tok.isdigit() and int(tok) in ID_TO_EVT:
276
+ return ID_TO_EVT[int(tok)]
277
+ folder, _ = resolve_field(field, game)
278
+ rec = FBG_TO_EVT.get(folder)
279
+ return rec[1] if rec else None
280
+
281
+
282
+ def extract_event_script(field: str, *, game=None, lang: str = EVT_LANG):
283
+ """The compiled ``.eb`` bytes of a real field's event script (``lang``, default us), or None if it
284
+ can't be located (no FBG->event mapping, or the binary isn't present). Used by ``import`` to
285
+ extract gateways / music / encounters / movement from the real field -- it never raises, so a
286
+ missing script just means the fork imports without that content (camera/walkmesh/art are
287
+ unaffected). ``lang`` is also used by provisioning to extract the per-language blank base."""
288
+ try:
289
+ evt = event_name_for(field, game)
290
+ if not evt:
291
+ return None
292
+ bundle = _events_bundle(game)
293
+ if not bundle:
294
+ return None
295
+ env = _load_env(_streaming_assets(game) / bundle)
296
+ want = f"eventbinary/field/{lang}/{evt}.eb".lower()
297
+ for k, obj in env.container.items():
298
+ kl = k.lower()
299
+ if want in kl and kl.endswith(".eb.bytes"):
300
+ return _raw_bytes(obj.read())
301
+ except Exception:
302
+ return None
303
+ return None
304
+
305
+
306
+ def extract_mapconfig(field: str, *, game=None):
307
+ """The field's **MapConfigData** bytes (``CommonAsset/MapConfigData/<EVT_name>``), or None if absent.
308
+
309
+ This config drives the 3D-model LIGHTING the engine applies at field load (``fldmcf.cs``): per-FLOOR
310
+ lights (``DMSMapLight``, keyed by the walkmesh floor) + per-object base colors (``DMSMapChar``) +
311
+ per-floor shadow intensity/scale. A native fork that ships its own scene but NOT this config renders
312
+ every field model untinted/bright (the cave's dim lighting is gone). Shipping it verbatim under the
313
+ fork's event name restores it -- the per-floor lights key on the ``.bgi`` the native fork already
314
+ carries verbatim, so the floors line up. Lives in the SAME bundle as the field event scripts
315
+ (``_events_bundle``); never raises (a missing config just means the fork renders with default light)."""
316
+ try:
317
+ evt = event_name_for(field, game)
318
+ if not evt:
319
+ return None
320
+ bundle = _events_bundle(game)
321
+ if not bundle:
322
+ return None
323
+ env = _load_env(_streaming_assets(game) / bundle)
324
+ want = f"commonasset/mapconfigdata/{evt}.bytes".lower()
325
+ for k, obj in env.container.items():
326
+ if want in k.lower():
327
+ return _raw_bytes(obj.read())
328
+ except Exception:
329
+ return None
330
+ return None
331
+
332
+
333
+ # ---- id-keyed event extraction (the chain walk) -----------------------------------------
334
+ # resolve_field()/event_name_for() are NAME-keyed (substring match on FBG folders), so a bare numeric
335
+ # field id mis-resolves. The graph walk needs id -> .eb DIRECTLY -> the id-keyed FIELD_BY_ID table.
336
+ # (NOT an inversion of the folder-keyed FBG_TO_EVT: ~142 of the 818 real fields SHARE a background folder
337
+ # with another field -- the same room at a different story beat -- so inverting the folder-keyed table
338
+ # DROPPED them, and their Field() warps leaked to the live game during a fork. FIELD_BY_ID keeps every id.)
339
+ ID_TO_FBG = {fid: fbg for fid, (fbg, _evt) in FIELD_BY_ID.items()} # field id -> FBG folder (complete)
340
+ ID_TO_EVT = {fid: evt for fid, (_fbg, evt) in FIELD_BY_ID.items()} # field id -> EVT_ script name (complete)
341
+
342
+
343
+ class EventBundle:
344
+ """The field event-script bundle loaded ONCE, with id -> ``.eb`` bytes lookup + per-id cache.
345
+
346
+ For walking many fields (``import-chain``) without re-loading the bundle per field. Never raises on
347
+ a miss: an id with no FBG/event mapping (world/special field) or whose binary is absent returns None,
348
+ so the walk terminates that branch cleanly (same contract as ``extract_event_script``)."""
349
+
350
+ def __init__(self, game=None, lang: str = EVT_LANG):
351
+ bundle = _events_bundle(game)
352
+ if not bundle:
353
+ raise RuntimeError(
354
+ "could not locate the field event bundle (eventbinary/field/...) in StreamingAssets/p0data*.bin")
355
+ self.lang = lang
356
+ env = _load_env(_streaming_assets(game) / bundle)
357
+ marker = f"eventbinary/field/{lang}/" # container keys carry a leading path -> substring match
358
+ self._by_evt = {}
359
+ for k, obj in env.container.items():
360
+ kl = k.lower()
361
+ i = kl.find(marker)
362
+ if i >= 0 and kl.endswith(".eb.bytes"):
363
+ self._by_evt[kl[i + len(marker):-len(".eb.bytes")]] = obj
364
+ self._cache: dict = {}
365
+
366
+ def eb_for_id(self, field_id: int):
367
+ """``.eb`` bytes for a field id, or None (no FBG/event mapping, or binary absent)."""
368
+ fid = int(field_id)
369
+ if fid in self._cache:
370
+ return self._cache[fid]
371
+ data = None
372
+ evt = ID_TO_EVT.get(fid)
373
+ if evt is not None:
374
+ obj = self._by_evt.get(evt.lower())
375
+ if obj is not None:
376
+ data = _raw_bytes(obj.read())
377
+ self._cache[fid] = data
378
+ return data
379
+
380
+
381
+ def _inst_tbl(it) -> str:
382
+ """One ``InitObject`` instance as a TOML inline table -- the spawn ``arg`` (+ the resolved x/z when
383
+ known, informational; build reads only ``arg`` and the entry self-positions)."""
384
+ parts = [f"arg = {int(it.get('arg', 0))}"]
385
+ if it.get("x") is not None and it.get("z") is not None:
386
+ parts += [f"x = {it['x']}", f"z = {it['z']}"]
387
+ return "{ " + ", ".join(parts) + " }"
388
+
389
+
390
+ def _object_block(o, fn: str, seq_fns=None) -> str:
391
+ """A ``[[object]]`` graft block: the verbatim-entry sidecar + what ``build`` needs to append + arm
392
+ it. ``carry_tags`` is emitted only for an ``init_only`` carry (a ``clean`` object carries whole);
393
+ ``needs_d9`` only for a ``Main_Init``-D9-positioned object (else the entry self-positions); ``seqs``
394
+ (``seq_fns`` = ``[(entry, sidecar)]``) only when the closure carries STARTSEQ helpers for this object."""
395
+ lines = [f'[[object]]\nbin = "{fn}"\nkind = "{o["kind"]}"\ndonor_idx = {o["donor_idx"]}']
396
+ if o.get("donor_player_entry") is not None:
397
+ lines.append(f'donor_player_entry = {o["donor_player_entry"]}')
398
+ dpes = o.get("donor_player_entries") or []
399
+ if len(dpes) > 1: # multi-DefinePlayerCharacter -> the grafter normalizes ALL
400
+ lines.append("donor_player_entries = [" + ", ".join(str(p) for p in dpes) + "]")
401
+ if o["graft_safety"] == "init_only":
402
+ lines.append("carry_tags = [" + ", ".join(str(t) for t in o["carry_tags"]) + "]")
403
+ if o.get("needs_d9"):
404
+ lines.append("needs_d9 = { " + ", ".join(f"{k} = {v}" for k, v in sorted(o["needs_d9"].items())) + " }")
405
+ lines.append("instances = [" + ", ".join(_inst_tbl(it) for it in o["instances"]) + "]")
406
+ if seq_fns:
407
+ lines.append("seqs = [" + ", ".join(f'{{ entry = {ei}, bin = "{sfn}" }}' for ei, sfn in seq_fns) + "]")
408
+ return "\n".join(lines)
409
+
410
+
411
+ def _object_stub(o) -> str:
412
+ """A REFUSED object (its render funcs reference field state a fork lacks) -> a ``[[prop]]``/``[[npc]]``
413
+ author stub (the lossy player-clone path, to fix up by hand)."""
414
+ inst = o["instances"][0] if o["instances"] else {}
415
+ x = inst.get("x") if inst.get("x") is not None else 0
416
+ z = inst.get("z") if inst.get("z") is not None else 0
417
+ mref = f'"{o["model"]}"' if isinstance(o["model"], str) else str(o["model_id"])
418
+ face = f"\nface = {o['face']}" if o.get("face") is not None else ""
419
+ head = "# REFUSED graft (its render funcs reference field state a fork lacks) -- author by hand:\n"
420
+ if o["kind"] == "npc":
421
+ short = o["model"].split("_")[-1] if isinstance(o["model"], str) else str(o["model_id"])
422
+ return (f'{head}[[npc]]\nname = "{short}_{o["donor_idx"]}"\nmodel = {mref}\npos = [{x}, {z}]\n'
423
+ f'dialogue = "..." # TODO: author this NPC\'s dialogue (text not carried){face}')
424
+ pose = f"\npose = {o['pose']}" if o.get("pose") is not None else ""
425
+ return f'{head}[[prop]]\nmodel = {mref}{pose}\npos = [{x}, {z}]{face}'
426
+
427
+
428
+ def _donor_battle_song(field_id, enc, game):
429
+ """The donor field's real random-battle BGM (akao song-play id) for this encounter's PRIMARY scene, or
430
+ ``None`` when unknown / not mapped / the install can't be read. FF9 keys the battle song on
431
+ ``(field id, scene)`` (``battle_bgm``); a fork to a custom id loses that lookup, so ``import`` prefills
432
+ ``[encounter] battle_music`` and the build reproduces it via a SCENE-keyed ``Music:`` BattlePatch line.
433
+ Best-effort: never fails an import over BGM detection."""
434
+ if field_id is None or not enc:
435
+ return None
436
+ try:
437
+ from . import battle_bgm as _bbgm
438
+ return _bbgm.song(field_id, int(enc["scenes"][0]), game)
439
+ except Exception: # noqa: BLE001 -- BGM is a nicety, never block the fork
440
+ return None
441
+
442
+
443
+ def _donor_battle_bgm_pairs(eb_bytes, field_id, game):
444
+ """``(scene, song)`` pairs for a VERBATIM fork: the donor's SCRIPTED battle scenes
445
+ (``Battle``/``BattleEx``, :func:`eventscan.scan_battle_scenes`) with their REAL song -- INCLUDING song 0
446
+ (the standard Battle Theme). A mint's custom ``fldMapNo`` misses the engine's ``(field, scene)`` BGM
447
+ lookup, so the carried ``Battle`` op gets no song and the field BGM bleeds into the battle (often
448
+ silence); each pair makes the build emit a SCENE-keyed ``Music:`` line (``BtlBgmPatcherMapper``) that
449
+ reproduces the donor's song regardless of the custom id. Song 0 IS emitted: a verbatim fork has no
450
+ declarative ``[encounter]`` default to fall back on, so a song-0 scripted battle (e.g. the Baku tutorial
451
+ fight, field 50 scene 336) is SILENT without an explicit ``Music: 0``. Only an UNMAPPED scene (``song``
452
+ is ``None`` -- e.g. the staged-play sword fight, which intentionally keeps the play BGM) is skipped.
453
+ ``[]`` when nothing qualifies. Best-effort: never raises (BGM is a nicety, must not block a fork) -- so a
454
+ missing install / no UnityPy just yields ``[]``."""
455
+ if field_id is None:
456
+ return []
457
+ try:
458
+ from . import battle_bgm as _bbgm
459
+ pairs = []
460
+ for scene in eventscan.scan_battle_scenes(eb_bytes):
461
+ song = _bbgm.song(field_id, scene, game)
462
+ if song is not None: # the donor's real song, 0 (Battle Theme) included
463
+ pairs.append((scene, song))
464
+ return pairs
465
+ except Exception: # noqa: BLE001 -- BGM detection never blocks the fork
466
+ return []
467
+
468
+
469
+ def _render_battle_bgm_blocks(pairs) -> str:
470
+ """``[[battle_bgm]]`` toml for the donor battle-song pairs (or "" if none)."""
471
+ if not pairs:
472
+ return ""
473
+ out = ["# --- Donor BATTLE BGM for the SCRIPTED battles this verbatim fork triggers: a mint loses the\n"
474
+ "# engine's (field, scene) song lookup, so each scene-keyed Music: line reproduces the donor's\n"
475
+ "# real battle theme on the custom id -- including song 0 (the standard Battle Theme), without\n"
476
+ "# which a song-0 scripted battle like the Baku tutorial fight is SILENT. (FORK_FIDELITY.md #6.) ---"]
477
+ out += [f"[[battle_bgm]]\nscene = {scene}\nsong = {song}" for scene, song in pairs]
478
+ return "\n\n".join(out)
479
+
480
+
481
+ def _imported_content_toml(eb_bytes, *, out_dir=None, name="field", id_remap=None, live_seams=False,
482
+ graft_player_funcs=False, carry_text=False, field_id=None, game=None,
483
+ graft_savepoint=False):
484
+ """field.toml blocks (gateways / encounter / music / ladders) + the control-direction value,
485
+ extracted LIVE from a real field's ``.eb``. Returns (blocks_text, control_dir, summary). blocks_text
486
+ is appended at the end of the toml; control_dir (or None) goes in the [camera] block; summary feeds
487
+ the CLI. Ladders carry a binary climb, so they're emitted only when ``out_dir`` is given (each climb
488
+ written verbatim to a ``<name>.ladder<i>.climb.bin`` sidecar that ``build`` grafts faithfully).
489
+
490
+ ``id_remap`` (import-chain / campaign): a ``{real_dest_id: new_id}`` map. When given, each gateway's
491
+ ``to`` is RETARGETED -- in-chain targets become the sibling NEW id; targets OUTSIDE the chain are NOT
492
+ emitted as a live gateway (that would warp the player back into the live game) but as a commented
493
+ seam stub for the author. ``live_seams=True`` keeps out-of-chain targets as live doors instead. When
494
+ ``id_remap is None`` the output is byte-identical to before (single-field ``import`` is unchanged).
495
+ NOTE: the retarget touches ONLY gateway ``to`` ids -- the encounter ``scene =`` (a battle-scene id,
496
+ not a field) is deliberately left alone."""
497
+ content = eventscan.scan_content(eb_bytes)
498
+ parts = []
499
+ gws = content["gateways"]
500
+ n_retargeted = n_seamed = n_story_branch = 0
501
+ # #2b (FORK_FIDELITY.md): a STORY-GATED door (its firing/destination guarded by a complex GLOB-flag
502
+ # conditional) is carried VERBATIM -- the declarative rebuild can't reproduce that state machine. Route
503
+ # self-contained gated entries to a [[gateway_carry]] block (+ a .gatewayN.bin sidecar) and EXCLUDE their
504
+ # zones from the declarative emission below. Needs an out_dir (the sidecar); ref-bearing gated entries
505
+ # (~30%) can't be door-only-carried -> they fall through to declarative + a warning.
506
+ gentries = eventscan.scan_gateway_entries(eb_bytes) if out_dir is not None else []
507
+ carry = [x for x in gentries if x["story_gated"] and x["self_contained"]]
508
+ carried_zones = {tuple(map(tuple, x["zone"])) for x in carry}
509
+ n_gateway_carry = len(carry)
510
+ n_gateway_gated_seam = sum(1 for x in gentries if x["story_gated"] and not x["self_contained"])
511
+ if gws:
512
+ if id_remap is None:
513
+ parts.append(
514
+ "# --- EXITS imported from the real field (LIVE). `to` is the REAL destination field id --\n"
515
+ "# retarget each to your own room ids, or leave them to walk back into the live game. ---")
516
+ else:
517
+ parts.append("# --- EXITS retargeted to this chain's own field ids (import-chain). "
518
+ "Out-of-chain exits are commented seam stubs. ---")
519
+ # #2 (FORK_FIDELITY.md): a STORY-BRANCH door = one zone with >1 DISTINCT destination -- FF9's
520
+ # if(flag){Field(A)}else{Field(B)} stacked door. scan_gateways emits each branch as its own [[gateway]]
521
+ # at that shared zone; left ungated BOTH arm in the fork (the player hits the wrong branch). Group by
522
+ # zone here (scan_gateways doesn't carry the flag -- scan_all_warps does) to mark them for gating.
523
+ dests_by_zone: dict = {}
524
+ for g in gws:
525
+ dests_by_zone.setdefault(tuple(map(tuple, g["zone"])), set()).add(int(g["to"]))
526
+ for g in gws:
527
+ if tuple(map(tuple, g["zone"])) in carried_zones:
528
+ continue # carried VERBATIM as a [[gateway_carry]] below
529
+ zone = ", ".join(f"[{x}, {z}]" for x, z in g["zone"])
530
+ raw_to = int(g["to"])
531
+ cond = len(dests_by_zone[tuple(map(tuple, g["zone"]))]) > 1
532
+ n_story_branch += 1 if cond else 0
533
+ note = ("# STORY-BRANCH door: this zone has >1 conditional exit (the real field picks one by story\n"
534
+ "# flag). Gate each branch with requires_flag / requires_flag_clear so only the right one\n"
535
+ "# arms per beat -- else both fire and you hit the wrong exit.\n") if cond else ""
536
+ stub = ("\n# requires_flag = # the GlobBool that selects THIS branch (flags-inspect to find it)"
537
+ if cond else "")
538
+ if id_remap is None or raw_to in id_remap:
539
+ to = id_remap[raw_to] if id_remap else raw_to
540
+ parts.append(f"{note}[[gateway]]\nto = {to}\nentrance = {g['entrance']}\nzone = [{zone}]{stub}")
541
+ n_retargeted += 1 if id_remap is not None else 0
542
+ elif live_seams:
543
+ parts.append(f"{note}# SEAM (live): real field {raw_to} -- a door back into the live game\n"
544
+ f"[[gateway]]\nto = {raw_to}\nentrance = {g['entrance']}\nzone = [{zone}]{stub}")
545
+ n_seamed += 1
546
+ else:
547
+ parts.append(f"# SEAM (out-of-chain): real field {raw_to} via this zone -- author by hand.\n"
548
+ f"# [[gateway]]\n# to = {raw_to}\n# entrance = {g['entrance']}\n# zone = [{zone}]")
549
+ n_seamed += 1
550
+ if carry: # the verbatim story-gated doors + sidecars
551
+ out_path = Path(out_dir)
552
+ parts.append("# --- STORY-GATED doors carried VERBATIM (their conditional state machine preserved; the\n"
553
+ "# GLOB conditions read the [startup]-preset story state). Destinations are the REAL field\n"
554
+ "# ids -- add `retarget = { <real id> = <your id> }` to redirect into your own rooms. ---")
555
+ for x in carry:
556
+ fn = f"{name}.gateway{x['entry_idx']}.bin"
557
+ out_path.joinpath(fn).write_bytes(x["entry_bytes"])
558
+ dests = sorted({fid for fid, _ent in x["fields"]})
559
+ retarget = ""
560
+ if id_remap: # import-chain: pre-fill in-chain retargets
561
+ pairs = [(fid, id_remap[fid]) for fid in dests if fid in id_remap]
562
+ if pairs:
563
+ retarget = "\nretarget = { " + ", ".join(f"{a} = {b}" for a, b in pairs) + " }"
564
+ parts.append(f'[[gateway_carry]]\nbin = "{fn}"{retarget}\n'
565
+ f"# verbatim story-gated door (real dest field id(s): {', '.join(map(str, dests))}). "
566
+ f"To redirect: retarget = {{ {dests[0]} = <your id> }}")
567
+ enc = content["encounter"]
568
+ donor_song = _donor_battle_song(field_id, enc, game) if enc else None
569
+ if enc:
570
+ block = f"[encounter]\nscene = {enc['scenes'][0]}\nfreq = {enc['freq']}"
571
+ if len(set(enc["scenes"])) != 1:
572
+ block += f"\nscenes = [{', '.join(str(s) for s in enc['scenes'])}]"
573
+ if donor_song: # non-zero only: 0 == the build's default Battle Theme
574
+ block += (f"\nbattle_music = {donor_song} # the donor's real battle song (akao song-play id), "
575
+ f"auto-detected from the real field -- a mint loses it (build emits a Music: line)")
576
+ parts.append("# random battles imported from the real field (build adds the after-battle "
577
+ "reinit)\n" + block)
578
+ if content["music"] is not None:
579
+ parts.append(f"# field BGM imported from the real field\n[music]\nsong = {content['music']}")
580
+ lads = content["ladders"]
581
+ n_ladders = 0
582
+ if lads and out_dir is not None: # ladders carry a binary climb -> need out_dir
583
+ out_path = Path(out_dir)
584
+ from .content import ladder as _ladder
585
+ blocks = ["# --- LADDER(s) imported from the real field (LIVE) -- the EXACT climb (the real,\n"
586
+ "# perspective-correct jump arcs), verbatim. Walk into the zone -> '!' -> press action\n"
587
+ "# to climb; the climb reads your height to go up or down. The zone is auto-widened to\n"
588
+ "# span BOTH climb ends (the real zone only covers the entry side -> a fork couldn't\n"
589
+ "# climb back); tighten it if you don't want the '!' along the whole column. ---"]
590
+ for i, lad in enumerate(lads):
591
+ fn = f"{name}.ladder{i}.climb.bin"
592
+ (out_path / fn).write_bytes(lad["climb"])
593
+ # the concurrent helper entries the climb launches via STARTSEQ (e.g. the SetPitchAngle
594
+ # forward-lean) -- one sidecar per referenced entry; build auto-loads them by the climb's
595
+ # STARTSEQ refs + this naming, grafts them at free slots, and remaps the climb's args.
596
+ for ei, sbytes in lad.get("sequences", {}).items():
597
+ (out_path / f"{name}.ladder{i}.seq{ei}.bin").write_bytes(sbytes)
598
+ zone_pts = _ladder.widen_zone_for_climb(lad["zone"], lad["climb"])
599
+ zone = ", ".join(f"[{x}, {z}]" for x, z in (zone_pts or []))
600
+ blocks.append(f'[[ladder]]\nzone = [{zone}]\nclimb = "{fn}"')
601
+ parts.append("\n\n".join(blocks))
602
+ n_ladders = len(lads)
603
+ jmps = content.get("jumps") or []
604
+ n_jumps = 0
605
+ if jmps and out_dir is not None: # jumps carry a binary arc -> need out_dir
606
+ out_path = Path(out_dir)
607
+ blocks = ["# --- JUMP(s) imported from the real field (LIVE) -- navigable ledge/gap hops (Ice\n"
608
+ "# Cavern style). Each carries the EXACT, perspective-tuned jump arc verbatim (the real\n"
609
+ "# world coords -- only copyable, like a ladder climb). trigger=\"action\" = walk to the\n"
610
+ "# ledge -> '!' -> press the button to hop; trigger=\"tread\" = auto-hop on walk-in. The\n"
611
+ "# arc moves the player along a parabola, so the zone must sit at the take-off ledge. ---"]
612
+ for i, jp in enumerate(jmps):
613
+ fn = f"{name}.jump{i}.bin"
614
+ (out_path / fn).write_bytes(jp["jump"])
615
+ zone = ", ".join(f"[{x}, {z}]" for x, z in (jp["zone"] or []))
616
+ extra = "" if jp.get("bubble", True) else "\nbubble = false"
617
+ blocks.append(f'[[jump]]\nzone = [{zone}]\njump = "{fn}"\ntrigger = "{jp["trigger"]}"{extra}')
618
+ parts.append("\n\n".join(blocks))
619
+ n_jumps = len(jmps)
620
+ # faithful object carry. The STARTSEQ-helper closure (graft_seq_helpers) is a pure fidelity win -- it
621
+ # carries the benign Seq an object launches, un-refusing it -- so it's ALWAYS on: the default path reads
622
+ # it via scan_content (objects_verbatim is scanned with it), and the graft_player_funcs path (which also
623
+ # touches the fork PLAYER, opt-in) requests it directly. graft mode flips init_only -> whole-entry; the
624
+ # closure flips refuse -> graftable. docs/OBJECT_CARRY.md S2 v1.5.
625
+ objs = ((eventscan.scan_objects_verbatim(eb_bytes, graft_player_funcs=True, carry_text=carry_text,
626
+ graft_seq_helpers=True, graft_savepoint=graft_savepoint)
627
+ if graft_player_funcs else content.get("objects_verbatim")) or [])
628
+ n_objects = 0
629
+ n_save_moogle = 0
630
+ if objs and out_dir is not None: # objects carry a verbatim entry -> need out_dir
631
+ out_path = Path(out_dir)
632
+ blocks = ["# --- OBJECTS imported from the real field -- the persistent NPCs/props (set-dressing the\n"
633
+ "# fork would otherwise DROP: the cask, signs, the save moogle's barrel, ...). Each is\n"
634
+ "# carried by GRAFTING the object's REAL .eb entry VERBATIM (renders byte-identical -- not a\n"
635
+ "# lossy player-clone). `bin` is the entry sidecar; `carry_tags` (init_only objects) keeps\n"
636
+ "# only the render-defining funcs, dropping interactive funcs that call a player function a\n"
637
+ "# blank fork lacks (those can't port). A REFUSED object falls back to a [[prop]]/[[npc]]\n"
638
+ "# author stub. The DIALOGUE text of a talkable NPC is still not carried -- author it. ---"]
639
+ for i, o in enumerate(objs):
640
+ if o["graft_safety"] in ("clean", "init_only"):
641
+ fn = f"{name}.object{i}.bin"
642
+ (out_path / fn).write_bytes(o["entry_bytes"]) # the verbatim entry build grafts
643
+ seq_fns = [] # the closure's STARTSEQ helper sidecars
644
+ for h in (o.get("seqs") or []):
645
+ sfn = f"{name}.object{i}.seq{h['entry']}.bin"
646
+ (out_path / sfn).write_bytes(h["bytes"])
647
+ seq_fns.append((int(h["entry"]), sfn))
648
+ blocks.append(_object_block(o, fn, seq_fns))
649
+ else:
650
+ blocks.append(_object_stub(o))
651
+ parts.append("\n\n".join(blocks))
652
+ n_objects = len(objs)
653
+ # the save-Moogle marker (docs/SAVEPOINT.md): when --save-moogle carried a real save point, flag it so
654
+ # the build + the author see it as ONE faithful save point (the hidden Moogle + book/feather/tent are in
655
+ # the [[object]] blocks above; its pose surgery in the [[player_func]] blocks). It's the user-facing
656
+ # handle -- and the forward-compatible slot for a future AUTHORED save Moogle (jump arc + distance).
657
+ if graft_savepoint and any(o.get("model") == "GEO_NPC_F0_MOG" for o in objs):
658
+ src = f'from = "{field_id}"\n' if field_id is not None else ""
659
+ # the save-sequence DIRECTOR (donor entry-0 tag-1) puppeteers the Moogle via shared MAP vars; carry
660
+ # it too -- the object carry misses it (it's main-loop logic, not an object) so without it the Moogle
661
+ # has no driver. Emitted as a gitignored sidecar the build grafts into the fork's empty entry-0 tag-1.
662
+ director_ref = ""
663
+ director = eventscan.extract_savepoint_director(eb_bytes)
664
+ if director and out_dir is not None:
665
+ dfn = f"{name}.savemoogle_director.bin"
666
+ (Path(out_dir) / dfn).write_bytes(director)
667
+ director_ref = f'director = "{dfn}"\n'
668
+ parts.append("# --- SAVE MOOGLE: a faithful FF9 save point, carried VERBATIM from the donor field --\n"
669
+ "# the hidden Moogle pops out of its barrel + the full save flourish, exactly as the\n"
670
+ "# original (cluster = the [[object]]+[[player_func]] blocks above; `director` = the donor's\n"
671
+ "# save-sequence loop that drives the Moogle through shared MAP vars). ---\n"
672
+ f"[[save_moogle]]\n{src}{director_ref}carried = true")
673
+ n_save_moogle = 1
674
+ # player-function graft (docs/PLAYER_GRAFT.md): the donor player gesture funcs a carried object
675
+ # RunScripts -- carried onto the fork player so the INTERACTIONS fire (the cask turns to face you on
676
+ # examine, the boxes gesture). Emitted ONLY with graft_player_funcs; CLEAN funcs always, plus TEXT funcs
677
+ # when --carry-text ships the words they show (else a text func stays refused -> its object init_only).
678
+ n_player_funcs = 0
679
+ all_pfuncs = (eventscan.scan_player_funcs(eb_bytes, graft_savepoint=graft_savepoint)
680
+ if (graft_player_funcs and out_dir is not None) else [])
681
+ pf_ok = {"clean", "text"} if carry_text else {"clean"}
682
+ if graft_player_funcs and out_dir is not None:
683
+ pfuncs = [s for s in all_pfuncs if s["safety"] in pf_ok]
684
+ if pfuncs:
685
+ out_path = Path(out_dir)
686
+ blocks = ["# --- PLAYER FUNCTION(S) grafted onto the fork player so the carried objects'\n"
687
+ "# INTERACTIONS fire (the cask EXAMINE turn, the box gestures). Each is a real donor player\n"
688
+ "# gesture func (turn/animation) carried VERBATIM at a fresh tag; the build remaps each\n"
689
+ "# object's RunScript(player, tag) to it + splices the donor's animation packs. ---"]
690
+ for i, p in enumerate(pfuncs):
691
+ fn = f"{name}.playerfunc{i}.bin"
692
+ (out_path / fn).write_bytes(p["body"])
693
+ packs = ", ".join("[" + ", ".join(str(x) for x in pk) + "]" for pk in p["donor_init_packs"])
694
+ blocks.append(f'[[player_func]]\nbin = "{fn}"\ndonor_tag = {p["donor_tag"]}\n'
695
+ f'safety = "{p["safety"]}"\ndonor_init_packs = [{packs}]')
696
+ parts.append("\n\n".join(blocks))
697
+ n_player_funcs = len(pfuncs)
698
+ # faithful TEXT CARRY (docs/TEXT_CARRY.md): ship the donor's referenced field text VERBATIM + remap the
699
+ # grafted windows so the forked interactions show the REAL words (a carried NPC's talk, a grafted text
700
+ # player func). Needs graft_player_funcs (so the carrying objects/funcs exist) + the field id + game (to
701
+ # read the donor's per-language .mes). The plan is written to a gitignored .carrytext.json sidecar the
702
+ # build consumes; an empty plan (no grafted windows) emits no [carry_text].
703
+ n_carry_text = 0
704
+ if carry_text and graft_player_funcs and out_dir is not None and field_id is not None:
705
+ from .content import textcarry as _tc
706
+ loader = _tc._field_text_loader(field_id, game=game)
707
+ plan = _tc.collect_carry(eb_bytes, objs, all_pfuncs, field_id, loader)
708
+ if plan:
709
+ fn = f"{name}.carrytext.json"
710
+ _tc.write_sidecar(Path(out_dir) / fn, plan, field=field_id)
711
+ parts.append(
712
+ "# --- TEXT CARRY: the donor field's referenced dialogue text, shipped VERBATIM (per language)\n"
713
+ "# + the grafted windows remapped to it, so the carried NPCs' talk + grafted text interactions\n"
714
+ "# show the REAL words. This is the FAITHFUL path (vs `import --dialogue`'s editable stubs); the\n"
715
+ "# words are SE-derived -> the .carrytext.json sidecar is gitignored. Remove this block (and the\n"
716
+ "# sidecar) to author the dialogue yourself instead. ---\n"
717
+ f'[carry_text]\nbin = "{fn}"')
718
+ n_carry_text = len(plan)
719
+ summary = {"gateways": len(gws), "encounter": enc is not None, "music": content["music"],
720
+ "battle_music": donor_song, # the donor's real battle BGM (auto-detected), or None if unknown/default
721
+ "control_direction": content["control_direction"], "ladders": n_ladders,
722
+ "jumps": n_jumps, "objects": n_objects, "player_funcs": n_player_funcs,
723
+ "carry_text": n_carry_text, "save_moogle": n_save_moogle,
724
+ "spawn_flash": sum(1 for o in objs if o.get("spawn_flash")), # P6.1: Init pose != rest -> flashes on a fork
725
+ "spawn_flash_fixed": (1 if (graft_savepoint and n_save_moogle) else 0),
726
+ "gateways_retargeted": n_retargeted, "gateways_seamed": n_seamed,
727
+ "story_branch": n_story_branch, # #2: doors sharing a zone (gate each with requires_flag)
728
+ "gateway_carry": n_gateway_carry, # #2b: story-gated doors carried VERBATIM
729
+ "gateway_gated_seam": n_gateway_gated_seam} # #2b: story-gated but ref-bearing -> can't carry yet
730
+ return "\n\n".join(parts), content["control_direction"], summary
731
+
732
+
733
+ def _content_for_import(field: str, game, *, out_dir=None, name="field", id_remap=None, live_seams=False,
734
+ graft_player_funcs=False, carry_text=False, graft_savepoint=False):
735
+ """(content_blocks, control_dir, summary) for a field's import. Locates + scans the real .eb;
736
+ returns ("", None, None) if it can't (no mapping / no game / UnityPy absent) so import still works.
737
+ ``out_dir``/``name`` let ladder climbs be written as sidecars next to the field.toml.
738
+ ``id_remap``/``live_seams`` retarget gateway ``to`` ids for import-chain (see _imported_content_toml).
739
+ ``graft_player_funcs`` emits the player-function graft (the carried objects' interactions).
740
+ ``carry_text`` additionally ships the donor's referenced dialogue text verbatim + remaps the grafted
741
+ windows (the faithful text carry, docs/TEXT_CARRY.md) -- needs the resolved field id + game install."""
742
+ eb_bytes = extract_event_script(field, game=game)
743
+ if not eb_bytes:
744
+ return "", None, None
745
+ # Resolve the donor's real field id for EVERY import: carry_text / graft_savepoint need it, AND the
746
+ # encounter block uses it to auto-detect the donor's battle BGM (battle_bgm keys on the field id). It's a
747
+ # pure table lookup (no install), so it's cheap and best-effort -- None just disables the BGM prefill.
748
+ from .dialogue import _resolve_field_id
749
+ try:
750
+ fid = _resolve_field_id(field)
751
+ except (FileNotFoundError, ValueError):
752
+ fid = None
753
+ return _imported_content_toml(eb_bytes, out_dir=out_dir, name=name, id_remap=id_remap,
754
+ live_seams=live_seams, graft_player_funcs=graft_player_funcs,
755
+ carry_text=carry_text, field_id=fid, game=game,
756
+ graft_savepoint=graft_savepoint)
757
+
758
+
759
+ def _content_section(content_blocks: str, x: int, z: int) -> str:
760
+ """The trailing content of the field.toml: the LIVE imported blocks, or a commented gateway stub
761
+ when nothing was imported (the old hand-authoring hint)."""
762
+ if content_blocks:
763
+ return content_blocks + "\n"
764
+ return ("# [[gateway]]\n# to = 100 # destination field id\n# entrance = 204\n"
765
+ "# zone = [[-200, 200], [200, 200], [200, 400], [-200, 400]]\n")
766
+
767
+
768
+ def find_field(field: str, game=None, bundle: str | None = None):
769
+ """Locate a field's bundle + container paths. Returns (bundle_path, folder, {role: key}, env).
770
+
771
+ Uses the cached field index unless `bundle` (e.g. 'p0data141.bin') is given to short-circuit it."""
772
+ sa = _streaming_assets(game)
773
+ folder = None
774
+ if not bundle:
775
+ folder, bundle = resolve_field(field, game)
776
+ env = _load_env(sa / bundle)
777
+ if folder is None: # explicit bundle: match within it
778
+ want = re.sub(r"^fbg_n\d+_", "", field.strip().lower())
779
+ folders = {m.group(1) for k in env.container
780
+ if (m := re.search(r"fieldmaps/([^/]+)/", k.lower()))}
781
+ hits = [f for f in folders if want in f]
782
+ if not hits:
783
+ raise FileNotFoundError(f"field {field!r} not in {bundle}")
784
+ folder = field.strip().lower() if field.strip().lower() in hits else hits[0]
785
+ roles = {}
786
+ for k in env.container:
787
+ kl = k.lower()
788
+ if f"fieldmaps/{folder}/" not in kl:
789
+ continue
790
+ base = kl.rsplit("/", 1)[-1]
791
+ if base == "atlas.png":
792
+ roles["atlas"] = k
793
+ elif base.endswith(".bgi.bytes"):
794
+ roles["bgi"] = k
795
+ elif base.endswith(".bgs.bytes") and not re.search(r"_(es|fr|gr|it|jp)\.bgs", base):
796
+ roles["bgs"] = k # default (us/en) scene
797
+ return str(sa / bundle), folder, roles, env
798
+
799
+
800
+ def field_camera_info(field: str, *, game=None, bundle: str | None = None) -> dict | None:
801
+ """A field's lens, read cheaply -- pitch/FOV/scrolling/camera-count from the scene `.bgs` ONLY (no
802
+ walkmesh/atlas extraction). Returns None if the install/scene can't be read (so callers degrade
803
+ gracefully). Read-only. Used by `fork-report` (the Camera axis) and reusable for a room finder."""
804
+ try:
805
+ _, _, roles, env = find_field(field, game=game, bundle=bundle)
806
+ if "bgs" not in roles:
807
+ return None
808
+ objs = {k: v for k, v in env.container.items()}
809
+ cams = bgs.parse_cameras(_raw_bytes(objs[roles["bgs"]].read()))
810
+ if not cams:
811
+ return None
812
+ c0 = cams[0]
813
+ fov = cam.decompose(c0)["fov_x_deg"]
814
+ return {
815
+ "pitch": round(cam.pitch_deg(c0), 1),
816
+ "fov": round(fov, 1) if fov else None,
817
+ "scrolling": bool(c0.range[0] > 384 or c0.range[1] > 448),
818
+ "count": len(cams),
819
+ # the camera's visible extent (screen units). range_h is the physically-meaningful "how far back"
820
+ # signal -- FF9's projection is orthographic-like (k~0.93) so FOV is a scale artifact, not a frustum.
821
+ "range_w": int(c0.range[0]), "range_h": int(c0.range[1]),
822
+ }
823
+ except Exception:
824
+ return None
825
+
826
+
827
+ def _pt_in_quad(px, pz, quad) -> bool:
828
+ """True if (px, pz) is inside the convex polygon ``quad`` ([x, z] corners), top-down. Convex
829
+ same-side-of-every-edge test (the trigger zones are convex quads, the IsInQuad norm)."""
830
+ n = len(quad)
831
+ if n < 3:
832
+ return False
833
+ sign = 0
834
+ for i in range(n):
835
+ ax, az = quad[i]
836
+ bx, bz = quad[(i + 1) % n]
837
+ cross = (bx - ax) * (pz - az) - (bz - az) * (px - ax)
838
+ if cross != 0:
839
+ s = 1 if cross > 0 else -1
840
+ if sign == 0:
841
+ sign = s
842
+ elif s != sign:
843
+ return False
844
+ return True
845
+
846
+
847
+ def _trigger_zones(field: str, game=None) -> list:
848
+ """Every trigger polygon in a field's event script (exits + interaction/trap regions), or [] if the
849
+ script can't be read. Lets ``extract_field`` keep the spawn off a trigger without the caller plumbing
850
+ the zones through. Never raises -- a missing script just means no zones to avoid."""
851
+ try:
852
+ eb_bytes = extract_event_script(field, game=game)
853
+ return eventscan.scan_region_zones(eb_bytes) if eb_bytes else []
854
+ except Exception:
855
+ return []
856
+
857
+
858
+ def cache_field(field_id, *, game=None, force=False) -> dict:
859
+ """Extract a real field's camera + walkmesh into the WORKSPACE CACHE (gitignored), idempotently.
860
+
861
+ The centralized alternative to copying a ``.bgx`` next to every BG-borrow project: extract a room ONCE
862
+ into ``provision.field_cache_dir(field_id)`` and have any number of tomls reference that single copy.
863
+ Skips the extraction when the camera is already cached (unless ``force``). Returns
864
+ ``{dir, camera, walkmesh, cached}`` (``camera``/``walkmesh`` are Paths; ``walkmesh`` is None if absent).
865
+ Needs the install + UnityPy (lazily, via :func:`extract_field`)."""
866
+ from . import provision
867
+ dest = provision.field_cache_dir(field_id)
868
+ cam = dest / "camera.bgx"
869
+ wmp = dest / "walkmesh.bgi"
870
+ if cam.is_file() and not force:
871
+ return {"dir": dest, "camera": cam, "walkmesh": wmp if wmp.is_file() else None, "cached": True}
872
+ dest.mkdir(parents=True, exist_ok=True)
873
+ meta = extract_field(str(field_id), dest, game=game)
874
+ return {"dir": dest, "camera": cam, "walkmesh": wmp if wmp.is_file() else None, "cached": False,
875
+ "meta": meta}
876
+
877
+
878
+ def extract_field(field: str, out_dir, *, game=None, bundle=None, want_atlas=False, avoid_zones=None) -> dict:
879
+ """Extract a real field's camera + walkmesh (+ optional atlas) to `out_dir`; return metadata.
880
+
881
+ ``avoid_zones`` ([x, z]-corner quads) are trigger polygons the spawn must stay OUT of (so a forked
882
+ field doesn't instant-warp on arrival); when None they're auto-scanned from the field's event script."""
883
+ out = Path(out_dir)
884
+ out.mkdir(parents=True, exist_ok=True)
885
+ path, folder, roles, env = find_field(field, game=game, bundle=bundle)
886
+ if "bgs" not in roles or "bgi" not in roles:
887
+ raise FileNotFoundError(f"{folder}: missing .bgs/.bgi (have {sorted(roles)})")
888
+
889
+ objs = {k: v for k, v in env.container.items()}
890
+ bgs_bytes = _raw_bytes(objs[roles["bgs"]].read())
891
+ bgi_bytes = _raw_bytes(objs[roles["bgi"]].read())
892
+
893
+ cameras = bgs.parse_cameras(bgs_bytes)
894
+ wm = bgi.BgiWalkmesh.from_bytes(bgi_bytes)
895
+ area, mapid = parse_fbg_folder(folder)
896
+
897
+ # write the camera as a borrowable .bgx (reference + drives movement/scroll) + the raw walkmesh
898
+ (out / "camera.bgx").write_text("".join(cam.format_bgx_camera(c) for c in cameras), encoding="utf-8")
899
+ (out / "walkmesh.bgi").write_bytes(bgi_bytes)
900
+ if want_atlas and "atlas" in roles:
901
+ try:
902
+ objs[roles["atlas"]].read().image.save(out / "atlas.png")
903
+ except Exception:
904
+ pass
905
+
906
+ c0 = cameras[0]
907
+ d = cam.decompose(c0)
908
+ scrolling = c0.range[0] > 384 or c0.range[1] > 448
909
+ # real .bgi verts are corner-origin per FLOOR; world_vert = vert + orgPos + floor.org puts the
910
+ # whole (multi-floor) walkmesh in the world (camera) frame, on the painted art + the engine frame.
911
+ wv = wm.world_verts()
912
+ wx = [p[0] for p in wv]
913
+ wz = [p[2] for p in wv]
914
+ ox, oz = wm.orgPos.x, wm.orgPos.z # header offset (for the charPos spawn guesses below)
915
+ # spawn: charPos is stored per-field in EITHER the corner frame or already world, and is unreliable
916
+ # for multi-floor fields. Prefer it only if it lands on the walkmesh, on-camera, AND clear of every
917
+ # trigger zone; else spawn at the centre of the ON-CAMERA walkmesh (a real walkmesh often runs far
918
+ # past the screen into gated tunnels, so the player should appear on-screen). The trigger-zone check
919
+ # matters because a field's stored charPos is usually right at the MAIN door -- which is the exit
920
+ # that warps you BACK out -- so a naive spawn lands inside that gateway and instant-warps on arrival.
921
+ bx0, bx1, bz0, bz1 = min(wx), max(wx), min(wz), max(wz)
922
+ rw, rh = c0.range
923
+ trigger_zones = avoid_zones if avoid_zones is not None else _trigger_zones(field, game=game)
924
+ def _inb(px, pz):
925
+ return bx0 <= px <= bx1 and bz0 <= pz <= bz1
926
+ def _oncam(px, pz):
927
+ cx, cy = cam.to_canvas((px, 0.0, pz), c0)
928
+ return 0 <= cx <= rw and 0 <= cy <= rh
929
+ def _clear(px, pz): # outside every exit/trigger polygon
930
+ return not any(_pt_in_quad(px, pz, q) for q in trigger_zones)
931
+ _cp = [(wm.charPos.x + ox, wm.charPos.z + oz), (wm.charPos.x, wm.charPos.z)]
932
+ # c.1: on a SPLIT walkmesh (e.g. a shop counter walls the behind-counter pocket off from the customer
933
+ # area) keep the spawn in the MAIN region -- the connected walkmesh component with the most on-camera
934
+ # verts -- so a fork doesn't strand the player in a trapped pocket (the donor charPos is often that
935
+ # pocket: a cutscene staging spot). No-op on a single-region walkmesh -> byte-identical there.
936
+ _comps = wm.tri_components()
937
+ _multi = len(_comps) > 1
938
+ if _multi:
939
+ _oncam_idx = {i for i in range(len(wx)) if _oncam(wx[i], wz[i])}
940
+ _main_vtx = {vi for t in max(_comps, key=lambda c: len({vi for t in c for vi in wm.tris[t].vtx}
941
+ & _oncam_idx)) for vi in wm.tris[t].vtx}
942
+ def _in_main(px, pz): # the nearest walkmesh vert is in the main region
943
+ vi = min(range(len(wx)), key=lambda i: (wx[i] - px) ** 2 + (wz[i] - pz) ** 2)
944
+ return vi in _main_vtx
945
+ else:
946
+ def _in_main(px, pz):
947
+ return True
948
+ _oncam_verts = [(wx[i], wz[i]) for i in range(len(wx))
949
+ if _oncam(wx[i], wz[i]) and (not _multi or i in _main_vtx)]
950
+ _clear_oncam = [p for p in _oncam_verts if _clear(*p)]
951
+ mcx = mcz = None
952
+ if _oncam_verts: # the visible centroid (the on-screen "middle")
953
+ mcx = sum(p[0] for p in _oncam_verts) / len(_oncam_verts)
954
+ mcz = sum(p[1] for p in _oncam_verts) / len(_oncam_verts)
955
+ # #9 spawn: prefer a REAL per-entrance ARRIVAL -- where the engine actually spawns the player walking in a
956
+ # door -- over the donor charPos (often a cutscene staging spot) or a synthetic centroid. The player Init's
957
+ # D9(0)/D9(4) arrival blocks are world coords in the same frame as the walkmesh; among those valid HERE
958
+ # (in-bounds, on-camera, clear of triggers, in the main region) take the one nearest the visible centroid:
959
+ # the natural main-entrance spawn, and FAITHFUL (a coord the game uses). Falls through to the c.1 charPos/
960
+ # centroid cascade when none qualifies (a single-spawn field, a frame mismatch, all arrivals off-screen/gated)
961
+ # -> byte-identical there. A synth fork can't reconstruct the per-DOOR table (gateways are retargeted), but
962
+ # the default landing now matches the real field's main arrival instead of a centroid guess.
963
+ _arrivals = []
964
+ try:
965
+ _aeb = extract_event_script(field, game=game)
966
+ if _aeb:
967
+ _arrivals = [(ax, az) for ax, az, _f in eventscan.scan_player_arrivals(_aeb)["arrivals"]]
968
+ except Exception: # a missing/odd script just disables the preference
969
+ _arrivals = []
970
+ _valid_arr = [p for p in _arrivals if _inb(*p) and _oncam(*p) and _clear(*p) and _in_main(*p)]
971
+ _spawn = None
972
+ if _valid_arr and mcx is not None: # the real arrival nearest the visible centre
973
+ _spawn = min(_valid_arr, key=lambda p: (p[0] - mcx) ** 2 + (p[1] - mcz) ** 2)
974
+ if _spawn is None: # c.1: a trustworthy charPos (clear + in-main)
975
+ _spawn = next((p for p in _cp if _inb(*p) and _oncam(*p) and _clear(*p) and _in_main(*p)), None)
976
+ if _spawn is None and _oncam_verts: # nearest-to-centre visible vert, clear if any
977
+ pool = _clear_oncam or _oncam_verts
978
+ _spawn = min(pool, key=lambda p: (p[0] - mcx) ** 2 + (p[1] - mcz) ** 2)
979
+ if _spawn is None: # no on-camera verts: in-bounds / centroid
980
+ _spawn = next((p for p in _cp if _inb(*p) and _in_main(*p)), (sum(wx) / len(wx), sum(wz) / len(wz)))
981
+ _spawn = [round(_spawn[0]), round(_spawn[1])]
982
+ meta = {
983
+ "field": folder,
984
+ "bundle": os.path.basename(path),
985
+ "area": area,
986
+ "mapid": mapid,
987
+ "cameras": len(cameras),
988
+ "camera": {
989
+ "pitch_deg": round(cam.pitch_deg(c0), 2),
990
+ "yaw_deg": round(cam.yaw_deg(c0), 2),
991
+ "fov_deg": round(d["fov_x_deg"], 2) if d["fov_x_deg"] else None,
992
+ "range": list(c0.range),
993
+ "proj": c0.proj,
994
+ },
995
+ "scrolling": scrolling,
996
+ "frame_offset": [wm.orgPos.x, wm.orgPos.y, wm.orgPos.z], # header base (+ per-floor floor.org)
997
+ "player_start": _spawn,
998
+ "walkmesh_bounds": { # WORLD frame (vert + orgPos + floor.org) = where content goes
999
+ "x": [round(min(wx)), round(max(wx))],
1000
+ "z": [round(min(wz)), round(max(wz))],
1001
+ "verts": len(wm.verts),
1002
+ "tris": len(wm.tris),
1003
+ },
1004
+ "out": str(out),
1005
+ }
1006
+ return meta
1007
+
1008
+
1009
+ def field_art_dir(field: str, game=None):
1010
+ """The folder where Memoria's `[Export] Field=1` dumped this field's per-overlay PNGs, or None
1011
+ if it hasn't been exported in-game yet (StreamingAssets/FieldMaps/<FBG>/Overlay*.png).
1012
+
1013
+ This is now only the FALLBACK source -- the art functions assemble the overlays OFFLINE from the
1014
+ atlas by default (`_overlay_art` / scene.bgart), so they no longer require the in-game export."""
1015
+ folder, _ = resolve_field(field, game)
1016
+ d = config.find_game_path(game) / "StreamingAssets" / "FieldMaps" / folder.upper()
1017
+ return d if (d / "Overlay0.png").is_file() else None
1018
+
1019
+
1020
+ def _overlay_art(field: str, game=None, bundle=None):
1021
+ """``(overlays, provider, factor, source, atlas)`` for a field's background art -- OFFLINE-FIRST.
1022
+
1023
+ ``overlays`` are the field's sprite-resolved overlays; ``provider(i)`` yields overlay ``i``'s
1024
+ ``Overlay{i}.png`` as a PIL RGBA image (or None); ``factor`` (= active TileSize // 16) is the
1025
+ overlay PNGs' pixels-per-tile -- the scale ``compose_background`` / ``extract_layers`` must crop
1026
+ and place at; ``source`` is ``"offline"`` or ``"export"``; ``atlas`` is the source atlas PIL image
1027
+ on the offline path (for an `export-art` ``atlas.png`` dump), else None. Returns None when no art
1028
+ is available.
1029
+
1030
+ DEFAULT = assemble each overlay straight from the atlas the engine itself would render with (the
1031
+ highest-priority mod atlas -- Moguri -- else the base p0data atlas), via :mod:`scene.bgart`, so NO
1032
+ in-game ``[Export] Field=1`` is needed (it replaces the multi-minute startup dump). Falls back to
1033
+ Memoria's on-disk export only if the atlas can't be read. Sprites are resolved against the CHOSEN
1034
+ atlas width + active TileSize so the cells are correct (the legacy ``2048/40`` was valid only
1035
+ because the old path cropped the pre-assembled export, never the atlas itself). The offline path is
1036
+ proven byte-exact vs the engine's own ``atlas.png`` crop; a live install differs only by the sub-
1037
+ 2/255 codec noise of re-decoding a DXT-compressed atlas offline (imperceptible, geometry-identical)."""
1038
+ import io # noqa: PLC0415
1039
+ from PIL import Image # noqa: PLC0415 - only the art path needs PIL
1040
+ try:
1041
+ _, folder, roles, env = find_field(field, game=game, bundle=bundle)
1042
+ except (FileNotFoundError, ValueError):
1043
+ return None
1044
+ if "bgs" not in roles:
1045
+ return None
1046
+ bgs_bytes = _raw_bytes(env.container[roles["bgs"]].read())
1047
+ ts = _active_tilesize(game)
1048
+ factor = ts // bgs.TILE
1049
+ atlas_img = None
1050
+ try: # the atlas AssetManager would load: mod, else base
1051
+ mod = _mod_field_atlas(folder, game=game)
1052
+ if mod is not None:
1053
+ atlas_img = Image.open(io.BytesIO(mod)).convert("RGBA")
1054
+ elif "atlas" in roles:
1055
+ atlas_img = env.container[roles["atlas"]].read().image.convert("RGBA")
1056
+ except Exception: # noqa: BLE001 - unreadable atlas -> try the export
1057
+ atlas_img = None
1058
+ if atlas_img is not None:
1059
+ _, overlays = bgs.parse_overlays(bgs_bytes)
1060
+ bgs.resolve_sprites(bgs_bytes, overlays, atlas_img.size[0], ts)
1061
+ imgs = bgart.assemble_overlays(atlas_img, overlays, ts)
1062
+ return overlays, (lambda i, _m=imgs: _m.get(i)), factor, "offline", atlas_img
1063
+ art = field_art_dir(field, game) # FALLBACK: the on-disk [Export] dump
1064
+ if art is None:
1065
+ return None
1066
+ _, overlays = bgs.parse_overlays(bgs_bytes)
1067
+ bgs.resolve_sprites(bgs_bytes, overlays, 2048, 40) # positions only -> the legacy params are fine here
1068
+
1069
+ def _disk(i, _art=art):
1070
+ p = _art / f"Overlay{i}.png"
1071
+ return Image.open(p).convert("RGBA") if p.is_file() else None
1072
+ return overlays, _disk, factor, "export", None
1073
+
1074
+
1075
+ def export_field_art(field: str, out_dir=None, *, game=None, bundle=None, write_atlas=True) -> dict:
1076
+ """Write a real field's per-overlay ``Overlay{i}.png`` (+ ``atlas.png``) to disk OFFLINE -- a drop-in
1077
+ for ONE field's slice of Memoria's ``[Export] Field=1`` dump, with NO in-game step.
1078
+
1079
+ Assembles the overlays via :func:`_overlay_art` (offline-first) and writes them under
1080
+ ``<out_dir>/<FBG-UPPER>/``. ``out_dir`` defaults to the install's ``StreamingAssets/FieldMaps`` so the
1081
+ result lands exactly where the engine's own export would (a true drop-in). ``write_atlas`` also dumps
1082
+ the source ``atlas.png`` (as the engine does). Returns a summary dict; raises ``FileNotFoundError`` if
1083
+ the field has no readable art. NOTE: these PNGs are SE-derived ART -- keep them OUT of version control
1084
+ (the kit's .gitignore already excludes field assets)."""
1085
+ folder, _ = resolve_field(field, game) # canonical FBG folder -> the output dir name
1086
+ res = _overlay_art(field, game=game, bundle=bundle)
1087
+ if res is None:
1088
+ raise FileNotFoundError(f"{folder}: no readable field art (atlas + on-disk export both unavailable)")
1089
+ overlays, provider, _factor, source, atlas = res
1090
+ base = Path(out_dir) if out_dir is not None else (
1091
+ config.find_game_path(game) / "StreamingAssets" / "FieldMaps")
1092
+ dest = base / folder.upper()
1093
+ dest.mkdir(parents=True, exist_ok=True)
1094
+ n = 0
1095
+ for i in range(len(overlays)):
1096
+ im = provider(i)
1097
+ if im is None:
1098
+ continue
1099
+ im.save(dest / f"Overlay{i}.png")
1100
+ n += 1
1101
+ wrote_atlas = bool(write_atlas and atlas is not None)
1102
+ if wrote_atlas:
1103
+ atlas.save(dest / "atlas.png")
1104
+ return {"folder": folder, "dir": str(dest), "overlays": n, "atlas": wrote_atlas, "source": source}
1105
+
1106
+
1107
+ def export_field_composite(field: str, out_dir=None, *, game=None, bundle=None) -> dict:
1108
+ """Write ONE composited background PNG for a field -- the browsable "glimpse" artifact (clean opaque
1109
+ art, walkmesh footprint OFF), vs :func:`export_field_art`'s per-overlay layers. Lands FLAT at
1110
+ ``<out_dir>/<FBG-UPPER>.png`` so a whole-game export is a single scrollable folder. Returns a summary;
1111
+ raises ``FileNotFoundError`` if the field has no readable art."""
1112
+ folder, _ = resolve_field(field, game)
1113
+ base = Path(out_dir) if out_dir is not None else (
1114
+ config.find_game_path(game) / "StreamingAssets" / "FieldMaps")
1115
+ base.mkdir(parents=True, exist_ok=True)
1116
+ out_path = base / f"{folder.upper()}.png"
1117
+ dims = compose_background(field, out_path, game=game, bundle=bundle, draw_footprint=False)
1118
+ if dims is None:
1119
+ raise FileNotFoundError(f"{folder}: no readable field art (atlas + on-disk export both unavailable)")
1120
+ return {"folder": folder, "path": str(out_path), "size": list(dims)}
1121
+
1122
+
1123
+ def _per_field_export(write_atlas: bool, composite: bool):
1124
+ """The per-field writer for :func:`_export_many` -- a composited PNG (glimpse) or per-overlay layers."""
1125
+ if composite:
1126
+ return lambda tok, out, game: export_field_composite(tok, out, game=game)
1127
+ return lambda tok, out, game: export_field_art(tok, out, game=game, write_atlas=write_atlas)
1128
+
1129
+
1130
+ def _export_many(field_tokens, out_dir, *, game=None, per_field=None, on_field=None) -> dict:
1131
+ """Run ``per_field(token, out_dir, game) -> summary`` over many fields, never raising on a single bad
1132
+ one. ``on_field(k, total, folder, summary, err)`` is an optional progress callback. Returns
1133
+ {fields, units, failed:[(token, err)], total} (``units`` = total overlays in raw mode, 1/field in
1134
+ composite mode -- ``summary['overlays']`` or 1)."""
1135
+ fields = list(field_tokens)
1136
+ total = len(fields)
1137
+ n_fields = units = 0
1138
+ failed = []
1139
+ for k, f in enumerate(fields):
1140
+ try:
1141
+ summ = per_field(f, out_dir, game)
1142
+ except (FileNotFoundError, ValueError, RuntimeError) as e:
1143
+ failed.append((str(f), str(e)))
1144
+ if on_field:
1145
+ on_field(k + 1, total, str(f), None, str(e))
1146
+ continue
1147
+ n_fields += 1
1148
+ units += summ.get("overlays", 1)
1149
+ if on_field:
1150
+ on_field(k + 1, total, summ["folder"], summ, None)
1151
+ return {"fields": n_fields, "units": units, "failed": failed, "total": total}
1152
+
1153
+
1154
+ def export_campaign_art(campaign_toml, out_dir=None, *, game=None, write_atlas=True, composite=False,
1155
+ on_field=None) -> dict:
1156
+ """Export the art for every REAL field a campaign forks (its members' ``source`` donor fields), OFFLINE.
1157
+ Reads the campaign manifest; dedups shared donors. ``composite`` = one glimpse PNG/field (else per-overlay
1158
+ layers). See :func:`export_field_art` / :func:`export_field_composite`."""
1159
+ from . import campaign as _camp
1160
+ plan = _camp.load_campaign(campaign_toml)
1161
+ ids = sorted({m.real_id for m in plan.members if m.real_id})
1162
+ if not ids:
1163
+ raise ValueError(f"{campaign_toml}: no member fields with a real `source` id to export")
1164
+ return _export_many((str(i) for i in ids), out_dir, game=game,
1165
+ per_field=_per_field_export(write_atlas, composite), on_field=on_field)
1166
+
1167
+
1168
+ def export_all_art(out_dir=None, *, game=None, pattern=None, write_atlas=True, composite=False,
1169
+ on_field=None) -> dict:
1170
+ """Export art for EVERY real field (optionally filtered by ``pattern``), OFFLINE -- the full drop-in for
1171
+ the in-game ``[Export] Field=1`` startup dump, without launching the game (or its hang). ``composite`` =
1172
+ a one-PNG-per-field browsable gallery (the "full-journey glimpse") instead of per-overlay layers."""
1173
+ fields = [folder for folder, _a, _m in list_fields(pattern, game=game)]
1174
+ return _export_many(fields, out_dir, game=game,
1175
+ per_field=_per_field_export(write_atlas, composite), on_field=on_field)
1176
+
1177
+
1178
+ def compose_background(field: str, out_path, *, game=None, bundle=None, upscale=None,
1179
+ draw_footprint=True, camera_index=None):
1180
+ """Composite the field's OPAQUE base art into one background PNG, for the Blender backdrop.
1181
+
1182
+ Assembles the field's per-overlay art OFFLINE from the atlas (`_overlay_art` / scene.bgart),
1183
+ falling back to Memoria's `[Export] Field=1` dump if the atlas can't be read; places each overlay
1184
+ by the .bgs overlay positions/depths and skips additive/subtractive light+shadow overlays (the
1185
+ "splotches"). When `draw_footprint` (default), also draws the walkable footprint -- the .bgi tris
1186
+ projected by the EXACT GTE->canvas map (cam.to_canvas), with NO offset: the engine projects the raw
1187
+ walkmesh frame directly, so this lands exactly where the player walks in-game. The walkmesh may
1188
+ extend past the canvas edges (tunnels) -- that's correct, not a misalignment. `upscale` defaults to
1189
+ the active export factor (TileSize // 16) so placement matches the overlay PNGs' own pixels-per-tile;
1190
+ pass a value only to force it. Returns (w, h), or None if the field has no readable art at all.
1191
+
1192
+ `camera_index` (default None) = composite ALL overlays onto camera 0's canvas (the glimpse). Pass
1193
+ an int K to composite ONLY the overlays camera K paints (`camNdx == K`) onto camera K's OWN range
1194
+ canvas + project the footprint via camera K -- the per-camera backdrop for a MULTI-camera field
1195
+ (each camera shows a different region of the scene; the all-overlays composite jams them onto one
1196
+ canvas as misplaced rectangles). For a single-camera field `camera_index=0` == None (all camNdx 0)."""
1197
+ res = _overlay_art(field, game=game, bundle=bundle)
1198
+ if res is None:
1199
+ return None
1200
+ overlays, provider, factor, _src, _atlas = res
1201
+ up = factor if upscale is None else upscale # the overlay PNGs are `factor` px/tile
1202
+ from PIL import Image, ImageDraw # noqa: PLC0415 - only the art path needs PIL
1203
+ _, _, roles, env = find_field(field, game=game, bundle=bundle)
1204
+ bgs_bytes = _raw_bytes(env.container[roles["bgs"]].read())
1205
+ h = bgs.parse_header(bgs_bytes)
1206
+ sOrgX, sOrgY = h.bounds[2], h.bounds[3]
1207
+ cams = bgs.parse_cameras(bgs_bytes)
1208
+ ci = 0 if camera_index is None else int(camera_index)
1209
+ cam_use = cams[ci] if 0 <= ci < len(cams) else cams[0]
1210
+ W, H = cam_use.range[0] * up, cam_use.range[1] * up
1211
+ canvas = Image.new("RGBA", (W, H), (0, 0, 0, 0))
1212
+ # camera_index=None -> all overlays (glimpse); =K -> only the overlays camera K paints (camNdx==K).
1213
+ opaque = [(i, o) for i, o in enumerate(overlays) if o.sprites and o.sprites[0].trans == 0
1214
+ and (camera_index is None or o.camNdx == ci)]
1215
+ opaque.sort(key=lambda io: -(io[1].curZ + io[1].orgZ)) # back (high depth) -> front
1216
+ for i, o in opaque:
1217
+ im = provider(i)
1218
+ if im is None:
1219
+ continue
1220
+ mnX = min(s.offX for s in o.sprites)
1221
+ mnY = min(s.offY for s in o.sprites)
1222
+ canvas.alpha_composite(im, ((sOrgX + o.orgX + mnX) * up, (sOrgY + o.orgY + mnY) * up))
1223
+
1224
+ if draw_footprint and "bgi" in roles:
1225
+ wm = bgi.BgiWalkmesh.from_bytes(_raw_bytes(env.container[roles["bgi"]].read()))
1226
+ # Project in the engine's RENDER frame: the engine negates the walkmesh Y before the GTE
1227
+ # (Memoria WalkMesh.cs:54), so flip Y here too. Without it a DEEP floor -- one whose .bgi
1228
+ # floor.org is 0, leaving it ~thousands of units off the floor plane (e.g. CPMP field 1554's
1229
+ # vine path) -- projects off the painting; near-plane floors were only ~20px off, masked by the
1230
+ # Blender view-offset. world_verts stays pre-flip (the BUILD ships it verbatim and the engine
1231
+ # applies its own flip), so the flip lives ONLY in this DISPLAY projection.
1232
+ wv = [(x, -y, z) for (x, y, z) in wm.world_verts()]
1233
+ draw = ImageDraw.Draw(canvas, "RGBA")
1234
+ for t in wm.tris:
1235
+ pts = []
1236
+ for vi in t.vtx:
1237
+ cx, cy = cam.to_canvas(wv[vi], cam_use) # exact GTE projection, render frame
1238
+ pts.append((cx * up, cy * up))
1239
+ draw.polygon(pts, fill=(90, 180, 255, 45), outline=(120, 225, 255, 160))
1240
+ canvas.save(out_path)
1241
+ return (W, H)
1242
+
1243
+
1244
+ _ABR_NONE = "PSX/FieldMap_Abr_None"
1245
+
1246
+
1247
+ def _overlay_shader(sprite) -> str:
1248
+ """The PSX field-map shader for an overlay, from its first sprite -- mirrors Memoria's OWN .bgx
1249
+ exporter (BGSCENE_DEF.cs:611): opaque (trans==0) => Abr_None, else Abr_{min(3, alpha)} (the PSX
1250
+ ABR blend mode: 0 average, 1 additive, 2 subtractive, 3 add-quarter). The .bgx importer honors
1251
+ the `Shader:` directive (BGSCENE_DEF.cs:321), so light/shadow overlays blend correctly."""
1252
+ if sprite.trans == 0:
1253
+ return _ABR_NONE
1254
+ return f"PSX/FieldMap_Abr_{min(3, sprite.alpha)}"
1255
+
1256
+
1257
+ def _depth_groups(overlays, sOrgX, sOrgY, sOrgZ, has_png, *, include_blend=True, depth_tolerance=1):
1258
+ """Bucket every renderable tile-sprite by (depth-bucket, shader) -- the PER-TILE occlusion split.
1259
+
1260
+ FF9 occludes the player per 16px TILE: the engine places each tile-sprite at its OWN depth
1261
+ (BGSCENE_DEF.cs:1742 combined / :1846 separate, quad z = sprite.depth) and the player competes at a
1262
+ single projected eye-Z. A pure-`.bgx` OVERLAY carries only ONE depth per PNG, so faithful occlusion
1263
+ means one layer per distinct tile depth -- NOT one layer per overlay at min(sprite.depth) (the old
1264
+ flatten, which pinned a whole multi-depth overlay to its NEAREST tile and drew the player UNDER art
1265
+ physically behind him). `depth_tolerance` coarsens depths into buckets (1 = exact per-distinct-depth).
1266
+
1267
+ Pure (no PIL/IO): `has_png(i)` reports whether overlay i has exported art on disk. Returns
1268
+ ((bucket_z, shader) -> [(overlay_index, sprite, scene_x, scene_y, mnX, mnY)], skipped_blend), where
1269
+ scene_x/scene_y are the tile's logical top-left on the canvas (sceneOrg + overlay.org + sprite.off,
1270
+ matching compose_background) and mnX/mnY are its overlay's min offsets (for the tile crop)."""
1271
+ tol = max(1, int(depth_tolerance))
1272
+ groups, skipped = {}, 0
1273
+ for i, o in enumerate(overlays):
1274
+ if not o.sprites:
1275
+ continue
1276
+ shader = _overlay_shader(o.sprites[0])
1277
+ if not include_blend and shader != _ABR_NONE:
1278
+ skipped += 1
1279
+ continue
1280
+ if not has_png(i):
1281
+ continue
1282
+ mnX = min(s.offX for s in o.sprites)
1283
+ mnY = min(s.offY for s in o.sprites)
1284
+ for s in o.sprites:
1285
+ bz = ((sOrgZ + o.orgZ + s.depth) // tol) * tol # tol==1 => exact per-distinct-depth
1286
+ groups.setdefault((bz, shader), []).append(
1287
+ (i, s, sOrgX + o.orgX + s.offX, sOrgY + o.orgY + s.offY, mnX, mnY))
1288
+ return groups, skipped
1289
+
1290
+
1291
+ def extract_layers(field: str, out_dir, *, game=None, bundle=None, upscale=None, include_blend=True,
1292
+ depth_tolerance=8, max_layers=48, bleed=1):
1293
+ """Per-TILE-DEPTH art layers for an EDITABLE custom-scene fork that PRESERVES per-tile occlusion.
1294
+
1295
+ Real FF9 fields occlude the player per 16px tile (each tile-sprite drawn at its own depth); a
1296
+ pure-`.bgx` OVERLAY can carry only ONE depth per PNG, so this SPLITS each overlay into one tight
1297
+ sub-PNG per distinct tile depth (within `depth_tolerance`), each emitted at that depth's own `z` +
1298
+ explicit `position`/`size`. The engine redraws the depth-ordered scene, so the 3D player is occluded
1299
+ by / occludes each layer exactly like the real field (smaller z = nearer = drawn in front), and
1300
+ light/shadow overlays blend (Abr shaders, BGSCENE_DEF.cs:597). This REPLACES the old
1301
+ one-PNG-per-overlay-at-min(sprite.depth) flatten (a verbatim port of Memoria's own lossy `.bgx`
1302
+ exporter, BGSCENE_DEF.cs:592), which drew the player UNDER any overlay whose nearest tile sat in
1303
+ front of him (the "Zidane under the boxes" bug).
1304
+
1305
+ Tiles are cropped from each overlay's `Overlay{i}.png`, which is now assembled OFFLINE from the
1306
+ atlas (`_overlay_art` / scene.bgart -- no in-game `[Export] Field=1` needed), falling back to
1307
+ Memoria's on-disk export dump only if the atlas can't be read. `bgs.tile_box` gives each tile's
1308
+ crop. `depth_tolerance` buckets nearby depths into one layer (1 = exact per-distinct-depth); the
1309
+ default 8 keeps each smooth surface whole (real surfaces vary only a few depth units per tile) while
1310
+ still splitting at the big depth jumps that actually occlude. `max_layers` then auto-coarsens the
1311
+ tolerance until the count fits (default 48) -- a real field can split into HUNDREDS of distinct tile
1312
+ depths (field 122 = 215 at tol 1), which both lags the load (one GameObject/texture per layer) and
1313
+ multiplies tile-cut seams. `bleed` edge-extends opaque layers to hide the bilinear cut seams. `upscale`
1314
+ defaults to the active export factor (TileSize // 16) so the crop matches the overlay PNGs' own
1315
+ pixels-per-tile; pass a value only to force it. Returns None only if the field has no readable art at
1316
+ all. `include_blend` (default) emits the additive/subtractive light+shadow overlays too.
1317
+
1318
+ Co-located tiles sharing a (depth-bucket, shader) merge into one layer (correct for a tiled plane,
1319
+ approximate for overlapping animation frames at the same depth -- a known v1 simplification)."""
1320
+ res = _overlay_art(field, game=game, bundle=bundle)
1321
+ if res is None:
1322
+ return None
1323
+ overlays, provider, factor, _src, _atlas = res
1324
+ up = factor if upscale is None else upscale # the overlay PNGs are `factor` px/tile
1325
+ _, _, roles, env = find_field(field, game=game, bundle=bundle)
1326
+ bgs_bytes = _raw_bytes(env.container[roles["bgs"]].read())
1327
+ h = bgs.parse_header(bgs_bytes)
1328
+ sOrgX, sOrgY, sOrgZ = h.bounds[2], h.bounds[3], h.bounds[0]
1329
+ c0 = bgs.parse_cameras(bgs_bytes)[0]
1330
+
1331
+ _png_cache = {}
1332
+ def _png(i):
1333
+ if i not in _png_cache:
1334
+ _png_cache[i] = provider(i)
1335
+ return _png_cache[i]
1336
+
1337
+ def _has_png(i):
1338
+ return _png(i) is not None
1339
+
1340
+ tol = max(1, int(depth_tolerance))
1341
+ groups, skipped = _depth_groups(overlays, sOrgX, sOrgY, sOrgZ, _has_png,
1342
+ include_blend=include_blend, depth_tolerance=tol)
1343
+ while len(groups) > max_layers and tol < 4096: # runaway-field backstop: coarsen the bucket
1344
+ tol *= 2
1345
+ groups, skipped = _depth_groups(overlays, sOrgX, sOrgY, sOrgZ, _has_png,
1346
+ include_blend=include_blend, depth_tolerance=tol)
1347
+
1348
+ layers, blend = _render_depth_groups(groups, _png, out_dir, upscale=up, bleed=bleed)
1349
+ return {"layers": layers, "blend_layers": blend, "skipped_blend_overlays": skipped,
1350
+ "range": list(c0.range), "depth_tolerance": tol,
1351
+ "tiles": sum(len(v) for v in groups.values())}
1352
+
1353
+
1354
+ def _edge_bleed(img, px):
1355
+ """Dilate opaque pixels `px` px outward into transparent neighbours (edge-extend). The kit's `.bgx`
1356
+ layer PNGs load BILINEAR (Unity `LoadImage` default; the engine's `.memnfo` `FilterMode Point` hook is
1357
+ dummied), so a tile cut from a larger image samples TRANSPARENT just past its edge -> a 1px seam at
1358
+ every boundary between tiles split into different depth layers. Bleeding real edge colour into a 1px
1359
+ margin makes the bilinear tap land on art, not transparent, killing the seam. Pure PIL (no numpy):
1360
+ each pass composites eight 1px-shifted copies UNDER the image, filling only the still-transparent
1361
+ border with the nearest edge pixel."""
1362
+ from PIL import Image # noqa: PLC0415 - only the art path needs PIL
1363
+ shifts = ((-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (1, 1), (-1, 1), (1, -1))
1364
+ for _ in range(px):
1365
+ under = Image.new("RGBA", img.size, (0, 0, 0, 0))
1366
+ for dx, dy in shifts:
1367
+ shifted = Image.new("RGBA", img.size, (0, 0, 0, 0))
1368
+ shifted.paste(img, (dx, dy))
1369
+ under = Image.alpha_composite(under, shifted) # accumulate every neighbour shift
1370
+ img = Image.alpha_composite(under, img) # original on top -> only the border fills
1371
+ return img
1372
+
1373
+
1374
+ def _render_depth_groups(groups, png_provider, out_dir, *, upscale=4, bleed=1):
1375
+ """Composite each (depth-bucket, shader) group from `_depth_groups` into one tight sub-PNG and
1376
+ return its `[[layers]]` list (back-to-front by depth). `png_provider(i)` yields overlay i's exported
1377
+ `Overlay{i}.png` as an RGBA PIL image; each tile is cropped from it via `bgs.tile_box` and blitted at
1378
+ its own canvas spot. Each layer gets an EXPLICIT `position`/`size` (the group's tile bbox) so the
1379
+ engine maps the sub-PNG onto exactly the quad the tiles occupy.
1380
+
1381
+ `bleed` (logical px) gives each OPAQUE layer a transparent margin that `_edge_bleed` fills with the
1382
+ tile edge colour, so the engine's bilinear sampling doesn't bleed a cut tile's edge to transparent
1383
+ (the 1px seams). Blend (additive) layers skip it: a transparent bleed only DIMS their glow (no dark
1384
+ seam) and a margin would double-add the glow where layers overlap. Split out for unit-testing the
1385
+ crop + placement (and the bleed) without a real `.bgs`."""
1386
+ from PIL import Image # noqa: PLC0415 - only the art path needs PIL
1387
+ out = Path(out_dir)
1388
+ out.mkdir(parents=True, exist_ok=True)
1389
+ layers, blend = [], 0
1390
+ for (bz, shader) in sorted(groups, key=lambda k: (-k[0], k[1])): # back (large z) -> front
1391
+ tiles = groups[(bz, shader)]
1392
+ gx0 = min(t[2] for t in tiles)
1393
+ gy0 = min(t[3] for t in tiles)
1394
+ gx1 = max(t[2] + bgs.TILE for t in tiles)
1395
+ gy1 = max(t[3] + bgs.TILE for t in tiles)
1396
+ m = bleed if (shader == _ABR_NONE and bleed > 0) else 0 # opaque layers get a bleed margin
1397
+ px0, py0 = gx0 - m, gy0 - m
1398
+ sw, sh = (gx1 - gx0) + 2 * m, (gy1 - gy0) + 2 * m
1399
+ canvas = Image.new("RGBA", (sw * upscale, sh * upscale), (0, 0, 0, 0))
1400
+ for (i, s, sx, sy, mnX, mnY) in tiles: # blit each tile at its own canvas spot
1401
+ canvas.alpha_composite(png_provider(i).crop(bgs.tile_box(s, mnX, mnY, upscale)),
1402
+ ((sx - px0) * upscale, (sy - py0) * upscale))
1403
+ if m:
1404
+ canvas = _edge_bleed(canvas, m * upscale) # fill the margin with edge colour
1405
+ abr = shader.rsplit("_", 1)[-1] # None / 0 / 1 / 2 / 3
1406
+ name = f"layer_{int(bz):05d}_{abr}.png"
1407
+ canvas.save(out / name)
1408
+ L = {"image": name, "z": int(bz), "position": [int(px0), int(py0)], "size": [int(sw), int(sh)]}
1409
+ if shader != _ABR_NONE:
1410
+ L["shader"] = shader
1411
+ blend += 1
1412
+ layers.append(L)
1413
+ return layers, blend
1414
+
1415
+
1416
+ def _world_walkmesh_obj_text(wm) -> str:
1417
+ """A world-frame Wavefront .obj (multi-floor aware) re-exported from a parsed walkmesh: the verts
1418
+ ARE the world coords (BgiWalkmesh.world_verts), one `o floor_N` per floor -- build with
1419
+ `[walkmesh] frame = "world"` (bgi.build, orgPos=0) so the engine renders them verbatim."""
1420
+ wv = wm.world_verts()
1421
+ faces = [tuple(t.vtx) for t in wm.tris]
1422
+ floor_ids = [t.floor_ndx for t in wm.tris]
1423
+ order = []
1424
+ for f in floor_ids:
1425
+ if f not in order:
1426
+ order.append(f)
1427
+ lines = ["# walkmesh re-exported by ff9mapkit (world frame, orgPos=0)"]
1428
+ for (x, y, z) in wv:
1429
+ lines.append(f"v {x} {y} {z}")
1430
+ if len(order) > 1:
1431
+ for fid in order:
1432
+ lines.append(f"o floor_{fid}")
1433
+ for (a, b, c), fl in zip(faces, floor_ids):
1434
+ if fl == fid:
1435
+ lines.append(f"f {a + 1} {b + 1} {c + 1}")
1436
+ else:
1437
+ for (a, b, c) in faces:
1438
+ lines.append(f"f {a + 1} {b + 1} {c + 1}")
1439
+ return "\n".join(lines) + "\n"
1440
+
1441
+
1442
+ def _write_links_toml(wm, path) -> int:
1443
+ """Write the adjacency sidecar (cross-floor seams + header) for an editable multi-floor walkmesh.
1444
+ walkmesh.obj carries geometry; this carries the seams geometry can't express. Reconciled on build
1445
+ by world position (build._apply_links). Returns the seam count. See docs/WALKMESH_EDITING.md."""
1446
+ seams = wm.extract_seams()
1447
+ L = ["# Adjacency sidecar for an editable multi-floor walkmesh (ff9mapkit). walkmesh.obj carries the",
1448
+ "# geometry; this carries the CROSS-FLOOR seams geometry can't express (FF9 floors use disjoint",
1449
+ "# vertex sets, so rebuild-from-geometry can't recover them). On build with `[walkmesh] obj +",
1450
+ "# links`, each seam is re-matched to your edited geometry by WORLD POSITION; a seam whose edge",
1451
+ "# you moved/deleted warns instead of silently dropping. Auto-generated -- rarely hand-edited.",
1452
+ "",
1453
+ "[header]",
1454
+ f"active_floor = {wm.activeFloor}",
1455
+ f"active_tri = {wm.activeTri}",
1456
+ f"char_pos = [{wm.charPos.x}, {wm.charPos.y}, {wm.charPos.z}]",
1457
+ ""]
1458
+ for (fa, a, fb, b) in seams:
1459
+ L += ["[[seam]]", f"a_floor = {fa}",
1460
+ f"a_edge = [[{a[0][0]}, {a[0][1]}, {a[0][2]}], [{a[1][0]}, {a[1][1]}, {a[1][2]}]]",
1461
+ f"b_floor = {fb}"]
1462
+ if b:
1463
+ L.append(f"b_edge = [[{b[0][0]}, {b[0][1]}, {b[0][2]}], [{b[1][0]}, {b[1][1]}, {b[1][2]}]]")
1464
+ L.append("")
1465
+ Path(path).write_text("\n".join(L) + "\n", encoding="utf-8", newline="\n")
1466
+ return len(seams)
1467
+
1468
+
1469
+ MIN_CUSTOM_AREA = 10 # engine builds 'FBG_N<area>' + reads exactly 2 chars -> areas 0-9 black-screen
1470
+
1471
+
1472
+ def safe_custom_area(area: int) -> int:
1473
+ """An area a CUSTOM scene (ships its own art) can safely render: a source area >= 10 as-is, else a
1474
+ safe default (11). For BG-borrow the area MUST equal the real field's, so 0-9 can't borrow at all."""
1475
+ return area if area >= MIN_CUSTOM_AREA else 11
1476
+
1477
+
1478
+ def _walkmesh_hotfix_line(field) -> str:
1479
+ """The ``[field] walkmesh_tri_toggles`` line for a fork of real ``field`` when that field has a LOAD-TIME
1480
+ engine walkmesh hotfix (``BGI_triSetActive`` keyed on its real ``fldMapNo``) the fork would lose on its
1481
+ custom id -- so the build reproduces it by prepending the toggles to Main_Init. ``""`` when the field has
1482
+ no statically-reproducible walkmesh hotfix (almost all). See :mod:`ff9mapkit.walkmesh_hotfixes`."""
1483
+ from . import walkmesh_hotfixes as _wh
1484
+ from .dialogue import _resolve_field_id
1485
+ try:
1486
+ h = _wh.info(_resolve_field_id(field))
1487
+ except Exception:
1488
+ return ""
1489
+ if h is not None and h.engine_remapped:
1490
+ # The shipped engine reproduces this hotfix for the fork id (s29 EffectiveFieldId, original timing) -- a
1491
+ # Main_Init toggle prepend would mis-time it (it fires before the field's props settle onto these tris,
1492
+ # snapping them a floor down -- the Ipsen 2507 chests). So emit NO toggle; note why for the reader.
1493
+ return (f"# {h.name}: load-time walkmesh hotfix reproduced by the engine fork-donor remap (s29,\n"
1494
+ f"# EffectiveFieldId, original timing) -- NOT a walkmesh_tri_toggles prepend (which would mis-time\n"
1495
+ f"# prop placement). Nothing to author here. ({h.source})\n")
1496
+ if not (h and h.auto):
1497
+ return ""
1498
+ arr = ", ".join(f"[{t}, {s}]" for t, s in h.toggles)
1499
+ return (f"walkmesh_tri_toggles = [{arr}] # {h.name}: reproduce its load-time engine walkmesh hotfix\n"
1500
+ f"# (engine BGI_triSetActive keyed on real fldMapNo {h.field_id} is lost on a custom id; {h.source})\n")
1501
+
1502
+
1503
+ def _area_title_hide_lines(meta, *, verbatim=False) -> str:
1504
+ """For a SYNTH (non-verbatim) native/editable fork of an AREA-TITLE field, auto-emit ``hide_area_title``
1505
+ + the overlay range so the inherited title card doesn't sit STATIC. The title overlays are active-by-
1506
+ default in the donor scene the fork ships; a synth fork has no donor ``.eb`` to run the show+fade, so the
1507
+ card would just sit there claiming to be that place (the same leak the World Hub's BG-borrow hit). A
1508
+ ``--verbatim`` fork CARRIES the donor ``.eb``'s scenario-gated show+fade, so it is left alone (the title is
1509
+ correct + wanted there). ``""`` when the field has no area title / the manifest is unreachable (offline).
1510
+ Reuses the proven hub mechanism (``content.areatitle`` via the ``[field] hide_area_title`` build hook)."""
1511
+ if verbatim or not meta:
1512
+ return ""
1513
+ from . import areatitle as _at
1514
+ fbg = f"FBG_N{int(meta['area']):02d}_{meta['mapid']}"
1515
+ rng = _at.title_range(fbg)
1516
+ if not rng:
1517
+ return ""
1518
+ s, e = rng
1519
+ return (f"# this synth fork borrows an area-title room ({fbg}); with no donor .eb to fade the card it would\n"
1520
+ f"# sit STATIC, so hide it (use --verbatim to keep the real, scenario-gated show+fade instead):\n"
1521
+ f"hide_area_title = true\n"
1522
+ f"area_title_overlays = [{s}, {e}]\n")
1523
+
1524
+
1525
+ def write_editable_project(field: str, out_dir, *, name: str | None = None, field_id: int = 4003,
1526
+ text_block: int = 1073, game=None, bundle=None,
1527
+ id_remap=None, live_seams=False, graft_player_funcs=False, carry_text=False,
1528
+ graft_savepoint=False):
1529
+ """Fork a real field as a fully EDITABLE custom scene (vs BG-borrow): re-export its walkmesh via the
1530
+ world-frame builder + extract its art as per-DEPTH layers (occlusion preserved) + reuse its camera.
1531
+
1532
+ Emits a custom-scene `field.toml` (NO borrow_bg) ready for `ff9mapkit build` and for repainting any
1533
+ single `layer_*.png` / editing `walkmesh.obj`. Returns (metadata, field_toml_path). The art is now
1534
+ assembled OFFLINE from the atlas (`extract_layers` / scene.bgart) -- no in-game `[Export] Field=1`
1535
+ needed; raises RuntimeError only if the field has no readable atlas at all (use plain import for a
1536
+ BG-borrow fork that reuses the art as-is)."""
1537
+ out = Path(out_dir)
1538
+ meta = extract_field(field, out, game=game, bundle=bundle) # writes camera.bgx + walkmesh.bgi
1539
+ name = name or (meta["mapid"].split("_")[0] + "_EDIT")
1540
+ # A custom scene ships its own art under FBG_N<area>_<name>, so the area is just a folder key --
1541
+ # any value >= 10 works (single-digit areas black-screen via the engine's 2-char FBG lookup).
1542
+ # Remap a low source area to a safe one so forks of early-game (area 0-9) fields build + render.
1543
+ safe_area = safe_custom_area(meta["area"])
1544
+ remap_note = ("" if safe_area == meta["area"] else
1545
+ f"# NOTE: source area {meta['area']} < 10 black-screens via the engine's FBG_N<area> "
1546
+ f"lookup, so this\n# custom scene uses area {safe_area} (it ships its own art -- the "
1547
+ f"source area is just a folder key).\n")
1548
+ # A MULTI-camera field paints a different region per camera (BGOVERLAY_DEF.camNdx). An editable fork
1549
+ # resolves ONE camera from its single [camera] block and doesn't reconstruct the per-camera switch
1550
+ # zones, so it captures CAMERA 0 ONLY -- cameras 1+ and their art are dropped. --verbatim keeps the
1551
+ # real .eb's camera switching + all cameras' art (the faithful multi-camera path).
1552
+ multicam_note = ("" if meta.get("cameras", 1) <= 1 else
1553
+ f"# WARNING: {meta['field']} is MULTI-CAMERA ({meta['cameras']} cameras); an editable fork\n"
1554
+ f"# captures CAMERA 0 ONLY (switch zones for cameras 1+ aren't reconstructed, so their\n"
1555
+ f"# walkmesh regions + art are dropped). For a faithful multi-camera fork use:\n"
1556
+ f"# ff9mapkit import {field} --verbatim\n")
1557
+ wm = bgi.BgiWalkmesh.from_bytes((out / "walkmesh.bgi").read_bytes())
1558
+ nfloors = len(wm.floors)
1559
+ (out / "walkmesh.obj").write_text(_world_walkmesh_obj_text(wm), encoding="utf-8", newline="\n")
1560
+ nseams = _write_links_toml(wm, out / "walkmesh.links.toml") if nfloors > 1 else 0
1561
+
1562
+ layers_info = extract_layers(field, out, game=game, bundle=bundle)
1563
+ if layers_info is None:
1564
+ raise RuntimeError(
1565
+ f"{meta['field']}: editable art needs a readable field atlas (assembled offline). The atlas "
1566
+ f"couldn't be read from p0data -- OR use `ff9mapkit import {field}` (BG-borrow: reuses the "
1567
+ f"real art as-is, no repaint).")
1568
+ layers = layers_info["layers"]
1569
+ meta["layers"] = len(layers)
1570
+ meta["blend_layers"] = layers_info["blend_layers"]
1571
+ meta["editable_name"] = name
1572
+ # A single composited backdrop (opaque art) for the Blender modeling preview: the per-tile-depth
1573
+ # sub-layers are tight crops that don't FIT-stretch, so the add-on models against this instead.
1574
+ try: # preview-only -- never fatal
1575
+ compose_background(field, out / "background.png", game=game, bundle=bundle, draw_footprint=False)
1576
+ except Exception:
1577
+ pass
1578
+
1579
+ content_blocks, control_dir, content_summary = _content_for_import(
1580
+ field, game, out_dir=out, name=name, id_remap=id_remap, live_seams=live_seams,
1581
+ graft_player_funcs=graft_player_funcs, carry_text=carry_text, graft_savepoint=graft_savepoint)
1582
+ meta["imported_content"] = content_summary
1583
+ cm = meta["camera"]
1584
+ wb = meta["walkmesh_bounds"]
1585
+ x, z = meta["player_start"]
1586
+ scroll = "[camera.scroll]\nenabled = true\n" if meta["scrolling"] else ""
1587
+ control_line = f"control_direction = {control_dir} # imported WASD-vs-camera tuning\n" if control_dir is not None else ""
1588
+
1589
+ def _layer_block(L):
1590
+ pos, sz = L.get("position", [0, 0]), L.get("size")
1591
+ s = (f'[[layers]]\nimage = "{L["image"]}"\nz = {L["z"]}\n'
1592
+ f'position = [{int(pos[0])}, {int(pos[1])}]')
1593
+ if sz: # tight per-tile-depth sub-PNG (vs full canvas)
1594
+ s += f'\nsize = [{int(sz[0])}, {int(sz[1])}]'
1595
+ return s + (f'\nshader = "{L["shader"]}"' if L.get("shader") else "")
1596
+ layer_blocks = "\n".join(_layer_block(L) for L in layers)
1597
+
1598
+ if nfloors > 1:
1599
+ reshape = ('# To RESHAPE the geometry: edit walkmesh.obj, then replace the bgi line above with:\n'
1600
+ '# obj = "walkmesh.obj"\n# links = "walkmesh.links.toml"\n# frame = "world"\n'
1601
+ f'# (the seam sidecar re-attaches this field\'s {nseams} cross-floor links to your edits\n'
1602
+ '# by world position; a seam whose edge you moved warns instead of dropping silently.)\n')
1603
+ else:
1604
+ reshape = ('# To RESHAPE: edit walkmesh.obj, then replace the bgi line above with:\n'
1605
+ '# obj = "walkmesh.obj"\n# frame = "world"\n')
1606
+ walkmesh_toml = (
1607
+ f"[walkmesh]\n"
1608
+ f'bgi = "walkmesh.bgi" # the real field\'s walkmesh ({nfloors} floor(s)) -- connectivity preserved\n'
1609
+ f"{reshape}"
1610
+ )
1611
+ toml = (
1612
+ f"# EDITABLE fork of {meta['field']} (area {meta['area']}) by ff9mapkit -- a full CUSTOM SCENE.\n"
1613
+ f"# Re-exported walkmesh + the real art split into one layer per TILE-DEPTH (per-tile occlusion\n"
1614
+ f"# preserved -- the player is occluded by each tile exactly as in the real field).\n"
1615
+ f"# Repaint any layer_*.png, reshape walkmesh.obj, add content -- then: ff9mapkit build {name}.field.toml\n"
1616
+ f"# Camera: pitch {cm['pitch_deg']} deg, FOV {cm['fov_deg']} deg, range {cm['range'][0]}x{cm['range'][1]}"
1617
+ f"{' (SCROLLING)' if meta['scrolling'] else ''}. Walkmesh bounds: x {wb['x']} z {wb['z']}.\n"
1618
+ f"{remap_note}{multicam_note}\n"
1619
+ f"[field]\n"
1620
+ f"id = {field_id}\n"
1621
+ f'name = "{name}"\n'
1622
+ f"area = {safe_area}\n"
1623
+ f"text_block = {text_block}\n"
1624
+ f"{_walkmesh_hotfix_line(field)}"
1625
+ f"{_area_title_hide_lines(meta)}\n"
1626
+ f"[camera]\n"
1627
+ f'borrow = "camera.bgx"\n'
1628
+ f"{control_line}"
1629
+ f"{scroll}\n"
1630
+ f"{walkmesh_toml}\n"
1631
+ f"{layer_blocks}\n\n"
1632
+ f"[player]\n"
1633
+ f"spawn = [{x}, {z}]\n\n"
1634
+ f"# --- add NPCs/dialogue (uncomment + edit); keep positions within the walkmesh bounds above ---\n"
1635
+ f'# [[npc]]\n# name = "Vivi"\n# preset = "vivi"\n# pos = [{x}, {z}]\n# dialogue = "Hello, traveler."\n\n'
1636
+ f"{_content_section(content_blocks, x, z)}"
1637
+ )
1638
+ p = Path(out_dir) / f"{name}.field.toml"
1639
+ p.write_text(toml, encoding="utf-8", newline="\n")
1640
+ meta["field_toml"] = str(p)
1641
+ return meta, p
1642
+
1643
+
1644
+ def _mod_folders(game=None) -> list:
1645
+ """Active mod folders (Memoria.ini [Mod] FolderNames), in listed order. The engine stacks these over
1646
+ the base assets, so a background mod (Moguri) supplies the field atlas the player actually sees."""
1647
+ try:
1648
+ ini = config.find_game_path(game) / "Memoria.ini"
1649
+ for line in ini.read_text(encoding="utf-8", errors="replace").splitlines():
1650
+ s = line.strip()
1651
+ if s.lower().startswith("foldernames") and "=" in s and not s.startswith(";"):
1652
+ return [v.strip().strip('"') for v in s.split("=", 1)[1].split(",") if v.strip().strip('"')]
1653
+ except OSError:
1654
+ pass
1655
+ return []
1656
+
1657
+
1658
+ def _active_tilesize(game=None) -> int:
1659
+ """The effective field-map atlas TileSize (Memoria.ini; a mod folder's ini overrides the base). Vanilla
1660
+ 32 / Moguri 64. The native atlas MUST be packed at this tile size or the engine samples the wrong cells
1661
+ (garbled art) -- it lays each tile out at i*(TileSize+pad)."""
1662
+ gp = config.find_game_path(game)
1663
+ ts = 32
1664
+ for ini in [gp / "Memoria.ini"] + [gp / f / "Memoria.ini" for f in _mod_folders(game)]:
1665
+ try:
1666
+ for line in ini.read_text(encoding="utf-8", errors="replace").splitlines():
1667
+ s = line.strip()
1668
+ if s.lower().startswith("tilesize") and "=" in s and not s.startswith(";"):
1669
+ ts = int(s.split("=", 1)[1].split(";")[0].strip())
1670
+ except (OSError, ValueError):
1671
+ pass
1672
+ return ts
1673
+
1674
+
1675
+ def _atlas_png_bytes(tex) -> bytes:
1676
+ import io # noqa: PLC0415
1677
+ buf = io.BytesIO()
1678
+ tex.image.save(buf, format="PNG")
1679
+ return buf.getvalue()
1680
+
1681
+
1682
+ _MOD_ENV_CACHE: dict = {}
1683
+
1684
+
1685
+ def _load_mod_bundle(path):
1686
+ """Load + cache a mod-folder p0data bundle (read-only, static). A multi-member campaign that needs
1687
+ several fields' mod atlases then loads each bundle ONCE instead of re-loading it per member."""
1688
+ p = str(path)
1689
+ if p not in _MOD_ENV_CACHE:
1690
+ try:
1691
+ _MOD_ENV_CACHE[p] = _unitypy().load(p)
1692
+ except Exception: # noqa: BLE001 - a non-bundle / unreadable bin
1693
+ _MOD_ENV_CACHE[p] = None
1694
+ return _MOD_ENV_CACHE[p]
1695
+
1696
+
1697
+ def _mod_field_atlas(folder: str, game=None):
1698
+ """The field's ``atlas.png`` from the highest-priority MOD folder that ships it (Moguri's high-res
1699
+ atlas), as PNG bytes -- or None. Scans each mod folder's loose Fieldmaps, then its (cached) p0data."""
1700
+ gp = config.find_game_path(game)
1701
+ key = f"assets/resources/fieldmaps/{folder.lower()}/atlas.png"
1702
+ for mod in _mod_folders(game):
1703
+ sa = gp / mod / "StreamingAssets"
1704
+ loose = sa / "Assets" / "Resources" / "Fieldmaps" / folder / "atlas.png"
1705
+ if loose.is_file():
1706
+ return loose.read_bytes()
1707
+ for b in sorted(sa.glob("p0data*.bin")):
1708
+ env = _load_mod_bundle(b)
1709
+ if env is None:
1710
+ continue
1711
+ for path, obj in env.container.items():
1712
+ if path.lower() == key:
1713
+ return _atlas_png_bytes(obj.read())
1714
+ return None
1715
+
1716
+
1717
+ def _native_atlas(field: str, game=None, bundle=None):
1718
+ """(atlas_png_bytes, source) for a NATIVE fork: the field atlas packed at the ACTIVE TileSize. The base
1719
+ bundle atlas fits vanilla (32); when a mod raises TileSize (Moguri = 64) the base atlas no longer fits,
1720
+ so we ship the bg mod's high-res atlas -- a Moguri player gets Moguri art, seamless + faithful. Returns
1721
+ the first atlas whose dimensions accommodate the field's sprite coords at the active TileSize."""
1722
+ from PIL import Image # noqa: PLC0415
1723
+ import io # noqa: PLC0415
1724
+ folder, _ = resolve_field(field, game)
1725
+ _, _, roles, env = find_field(field, game=game, bundle=bundle)
1726
+ bgs_bytes = _raw_bytes(env.container[roles["bgs"]].read())
1727
+ ts = _active_tilesize(game)
1728
+
1729
+ def _fits(png: bytes) -> bool:
1730
+ w, h = Image.open(io.BytesIO(png)).size
1731
+ _, ov = bgs.parse_overlays(bgs_bytes) # fresh overlays (resolve_sprites appends)
1732
+ bgs.resolve_sprites(bgs_bytes, ov, w, ts)
1733
+ sprites = [s for o in ov for s in o.sprites]
1734
+ return (max((s.atlasX for s in sprites), default=0) <= w
1735
+ and max((s.atlasY for s in sprites), default=0) <= h)
1736
+
1737
+ base = _atlas_png_bytes(env.container[roles["atlas"]].read()) if "atlas" in roles else None
1738
+ if base is not None and _fits(base):
1739
+ return base, "base"
1740
+ mod = _mod_field_atlas(folder, game=game) # Moguri / a bg mod's high-res atlas
1741
+ if mod is not None and _fits(mod):
1742
+ return mod, f"mod (TileSize {ts})"
1743
+ return (base or mod), ("base (TILESIZE MISMATCH -- art will garble)" if base else "none found")
1744
+
1745
+
1746
+ def apply_player_swap(toml_path, char, *, neutralize=False):
1747
+ """Swap the verbatim fork's player to ``char`` in place (patches the ``[verbatim_eb]`` sidecar `.eb`).
1748
+ Shared by the single ``import --swap-player`` and the chain (every member). When ``neutralize`` is set,
1749
+ also rewrites the player's scripted GESTURES to the rig's idle so a cutscene field stands cleanly instead
1750
+ of glitching (:func:`playerswap.neutralize_gestures`). Returns the count of scripted player GESTURES (the
1751
+ number that would glitch un-neutralized / were neutralized -- the caller phrases the message), or ``None``
1752
+ if the project has no verbatim sidecar to swap (a degraded / non-verbatim member). Raises ``ValueError``
1753
+ on an unknown char or a member with no swappable player entry."""
1754
+ import tomllib
1755
+ from . import playerswap
1756
+ toml_path = Path(toml_path)
1757
+ vb = tomllib.loads(toml_path.read_text(encoding="utf-8")).get("verbatim_eb")
1758
+ if not vb or "bin" not in vb:
1759
+ return None # not a verbatim fork (e.g. a logic-only stub) -> nothing to swap
1760
+ binp = toml_path.parent / vb["bin"]
1761
+ from .eb import EbScript
1762
+ original = binp.read_bytes()
1763
+ # resolve the swap targets ONCE on the original bytes: swap_targets keys on the Init SetModel id, which
1764
+ # swap_player MUTATES, so re-deriving on the swapped bytes drifts to a different entry on a Zidane-present
1765
+ # multi-PC field (neutralizing the wrong actor). Pin the set and reuse it for every pass.
1766
+ targets = playerswap.swap_targets(EbScript.from_bytes(original))
1767
+ swapped = playerswap.swap_player(original, char, entry=targets)
1768
+ n_gestures = playerswap.scripted_gesture_ops(swapped, entry=targets)
1769
+ if neutralize:
1770
+ swapped = playerswap.neutralize_gestures(swapped, char, entry=targets)
1771
+ binp.write_bytes(swapped)
1772
+ return n_gestures
1773
+
1774
+
1775
+ def write_native_project(field: str, out_dir, *, name: str | None = None, field_id: int = 4003,
1776
+ text_block: int = 1073, game=None, bundle=None,
1777
+ id_remap=None, live_seams=False, graft_player_funcs=False, carry_text=False,
1778
+ graft_savepoint=False, verbatim=False):
1779
+ """Fork a real field as a NATIVE custom scene: ship its OWN ``atlas.png`` + ``.bgs`` (the real
1780
+ per-tile-depth scene) + a custom walkmesh ``.bgi``, and NO ``.bgx``.
1781
+
1782
+ The engine then renders it through the SEAMLESS native path (point-sampled atlas, per-tile-depth
1783
+ quads) -- exactly how Moguri ships (vanilla ``.bgs`` + a high-res atlas, no ``.bgx``), so the player
1784
+ is occluded per-tile with none of the bilinear tile seams a ``.bgx`` memoria-image fork has. The area
1785
+ is remapped >= 10 so the ``FBG_N<area>`` lookup doesn't black-screen, which also lets this fork
1786
+ area<10 fields that BG-borrow can't. Repaint by editing ``atlas.png`` (or the Memoria PSD pipeline).
1787
+ Returns (metadata, field_toml_path). Needs no in-game export (unlike ``--editable``)."""
1788
+ out = Path(out_dir)
1789
+ # camera.bgx (content logic) + walkmesh.bgi (real walkmesh)
1790
+ meta = extract_field(field, out, game=game, bundle=bundle)
1791
+ # atlas packed at the ACTIVE TileSize: base for vanilla, Moguri's high-res atlas when a mod raised it
1792
+ atlas_bytes, atlas_src = _native_atlas(field, game=game, bundle=bundle)
1793
+ if atlas_bytes is None:
1794
+ raise RuntimeError(f"{meta['field']}: no atlas.png in the field bundle -- can't ship a native scene "
1795
+ f"(use `ff9mapkit import {field}` for a BG-borrow fork instead).")
1796
+ (out / "atlas.png").write_bytes(atlas_bytes)
1797
+ meta["atlas_source"] = atlas_src
1798
+ meta["atlas_tile_size"] = _active_tilesize(game) # the TileSize the atlas was packed at (repaint round-trip)
1799
+ name = name or (meta["mapid"].split("_")[0] + "_NATIVE")
1800
+ safe_area = safe_custom_area(meta["area"])
1801
+ remap_note = ("" if safe_area == meta["area"] else
1802
+ f"# NOTE: source area {meta['area']} < 10 black-screens via the engine's FBG_N<area> "
1803
+ f"lookup, so this\n# native scene uses area {safe_area} (it ships its own art).\n")
1804
+ # ship the field's NATIVE .bgs VERBATIM -- it carries the per-tile depth the engine renders seamlessly
1805
+ _, folder, roles, env = find_field(field, game=game, bundle=bundle)
1806
+ (out / "scene.bgs.bytes").write_bytes(_raw_bytes(env.container[roles["bgs"]].read()))
1807
+ # ship the field's SPS effect bins + texture (spt.tcb) VERBATIM. The engine loads field SPS from
1808
+ # FieldMaps/<SceneName>/<id>.sps by the RUNNING scene name; a fork's scene name is custom, so without
1809
+ # these its RunSPSCode triggers find no bin (spsBin == null) and the effect (fire / smoke / magic) never
1810
+ # draws -- the actor anim still plays (the symptom: forked Ice Cavern lost Vivi's melt-fire). Carry them
1811
+ # into a sps/ sidecar; build.py copies them under the fork's FBG folder. -> project-ff9-sps-fork.
1812
+ sps_pref = f"assets/resources/fieldmaps/{folder}/"
1813
+ sps_assets = {k.rsplit("/", 1)[-1]: v for k, v in env.container.items()
1814
+ if k.startswith(sps_pref) and (k.endswith(".sps.bytes") or k.endswith("spt.tcb.bytes"))}
1815
+ if sps_assets:
1816
+ (out / "sps").mkdir(exist_ok=True)
1817
+ for base, obj in sps_assets.items():
1818
+ (out / "sps" / base).write_bytes(_raw_bytes(obj.read()))
1819
+ meta["sps"] = len(sps_assets)
1820
+ meta["editable_name"] = name
1821
+ # ship the field's MapConfigData VERBATIM -- the 3D-model LIGHTING (per-floor lights + shadows + per-
1822
+ # object colors) the engine applies at load. Without it a native fork's models render bright/untinted.
1823
+ mc_bytes = extract_mapconfig(field, game=game)
1824
+ if mc_bytes:
1825
+ (out / "mapconfig.bytes").write_bytes(mc_bytes)
1826
+ meta["mapconfig"] = bool(mc_bytes)
1827
+
1828
+ cm = meta["camera"]
1829
+ wb = meta["walkmesh_bounds"]
1830
+ x, z = meta["player_start"]
1831
+ scroll = "[camera.scroll]\nenabled = true\n" if meta["scrolling"] else ""
1832
+ if verbatim:
1833
+ # VERBATIM .eb fork (docs/FORK_FIDELITY.md, the entry-0 carry): ship the donor's WHOLE event script;
1834
+ # the build runs the real logic instead of synthesizing. No declarative content (it's all in the .eb).
1835
+ import json as _json
1836
+
1837
+ from . import dialogue as _dlg
1838
+ from .content import verbatim as _vb
1839
+ from .eb import EbScript
1840
+ donor_eb = extract_event_script(field, game=game)
1841
+ (out / f"{name}.verbatim_eb.bin").write_bytes(donor_eb)
1842
+ _de = EbScript.from_bytes(donor_eb)
1843
+ dests = sorted({int(i.imm(0)) for e in _de.entries if not e.empty for f in e.funcs
1844
+ for i in _de.instrs(f) if i.op == 0x2B and i.imm(0) is not None})
1845
+ # Donor battle BGM: a verbatim fork carries the real Battle()/BattleEx() ops, but its custom id misses
1846
+ # the engine's (fldMapNo, scene) song lookup -> the boss/special theme is lost. Auto-emit [[battle_bgm]]
1847
+ # for the donor's scripted battle scenes whose song is non-zero (build -> a scene-keyed Music: line).
1848
+ from .dialogue import _resolve_field_id
1849
+ try:
1850
+ _donor_fid = _resolve_field_id(field)
1851
+ except (FileNotFoundError, ValueError):
1852
+ _donor_fid = None
1853
+ bgm_pairs = _donor_battle_bgm_pairs(donor_eb, _donor_fid, game)
1854
+ bgm_blocks = _render_battle_bgm_blocks(bgm_pairs)
1855
+ # retarget the Field() exits: import-chain pre-fills a LIVE table (doors warp into the chain's own
1856
+ # member forks); a single-field import leaves the commented fill-in template (byte-identical golden).
1857
+ rt_text, n_retargeted = _vb.render_retarget(dests, id_remap)
1858
+ rt_intro = (
1859
+ "# The Field() exits are RETARGETED to this chain's own member forks (import-chain); ids left out\n"
1860
+ "# of the table stay live seams back into the real game:\n" if n_retargeted else
1861
+ "# The Field() exits below point at REAL fields (live seams back into the game). To redirect any to\n"
1862
+ "# your own fork, set its id and uncomment the table (omit a line to keep that exit a live seam):\n")
1863
+ # ship the donor's WHOLE text per language: the verbatim .eb's index-txids resolve straight into it
1864
+ # (no remap, unlike --carry-text). ONE batched scan of all 7 langs (was 7 full resources.assets scans
1865
+ # per member -- the dominant import-chain cost); per-lang is coarse but us is right.
1866
+ mes_by_lang = _dlg.extract_field_mes_all_langs(field, game=game)
1867
+ text_line = ""
1868
+ if mes_by_lang:
1869
+ (out / f"{name}.verbatim_mes.json").write_text(_json.dumps(mes_by_lang), encoding="utf-8")
1870
+ text_line = f'text = "{name}.verbatim_mes.json" # the donor field text (its index-txids resolve in)\n'
1871
+ control_line = ""
1872
+ content_tail = (
1873
+ "# --- VERBATIM .eb fork: this field ships its REAL event script WHOLE (entry-0 + every object +\n"
1874
+ "# every gateway, slot layout intact) and runs the original logic, so the declarative blocks below\n"
1875
+ "# are NOT used here -- EXCEPT [startup], which presets STORY STATE before the real logic runs. A\n"
1876
+ "# fork boots at scenario 0 (a fresh game), so the donor's gating sees no progress; uncomment + set\n"
1877
+ "# the beat so it fires the right cast / cutscene / dialogue. (In a multi-field journey the seed\n"
1878
+ "# usually lives in [journey.seed] instead -- it bakes into the ENTRY member's .eb right here.) ---\n"
1879
+ "# [startup]\n"
1880
+ "# scenario = 0 # the story beat = the ScenarioCounter (an int, or an \"area\" name)\n"
1881
+ "# # flags = [ { flag = \"<name-or-index>\", value = 1 } ] # story bits the real .eb gates on (name or index)\n"
1882
+ "# # words = [ { byte = 236, value = 0 } ] # 16-bit words, e.g. the ATE-availability mask\n"
1883
+ "[verbatim_eb]\n"
1884
+ f'bin = "{name}.verbatim_eb.bin"\n'
1885
+ + (f"donor = {_donor_fid} # the real field this is forked from (for engine-hotfix warnings)\n"
1886
+ if _donor_fid is not None else "")
1887
+ + f"{text_line}"
1888
+ f"{rt_intro}{rt_text}"
1889
+ + (("\n\n" + bgm_blocks) if bgm_blocks else ""))
1890
+ meta["imported_content"] = {"verbatim_eb": True, "field_exits": dests, "text": bool(mes_by_lang),
1891
+ "gateways_retargeted": n_retargeted, "battle_bgm": len(bgm_pairs)}
1892
+ else:
1893
+ content_blocks, control_dir, content_summary = _content_for_import(
1894
+ field, game, out_dir=out, name=name, id_remap=id_remap, live_seams=live_seams,
1895
+ graft_player_funcs=graft_player_funcs, carry_text=carry_text, graft_savepoint=graft_savepoint)
1896
+ meta["imported_content"] = content_summary
1897
+ control_line = (f"control_direction = {control_dir} # imported WASD-vs-camera tuning\n"
1898
+ if control_dir is not None else "")
1899
+ content_tail = (
1900
+ "# --- add NPCs/dialogue (uncomment + edit); keep positions within the walkmesh bounds above ---\n"
1901
+ f'# [[npc]]\n# name = "Vivi"\n# preset = "vivi"\n# pos = [{x}, {z}]\n# dialogue = "Hello, traveler."\n\n'
1902
+ f"{_content_section(content_blocks, x, z)}")
1903
+
1904
+ toml = (
1905
+ f"# NATIVE fork of {meta['field']} (area {meta['area']}) by ff9mapkit -- ships its OWN atlas.png +\n"
1906
+ f"# scene.bgs (the real per-tile-depth scene) + custom walkmesh, NO .bgx. The engine renders it via\n"
1907
+ f"# the SEAMLESS native path (point-sampled atlas, per-tile occlusion) -- exactly how Moguri ships.\n"
1908
+ f"# Repaint by editing atlas.png (or the Memoria PSD pipeline). Add content below, then build it.\n"
1909
+ f"# Camera: pitch {cm['pitch_deg']} deg, FOV {cm['fov_deg']} deg. Walkmesh bounds: x {wb['x']} z {wb['z']}.\n"
1910
+ f"{remap_note}\n"
1911
+ f"[field]\n"
1912
+ f"id = {field_id}\n"
1913
+ f'name = "{name}"\n'
1914
+ f"area = {safe_area}\n"
1915
+ f"text_block = {text_block}\n"
1916
+ f"{_walkmesh_hotfix_line(field)}"
1917
+ f"{_area_title_hide_lines(meta, verbatim=verbatim)}"
1918
+ f'bgs = "scene.bgs.bytes" # NATIVE scene (per-tile depth) -> seamless render, NO .bgx / no tile seams\n'
1919
+ f'atlas = "atlas.png"\n'
1920
+ f'atlas_tile_size = {meta["atlas_tile_size"]} # TileSize the atlas is packed at -- `ff9mapkit repaint-native` round-trips it\n'
1921
+ + ('mapconfig = "mapconfig.bytes" # the real field LIGHTING (per-floor lights + shadows) for 3D models\n'
1922
+ if mc_bytes else "")
1923
+ + "\n"
1924
+ f"[camera]\n"
1925
+ f'borrow = "camera.bgx" # content logic uses this; the RENDERED camera lives inside scene.bgs\n'
1926
+ f"{control_line}"
1927
+ f"{scroll}\n"
1928
+ f"[walkmesh]\n"
1929
+ f'bgi = "walkmesh.bgi" # the real field\'s walkmesh -- connectivity preserved (faithful copy)\n\n'
1930
+ f"[player]\n"
1931
+ f"spawn = [{x}, {z}]\n\n"
1932
+ f"{content_tail}"
1933
+ )
1934
+ p = Path(out_dir) / f"{name}.field.toml"
1935
+ p.write_text(toml, encoding="utf-8", newline="\n")
1936
+ meta["field_toml"] = str(p)
1937
+ return meta, p
1938
+
1939
+
1940
+ # --------------------------------------------------------------------------- native-art repaint round-trip
1941
+ # A native fork ships its OWN atlas.png (the seamless render path), but the atlas is TILE-PACKED -- a grid
1942
+ # of (TileSize+4) cells in global sprite-index order, NOT a spatial picture -- so it's awkward to repaint by
1943
+ # hand. These two functions are a SPATIAL<->ATLAS round-trip: `export_native_repaint` unpacks the atlas into
1944
+ # the engine's own per-overlay `Overlay{i}.png` layers (each a spatial picture of one depth layer, exactly
1945
+ # what Moguri's PSD pipeline edits); the artist repaints those; `repack_native_atlas` blits them back into
1946
+ # atlas.png (the inverse of bgart.assemble_overlay -> bgart.repack_overlay), writing only CHANGED cells (each
1947
+ # re-bled into its 2px margin) so an unmodified pack is byte-exact and a re-pack is idempotent. Per-OVERLAY
1948
+ # granularity sidesteps the two hazards a single flat composite would hit: spatially-overlapping tiles from
1949
+ # different depth layers (a flat picture can hold only the front one) and multi-camera fields (one atlas,
1950
+ # many camera canvases). Fully self-contained: operates on the project's own scene.bgs.bytes + atlas.png, no
1951
+ # game/UnityPy/ini needed -> provenance-clean and machine-independent. -> project-ff9-native-repaint-workflow.
1952
+ _REPAINT_MANIFEST = "repaint.manifest.json"
1953
+
1954
+
1955
+ def _native_project_paths(project_dir):
1956
+ """``(proj_dir, field_cfg, bgs_path, atlas_path)`` for a native scene. ``project_dir`` may be a project
1957
+ DIRECTORY or a specific ``*.field.toml``. When a directory holds several field.tomls (e.g. a native AND an
1958
+ editable fork of the same field side by side), the one that DECLARES a native scene (``[field] bgs`` +
1959
+ ``atlas``) is chosen -- so the repaint never silently grabs an editable/.bgx fork that merely shares the
1960
+ folder's ``atlas.png``. ``field_cfg`` carries ``atlas_tile_size`` when the fork recorded it. Raises
1961
+ ``FileNotFoundError`` (with a pointer to native) if the project is editable/BG-borrow, not native."""
1962
+ import tomllib # noqa: PLC0415
1963
+ p = Path(project_dir)
1964
+ if p.is_file() and p.name.endswith(".field.toml"): # an explicit field.toml -> use exactly it
1965
+ proj = p.parent
1966
+ cfgs = [(p, tomllib.loads(p.read_text(encoding="utf-8")).get("field", {}))]
1967
+ else:
1968
+ proj = p
1969
+ cfgs = [(t, tomllib.loads(t.read_text(encoding="utf-8")).get("field", {}))
1970
+ for t in sorted(proj.glob("*.field.toml"))]
1971
+ native = [(t, f) for t, f in cfgs if f.get("bgs") and f.get("atlas")]
1972
+ if native:
1973
+ _ftoml, fcfg = native[0]
1974
+ elif cfgs: # field.toml(s) present but none native
1975
+ raise FileNotFoundError(
1976
+ f"{proj}: no NATIVE scene here -- {cfgs[0][0].name} ships no [field] bgs/atlas, so it's an "
1977
+ f"editable (.bgx) or BG-borrow fork. repaint-native is for NATIVE forks; editable (.bgx) scenes "
1978
+ f"are seam-prone by design (the seamless+repaintable path is native). Re-fork with native art "
1979
+ f"(`ff9mapkit import <field>`, the default) into a FRESH folder and repaint that -- then deploy "
1980
+ f"its native field.toml.")
1981
+ else:
1982
+ fcfg = {}
1983
+ bgs_path = proj / fcfg.get("bgs", "scene.bgs.bytes")
1984
+ atlas_path = proj / fcfg.get("atlas", "atlas.png")
1985
+ if not bgs_path.is_file() or not atlas_path.is_file():
1986
+ raise FileNotFoundError(
1987
+ f"{proj}: not a native scene project (need a {bgs_path.name} + {atlas_path.name}). "
1988
+ f"Fork one with `ff9mapkit import <field>` (native is the default).")
1989
+ return proj, fcfg, bgs_path, atlas_path
1990
+
1991
+
1992
+ def _derive_native_tile_size(bgs_bytes: bytes, atlas_w: int, atlas_h: int) -> int:
1993
+ """The TileSize the atlas was packed at, derived from the atlas dims + the field's sprite layout:
1994
+ the LARGEST candidate whose every tile cell fits inside the atlas. The extractor sizes the atlas
1995
+ tightly to the active TileSize (vanilla 32 / Moguri 64), so largest-that-fits is unambiguous (a
1996
+ smaller size always fits too but leaves the atlas mostly empty; a larger one overflows)."""
1997
+ for ts in (128, 64, 32, 16):
1998
+ if atlas_w // (ts + 4) == 0: # a cell wider than the atlas can't pack -> skip
1999
+ continue
2000
+ _, ov = bgs.parse_overlays(bgs_bytes)
2001
+ bgs.resolve_sprites(bgs_bytes, ov, atlas_w, ts)
2002
+ sprites = [s for o in ov for s in o.sprites]
2003
+ if sprites and max(s.atlasX for s in sprites) + ts <= atlas_w \
2004
+ and max(s.atlasY for s in sprites) + ts <= atlas_h:
2005
+ return ts
2006
+ return 64
2007
+
2008
+
2009
+ def export_native_repaint(project_dir, out_dir=None) -> dict:
2010
+ """Unpack a native fork's tile-packed ``atlas.png`` into per-overlay spatial layers for repainting.
2011
+
2012
+ Writes ``Overlay{i}.png`` (one tight spatial picture per background depth layer, the same artifact
2013
+ Memoria's ``[Export] Field=1`` dumps and Moguri's PSD pipeline edits) + a ``repaint.manifest.json``
2014
+ (records the TileSize so the repack is unambiguous) under ``<out_dir>`` (default ``<project>/repaint/``).
2015
+ Repaint any layer, then :func:`repack_native_atlas`. Returns a summary dict. Self-contained: operates
2016
+ only on the project's own scene.bgs.bytes + atlas.png (no game/UnityPy needed)."""
2017
+ from PIL import Image # noqa: PLC0415
2018
+ proj, fcfg, bgs_path, atlas_path = _native_project_paths(project_dir)
2019
+ out = Path(out_dir) if out_dir is not None else proj / "repaint"
2020
+ out.mkdir(parents=True, exist_ok=True)
2021
+ bgs_bytes = bgs_path.read_bytes()
2022
+ atlas = Image.open(atlas_path).convert("RGBA")
2023
+ ts = int(fcfg.get("atlas_tile_size") or _derive_native_tile_size(bgs_bytes, atlas.width, atlas.height))
2024
+ _, overlays = bgs.parse_overlays(bgs_bytes)
2025
+ bgs.resolve_sprites(bgs_bytes, overlays, atlas.width, ts)
2026
+ imgs = bgart.assemble_overlays(atlas, overlays, ts)
2027
+ n = 0
2028
+ for i, im in imgs.items():
2029
+ im.save(out / f"Overlay{i}.png")
2030
+ n += 1
2031
+ manifest = {"tile_size": ts, "atlas_width": atlas.width, "atlas_height": atlas.height,
2032
+ "overlays": len(overlays), "atlas": atlas_path.name, "bgs": bgs_path.name}
2033
+ (out / _REPAINT_MANIFEST).write_text(json.dumps(manifest, indent=2), encoding="utf-8")
2034
+ return {"dir": str(out), "overlays": n, "tile_size": ts,
2035
+ "atlas_size": [atlas.width, atlas.height]}
2036
+
2037
+
2038
+ def repack_native_atlas(project_dir, repaint_dir=None, *, backup=True) -> dict:
2039
+ """Repaint round-trip's second half: blit the edited ``Overlay{i}.png`` layers back into the native
2040
+ fork's ``atlas.png``. The inverse of :func:`export_native_repaint`. The current ``atlas.png`` IS the
2041
+ base: each layer cell is written ONLY where it differs from the cell already there (and its edge is
2042
+ re-bled into the 2px margin), so an unmodified layer is a byte-exact no-op and re-running is
2043
+ idempotent -- no separate pristine copy to corrupt. Only overlays whose PNG is present are touched;
2044
+ the rest keep their cells. A hand-edited PNG that isn't the exported size is rescaled to fit (a
2045
+ uniform multiple is downscaled with a note; a mismatched aspect raises). Backs up the current atlas
2046
+ to ``backups/<atlas>.<timestamp>`` first. Returns a summary dict."""
2047
+ from datetime import datetime # noqa: PLC0415
2048
+ from PIL import Image # noqa: PLC0415
2049
+ proj, fcfg, bgs_path, atlas_path = _native_project_paths(project_dir)
2050
+ rp = Path(repaint_dir) if repaint_dir is not None else proj / "repaint"
2051
+ if not rp.is_dir():
2052
+ raise FileNotFoundError(f"{rp}: no repaint layers -- run `export_native_repaint` first.")
2053
+ man_path = rp / _REPAINT_MANIFEST
2054
+ manifest = json.loads(man_path.read_text(encoding="utf-8")) if man_path.is_file() else {}
2055
+ bgs_bytes = bgs_path.read_bytes()
2056
+ original_bytes = atlas_path.read_bytes() # for the pre-overwrite backup
2057
+ atlas = Image.open(atlas_path).convert("RGBA") # the current atlas IS the base (no separate pristine)
2058
+ ts = int(manifest.get("tile_size") or fcfg.get("atlas_tile_size")
2059
+ or _derive_native_tile_size(bgs_bytes, atlas.width, atlas.height))
2060
+ _, overlays = bgs.parse_overlays(bgs_bytes)
2061
+ bgs.resolve_sprites(bgs_bytes, overlays, atlas.width, ts)
2062
+ written = touched = rescaled = 0
2063
+ notes = []
2064
+ for i, ov in enumerate(overlays):
2065
+ png = rp / f"Overlay{i}.png"
2066
+ if not png.is_file():
2067
+ continue
2068
+ im = Image.open(png).convert("RGBA")
2069
+ exp = bgart.overlay_size(ov.sprites, ts)
2070
+ if tuple(im.size) != exp:
2071
+ kx, ky = im.size[0] / exp[0], im.size[1] / exp[1]
2072
+ if kx == ky and kx > 0: # uniform scale (e.g. painted 2x) -> downscale to fit
2073
+ im = im.resize(exp, Image.LANCZOS)
2074
+ rescaled += 1
2075
+ notes.append(f"Overlay{i}: rescaled {png.name} {kx:g}x to {exp[0]}x{exp[1]}")
2076
+ else:
2077
+ raise ValueError(
2078
+ f"Overlay{i}.png is {tuple(im.size)} but its tiles need {exp} (non-uniform scale); "
2079
+ f"re-export the layers or match the exported size.")
2080
+ written += bgart.repack_overlay(atlas, im, ov.sprites, ts)
2081
+ touched += 1
2082
+ if backup and written: # back up only when we actually change the atlas
2083
+ bdir = proj / "backups"
2084
+ bdir.mkdir(exist_ok=True)
2085
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
2086
+ (bdir / f"{atlas_path.name}.{stamp}").write_bytes(original_bytes)
2087
+ atlas.save(atlas_path)
2088
+ return {"atlas": str(atlas_path), "overlays_repacked": touched, "cells_written": written,
2089
+ "rescaled": rescaled, "tile_size": ts, "notes": notes}
2090
+
2091
+
2092
+ def write_field_project(field: str, out_dir, *, name: str | None = None, field_id: int = 4003,
2093
+ text_block: int = 1073, game=None, bundle=None, want_atlas=False,
2094
+ id_remap=None, live_seams=False, graft_player_funcs=False, carry_text=False,
2095
+ graft_savepoint=False):
2096
+ """Extract a real field and emit a ready-to-edit BG-borrow field.toml + camera.bgx in out_dir.
2097
+
2098
+ `name` is the custom script/field id (must be unique vs real fieldids; defaults to
2099
+ '<MAPID-first-token>_FORK', e.g. 'GRGR_FORK'). Returns (metadata, field_toml_path).
2100
+ `ff9mapkit build <path>` compiles it; the author fills in NPCs/gateways/dialogue first."""
2101
+ # BG-borrow reuses the REAL field's BG via FBG_N<area>_<mapid>; the engine builds that name with
2102
+ # no zero-padding and reads exactly 2 chars for the area, so single-digit areas (0-9) black-screen.
2103
+ # Catch it here with a clear pointer to --editable rather than emitting a field.toml that won't build.
2104
+ folder, _b = resolve_field(field, game)
2105
+ src_area, _m = parse_fbg_folder(folder)
2106
+ if src_area < MIN_CUSTOM_AREA:
2107
+ raise RuntimeError(
2108
+ f"{folder} is in area {src_area}: BG-borrow can't render single-digit areas (the engine "
2109
+ f"builds 'FBG_N<area>' and reads exactly 2 chars, so areas 0-9 black-screen). "
2110
+ f"Fork it as a custom scene instead: ff9mapkit import {field} --editable "
2111
+ f"(it ships its own art, so it runs at a safe area).")
2112
+ meta = extract_field(field, out_dir, game=game, bundle=bundle, want_atlas=want_atlas)
2113
+ name = name or (meta["mapid"].split("_")[0] + "_FORK")
2114
+ # real-art backdrop for the Blender import (only if the field was exported in-game once via
2115
+ # Memoria.ini [Export] Field=1). In-game still uses BG-borrow; this is the Blender modelling view.
2116
+ meta["background"] = None
2117
+ try:
2118
+ if compose_background(field, Path(out_dir) / "background.png", game=game, bundle=bundle):
2119
+ meta["background"] = "background.png"
2120
+ except Exception:
2121
+ pass
2122
+ # extract the real field's LIVE content (gateways / encounter / music / movement) from its .eb
2123
+ content_blocks, control_dir, content_summary = _content_for_import(
2124
+ field, game, out_dir=Path(out_dir), name=name, id_remap=id_remap, live_seams=live_seams,
2125
+ graft_player_funcs=graft_player_funcs, carry_text=carry_text, graft_savepoint=graft_savepoint)
2126
+ meta["imported_content"] = content_summary
2127
+ cm = meta["camera"]
2128
+ wb = meta["walkmesh_bounds"]
2129
+ x, z = meta["player_start"]
2130
+ scroll = "[camera.scroll]\nenabled = true\n" if meta["scrolling"] else ""
2131
+ control_line = f"control_direction = {control_dir} # imported WASD-vs-camera tuning\n" if control_dir is not None else ""
2132
+ toml = (
2133
+ f"# Imported from {meta['field']} (area {meta['area']}) by ff9mapkit -- BG-borrow.\n"
2134
+ f"# Renders the REAL field's art + walkmesh + camera while running your script.\n"
2135
+ f"# Camera: pitch {cm['pitch_deg']} deg, FOV {cm['fov_deg']} deg, range {cm['range'][0]}x{cm['range'][1]}"
2136
+ f"{' (SCROLLING)' if meta['scrolling'] else ''}.\n"
2137
+ f"# Walkmesh bounds: x {wb['x']} z {wb['z']} -- place NPCs/gateways within these.\n"
2138
+ f"# Edit the content below, then: ff9mapkit build {name}.field.toml\n\n"
2139
+ f"[field]\n"
2140
+ f"id = {field_id}\n"
2141
+ f'name = "{name}"\n'
2142
+ f"area = {meta['area']}\n"
2143
+ f'borrow_bg = "{meta["mapid"]}"\n'
2144
+ f"text_block = {text_block}\n\n"
2145
+ f"[camera]\n"
2146
+ f'borrow = "camera.bgx"\n'
2147
+ f"{control_line}"
2148
+ f"{scroll}\n"
2149
+ f"[walkmesh]\n"
2150
+ f'reference = "walkmesh.bgi" # validation only -- NOT shipped (the engine uses the borrowed\n'
2151
+ f"# field's real walkmesh). The build WARNS if the content below sits off this walkable area.\n\n"
2152
+ f"[player]\n"
2153
+ f"spawn = [{x}, {z}]\n\n"
2154
+ f"# --- add NPCs/dialogue (uncomment + edit); keep positions within the walkmesh bounds above ---\n"
2155
+ f'# [[npc]]\n# name = "Vivi"\n# preset = "vivi"\n# pos = [{x}, {z}]\n# dialogue = "Hello, traveler."\n\n'
2156
+ f"{_content_section(content_blocks, x, z)}"
2157
+ )
2158
+ p = Path(out_dir) / f"{name}.field.toml"
2159
+ p.write_text(toml, encoding="utf-8", newline="\n")
2160
+ meta["field_toml"] = str(p)
2161
+ return meta, p
2162
+
2163
+
2164
+ def write_lightweight_project(field: str, out_dir, *, name: str | None = None, field_id: int = 4003,
2165
+ text_block: int = 1073, game=None, bundle=None):
2166
+ """A LIGHTWEIGHT, Blender model-against project for a real field: camera.bgx + walkmesh (.bgi + a
2167
+ reshapeable .obj, + links for multi-floor) + a composited ``background.png`` + a compact field.toml --
2168
+ and NO per-depth layer split. This is the per-field unit of the whole-game ``import-all`` archive:
2169
+ small + fast, enough to browse and to model markers/geometry against in Blender's *Import Field*.
2170
+
2171
+ UNIVERSAL across areas (unlike BG-borrow, which black-screens area<10): an area>=10 field gets a
2172
+ buildable BG-borrow toml; an area<10 field gets a MODEL-AGAINST stub toml (you promote it with
2173
+ ``import <field> --editable``/``--native`` to actually build/ship). To repaint per-depth layers or
2174
+ reshape into a custom scene, re-run ``ff9mapkit import <field> --editable`` into the SAME folder."""
2175
+ out = Path(out_dir)
2176
+ meta = extract_field(field, out, game=game, bundle=bundle) # camera.bgx + walkmesh.bgi
2177
+ wm = bgi.BgiWalkmesh.from_bytes((out / "walkmesh.bgi").read_bytes())
2178
+ (out / "walkmesh.obj").write_text(_world_walkmesh_obj_text(wm), encoding="utf-8", newline="\n")
2179
+ if len(wm.floors) > 1:
2180
+ _write_links_toml(wm, out / "walkmesh.links.toml")
2181
+ try: # the model-against backdrop (no footprint)
2182
+ compose_background(field, out / "background.png", game=game, bundle=bundle,
2183
+ draw_footprint=False, camera_index=0)
2184
+ # MULTI-camera field: also write a clean per-camera backdrop (camera 0 = background.png above;
2185
+ # cameras 1.. here) so Blender's Import Field shows each camera its OWN art instead of all
2186
+ # overlays jammed onto camera 0's canvas (the "molded together" look). A single-camera field
2187
+ # writes nothing extra -- camera_index=0 already == the whole scene (every overlay is camNdx 0).
2188
+ ncams = len(cam.parse_bgx_cameras(str(out / "camera.bgx")))
2189
+ for k in range(1, ncams):
2190
+ compose_background(field, out / f"background_cam{k:02d}.png", game=game, bundle=bundle,
2191
+ draw_footprint=False, camera_index=k)
2192
+ except Exception: # noqa: BLE001 - preview only, never fatal
2193
+ pass
2194
+ name = name or (meta["mapid"].split("_")[0] + "_FORK")
2195
+ area = meta["area"]
2196
+ borrowable = area >= MIN_CUSTOM_AREA
2197
+ safe_area = area if borrowable else safe_custom_area(area)
2198
+ wb = meta["walkmesh_bounds"]
2199
+ x, z = meta["player_start"]
2200
+ scroll = "[camera.scroll]\nenabled = true\n" if meta["scrolling"] else ""
2201
+ note = ("" if borrowable else
2202
+ f"# area {area} < 10: BG-borrow can't render it in-game (the engine's FBG_N<area> 2-char lookup), so\n"
2203
+ f"# this is a MODEL-AGAINST stub. To build/ship, re-run `ff9mapkit import {field} --editable` (or --native).\n")
2204
+ borrow_line = f'borrow_bg = "{meta["mapid"]}"\n' if borrowable else ""
2205
+ wm_stanza = ('reference = "walkmesh.bgi" # borrow: the engine uses the real field walkmesh (validation only)'
2206
+ if borrowable else 'bgi = "walkmesh.bgi"')
2207
+ toml = (
2208
+ f"# LIGHTWEIGHT model-against fork of {meta['field']} (area {area}) -- camera + walkmesh + a composited\n"
2209
+ f"# background.png for Blender 'Import Field'. NOT a repaint project: promote with `--editable` to get\n"
2210
+ f"# repaintable per-depth layers / reshape into a custom scene. Walkmesh bounds: x {wb['x']} z {wb['z']}.\n"
2211
+ f"{note}"
2212
+ f"[field]\nid = {field_id}\nname = \"{name}\"\narea = {safe_area}\n{borrow_line}text_block = {text_block}\n\n"
2213
+ f"[camera]\nborrow = \"camera.bgx\"\n{scroll}\n"
2214
+ f"[walkmesh]\n{wm_stanza}\n\n"
2215
+ f"[player]\nspawn = [{x}, {z}]\n\n"
2216
+ f"{_content_section('', x, z)}"
2217
+ )
2218
+ p = out / f"{name}.field.toml"
2219
+ p.write_text(toml, encoding="utf-8", newline="\n")
2220
+ meta["field_toml"] = str(p)
2221
+ return meta, p
2222
+
2223
+
2224
+ def _bulk_import(entries, *, editable=False, game=None, on_field=None) -> dict:
2225
+ """Run the per-field writer over ``entries`` = [(token, dest_dir, label)], never raising on a single bad
2226
+ field. ``editable`` picks the full custom-scene fork vs the lightweight model-against project.
2227
+ ``on_field(k, total, label, dest|None, err|None)`` is an optional progress callback. Returns
2228
+ {fields, failed:[(label, err)], total}."""
2229
+ entries = list(entries)
2230
+ total = len(entries)
2231
+ n_fields = 0
2232
+ failed = []
2233
+ for k, (token, dest, label) in enumerate(entries):
2234
+ try:
2235
+ if editable:
2236
+ write_editable_project(token, dest, game=game)
2237
+ else:
2238
+ write_lightweight_project(token, dest, game=game)
2239
+ except (FileNotFoundError, ValueError, RuntimeError) as e:
2240
+ failed.append((str(label), str(e)))
2241
+ if on_field:
2242
+ on_field(k + 1, total, str(label), None, str(e))
2243
+ continue
2244
+ n_fields += 1
2245
+ if on_field:
2246
+ on_field(k + 1, total, str(label), str(dest), None)
2247
+ return {"fields": n_fields, "failed": failed, "total": total}
2248
+
2249
+
2250
+ def import_all(out_root, *, game=None, pattern=None, editable=False, on_field=None) -> dict:
2251
+ """Bulk-import EVERY real field (optionally filtered by ``pattern``) into a foldered Blender-ready
2252
+ archive at ``<out_root>/<ZONE>/<FBG>/``, OFFLINE -- a quick whole-game source-of-truth to browse and
2253
+ copy field folders out of. Lightweight by default (camera+walkmesh+background+toml); ``editable`` =
2254
+ full repaintable custom scenes. The output is SE-derived art -- point ``out_root`` at a gitignored path."""
2255
+ root = Path(out_root)
2256
+ entries = []
2257
+ for folder, _area, mapid in list_fields(pattern, game=game):
2258
+ zone = mapid.split("_")[0] # FBG zone token (ICCV, ALXT, ...)
2259
+ entries.append((folder, root / zone / folder.upper(), folder))
2260
+ return _bulk_import(entries, editable=editable, game=game, on_field=on_field)
2261
+
2262
+
2263
+ def import_campaign_fields(campaign_toml, out_root, *, game=None, editable=False, on_field=None) -> dict:
2264
+ """Bulk-import the real fields a campaign forks (its members' ``source`` donors) into
2265
+ ``<out_root>/<CAMPAIGN>/<MEMBER>/``, OFFLINE -- a campaign-foldered slice of the archive. See
2266
+ :func:`import_all`."""
2267
+ from . import campaign as _camp
2268
+ plan = _camp.load_campaign(campaign_toml)
2269
+ root = Path(out_root) / (plan.name or "CAMPAIGN")
2270
+ entries = []
2271
+ seen = set()
2272
+ for m in plan.members:
2273
+ if not m.real_id or m.real_id in seen:
2274
+ continue
2275
+ seen.add(m.real_id)
2276
+ entries.append((str(m.real_id), root / m.name, m.name))
2277
+ if not entries:
2278
+ raise ValueError(f"{campaign_toml}: no member fields with a real `source` id to import")
2279
+ return _bulk_import(entries, editable=editable, game=game, on_field=on_field)