ff9mapkit 1.0.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. ff9mapkit/__init__.py +18 -0
  2. ff9mapkit/__main__.py +36 -0
  3. ff9mapkit/_animdb.py +2994 -0
  4. ff9mapkit/_animdb_all.py +14125 -0
  5. ff9mapkit/_fieldtable.py +1516 -0
  6. ff9mapkit/_fieldtext.py +845 -0
  7. ff9mapkit/_held_poses.py +44 -0
  8. ff9mapkit/_itemdb.py +65 -0
  9. ff9mapkit/_modeldb.py +725 -0
  10. ff9mapkit/_narrowmap_data.py +10 -0
  11. ff9mapkit/_npcparams.py +634 -0
  12. ff9mapkit/_regen_animdb.py +72 -0
  13. ff9mapkit/_regen_animdb_all.py +66 -0
  14. ff9mapkit/_regen_fieldtable.py +95 -0
  15. ff9mapkit/_regen_fieldtext.py +66 -0
  16. ff9mapkit/_regen_modeldb.py +67 -0
  17. ff9mapkit/_regen_npcparams.py +123 -0
  18. ff9mapkit/_regen_scenedb.py +57 -0
  19. ff9mapkit/_scenedb.py +869 -0
  20. ff9mapkit/abilities.py +225 -0
  21. ff9mapkit/animations.py +120 -0
  22. ff9mapkit/archetypes.py +218 -0
  23. ff9mapkit/areatitle.py +76 -0
  24. ff9mapkit/battle/__init__.py +21 -0
  25. ff9mapkit/battle/abilityfeatures.py +294 -0
  26. ff9mapkit/battle/actiondelta.py +441 -0
  27. ff9mapkit/battle/aiauthor.py +305 -0
  28. ff9mapkit/battle/ailint.py +140 -0
  29. ff9mapkit/battle/aipatch.py +175 -0
  30. ff9mapkit/battle/battleai.py +148 -0
  31. ff9mapkit/battle/battlecsv.py +390 -0
  32. ff9mapkit/battle/battlepatch.py +395 -0
  33. ff9mapkit/battle/build.py +558 -0
  34. ff9mapkit/battle/camera_codec.py +332 -0
  35. ff9mapkit/battle/camera_data.py +128 -0
  36. ff9mapkit/battle/characterdelta.py +789 -0
  37. ff9mapkit/battle/event_data.py +72 -0
  38. ff9mapkit/battle/extract.py +540 -0
  39. ff9mapkit/battle/fbx.py +223 -0
  40. ff9mapkit/battle/reskin.py +149 -0
  41. ff9mapkit/battle/scene_codec.py +314 -0
  42. ff9mapkit/battle/scene_data.py +369 -0
  43. ff9mapkit/battle/scenelint.py +125 -0
  44. ff9mapkit/battle/seqasm.py +131 -0
  45. ff9mapkit/battle/seqauthor.py +220 -0
  46. ff9mapkit/battle/seqcodec.py +300 -0
  47. ff9mapkit/battle/seqdis.py +106 -0
  48. ff9mapkit/battle/seqpatch.py +137 -0
  49. ff9mapkit/battle_bgm.py +133 -0
  50. ff9mapkit/binutils.py +60 -0
  51. ff9mapkit/build.py +5445 -0
  52. ff9mapkit/campaign.py +1276 -0
  53. ff9mapkit/catalog.py +316 -0
  54. ff9mapkit/chain.py +358 -0
  55. ff9mapkit/cli.py +3114 -0
  56. ff9mapkit/config.py +360 -0
  57. ff9mapkit/content/__init__.py +13 -0
  58. ff9mapkit/content/areatitle.py +36 -0
  59. ff9mapkit/content/ate.py +118 -0
  60. ff9mapkit/content/camera.py +123 -0
  61. ff9mapkit/content/chest.py +186 -0
  62. ff9mapkit/content/choice.py +163 -0
  63. ff9mapkit/content/conductor.py +217 -0
  64. ff9mapkit/content/cutscene.py +290 -0
  65. ff9mapkit/content/encounter.py +41 -0
  66. ff9mapkit/content/entry_settle.py +50 -0
  67. ff9mapkit/content/equipment.py +93 -0
  68. ff9mapkit/content/event.py +191 -0
  69. ff9mapkit/content/gateway.py +101 -0
  70. ff9mapkit/content/inventory.py +59 -0
  71. ff9mapkit/content/itemdata.py +644 -0
  72. ff9mapkit/content/itemtext.py +168 -0
  73. ff9mapkit/content/jump.py +114 -0
  74. ff9mapkit/content/ladder.py +633 -0
  75. ff9mapkit/content/movement.py +53 -0
  76. ff9mapkit/content/music.py +97 -0
  77. ff9mapkit/content/npc.py +348 -0
  78. ff9mapkit/content/object.py +340 -0
  79. ff9mapkit/content/onentry.py +135 -0
  80. ff9mapkit/content/party.py +111 -0
  81. ff9mapkit/content/pathfind.py +138 -0
  82. ff9mapkit/content/platform.py +314 -0
  83. ff9mapkit/content/player.py +168 -0
  84. ff9mapkit/content/prop.py +75 -0
  85. ff9mapkit/content/region.py +340 -0
  86. ff9mapkit/content/reinit.py +59 -0
  87. ff9mapkit/content/savepoint.py +90 -0
  88. ff9mapkit/content/shop.py +178 -0
  89. ff9mapkit/content/sps_trigger.py +66 -0
  90. ff9mapkit/content/startup.py +71 -0
  91. ff9mapkit/content/synthesis.py +106 -0
  92. ff9mapkit/content/text.py +183 -0
  93. ff9mapkit/content/textcarry.py +290 -0
  94. ff9mapkit/content/verbatim.py +86 -0
  95. ff9mapkit/content/walkmesh_hotfix.py +38 -0
  96. ff9mapkit/data/__init__.py +48 -0
  97. ff9mapkit/data/_regen_provenance.py +142 -0
  98. ff9mapkit/data/provenance/blank.es.patch +1 -0
  99. ff9mapkit/data/provenance/blank.fr.patch +1 -0
  100. ff9mapkit/data/provenance/blank.gr.patch +1 -0
  101. ff9mapkit/data/provenance/blank.it.patch +1 -0
  102. ff9mapkit/data/provenance/blank.jp.patch +1 -0
  103. ff9mapkit/data/provenance/blank.uk.patch +1 -0
  104. ff9mapkit/data/provenance/blank.us.patch +1 -0
  105. ff9mapkit/data/provenance/manifest.json +65 -0
  106. ff9mapkit/data/provenance/region_template.patch +1 -0
  107. ff9mapkit/data/reference_arcs.toml +89 -0
  108. ff9mapkit/data/region_catalog.toml +593 -0
  109. ff9mapkit/deploystack.py +358 -0
  110. ff9mapkit/dialogue.py +803 -0
  111. ff9mapkit/eb/__init__.py +12 -0
  112. ff9mapkit/eb/_exprtable.py +59 -0
  113. ff9mapkit/eb/_membertable.py +38 -0
  114. ff9mapkit/eb/_optables.py +537 -0
  115. ff9mapkit/eb/_regen_optables.py +76 -0
  116. ff9mapkit/eb/cmdasm.py +323 -0
  117. ff9mapkit/eb/disasm.py +332 -0
  118. ff9mapkit/eb/edit.py +439 -0
  119. ff9mapkit/eb/exprasm.py +158 -0
  120. ff9mapkit/eb/model.py +178 -0
  121. ff9mapkit/eb/opcodes.py +463 -0
  122. ff9mapkit/eblint.py +177 -0
  123. ff9mapkit/editor/__init__.py +20 -0
  124. ff9mapkit/editor/app.py +950 -0
  125. ff9mapkit/editor/battle_forms.py +240 -0
  126. ff9mapkit/editor/breadcrumb.py +89 -0
  127. ff9mapkit/editor/dialogs.py +116 -0
  128. ff9mapkit/editor/feedback.py +208 -0
  129. ff9mapkit/editor/forms.py +632 -0
  130. ff9mapkit/editor/graphview.py +350 -0
  131. ff9mapkit/editor/jobs.py +342 -0
  132. ff9mapkit/editor/model.py +243 -0
  133. ff9mapkit/editor/picker.py +120 -0
  134. ff9mapkit/editor/theme.py +212 -0
  135. ff9mapkit/eventscan.py +1441 -0
  136. ff9mapkit/extract.py +2279 -0
  137. ff9mapkit/flags.py +693 -0
  138. ff9mapkit/forkreport.py +1383 -0
  139. ff9mapkit/hub.py +477 -0
  140. ff9mapkit/idgated.py +101 -0
  141. ff9mapkit/infohub.py +580 -0
  142. ff9mapkit/items.py +63 -0
  143. ff9mapkit/itemstats.py +346 -0
  144. ff9mapkit/journey.py +1902 -0
  145. ff9mapkit/keyitems.py +93 -0
  146. ff9mapkit/logic_add.py +632 -0
  147. ff9mapkit/logic_edit.py +728 -0
  148. ff9mapkit/logic_map.py +526 -0
  149. ff9mapkit/pack.py +175 -0
  150. ff9mapkit/playerswap.py +231 -0
  151. ff9mapkit/prop_archetypes.py +228 -0
  152. ff9mapkit/provision.py +282 -0
  153. ff9mapkit/refarc.py +825 -0
  154. ff9mapkit/save.py +337 -0
  155. ff9mapkit/save_items.py +1673 -0
  156. ff9mapkit/scene/__init__.py +11 -0
  157. ff9mapkit/scene/arena.py +63 -0
  158. ff9mapkit/scene/bgart.py +140 -0
  159. ff9mapkit/scene/bgi.py +732 -0
  160. ff9mapkit/scene/bgs.py +174 -0
  161. ff9mapkit/scene/bgx.py +185 -0
  162. ff9mapkit/scene/cam.py +345 -0
  163. ff9mapkit/scene/guide.py +311 -0
  164. ff9mapkit/scene/paint.py +506 -0
  165. ff9mapkit/scene/placeholder.py +107 -0
  166. ff9mapkit/sjbinary.py +285 -0
  167. ff9mapkit/sps/__init__.py +17 -0
  168. ff9mapkit/sps/author.py +294 -0
  169. ff9mapkit/sps/catalog.py +88 -0
  170. ff9mapkit/sps/codec.py +264 -0
  171. ff9mapkit/sps/edit.py +184 -0
  172. ff9mapkit/sps/lint.py +58 -0
  173. ff9mapkit/sps/render.py +116 -0
  174. ff9mapkit/sps/templates.py +47 -0
  175. ff9mapkit/sps/texture.py +131 -0
  176. ff9mapkit/walkmesh_hotfixes.py +163 -0
  177. ff9mapkit/workspace/__init__.py +18 -0
  178. ff9mapkit/workspace/battledoc.py +985 -0
  179. ff9mapkit/workspace/builddoc.py +607 -0
  180. ff9mapkit/workspace/forms_qt.py +586 -0
  181. ff9mapkit/workspace/importdoc.py +665 -0
  182. ff9mapkit/workspace/mapview.py +131 -0
  183. ff9mapkit/workspace/palette.py +85 -0
  184. ff9mapkit/workspace/savedoc.py +664 -0
  185. ff9mapkit/workspace/shell.py +6907 -0
  186. ff9mapkit/workspace/style.py +105 -0
  187. ff9mapkit/workspace/tuningdialog.py +223 -0
  188. ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
  189. ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
  190. ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
  191. ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
  192. ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
  193. ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,72 @@
1
+ """Re-author a battle eb's Main_Init so its enemy-AI binding matches an edited spawn composition.
2
+
3
+ A battle ``EVT_BATTLE`` eb's entry 0 (tag 0 = Main_Init) issues one ``InitObject(1+type, 0x80+slot)``
4
+ per enemy the donor spawns; the per-type AI lives in entries ``1..TypCount`` (entry ``1+T`` = type T's
5
+ AI), and the engine binds these objects POSITIONALLY to enemy slots. So if a minted battle's raw16
6
+ spawns MORE enemies than Main_Init issues InitObjects for, the extra slots get null AI objects and the
7
+ (N+1)th enemy's death misroutes into the player -> the player model twitches (root cause in
8
+ ``project_ff9_battle_backgrounds``).
9
+
10
+ ``rewrite_main_init`` issues exactly one InitObject per spawned slot, REUSING the donor's existing
11
+ per-type AI entries -- so the AI binding always matches the pattern and a minted battle can exceed the
12
+ donor's natural enemy count (up to the engine's hard cap of 4) using any types already in the scene.
13
+
14
+ Verified on EF_R007 / BU_E072 / AC_E031 (2026-06-09): uid = 0x80 + slot; AI entry = 1 + enemy type.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from ..eb import opcodes
19
+ from ..eb.edit import replace_function_body
20
+ from ..eb.model import EbScript
21
+
22
+ ENEMY_UID_BASE = 0x80 # enemy object uid = 0x80 + slot index
23
+ INITOBJECT_OP = 0x09
24
+
25
+
26
+ def _ai_entry(type_no: int) -> int:
27
+ """Entry index of enemy type ``type_no``'s AI (entry 0 = Main_Init; per-type AI = entries 1..N)."""
28
+ return 1 + type_no
29
+
30
+
31
+ def main_init_initobject_count(eb_bytes) -> int:
32
+ """Number of InitObject calls in Main_Init (entry 0 tag 0). For an UNCONDITIONAL Main_Init this is
33
+ the donor's simultaneous-enemy count; for a conditional one (type-select) it's an upper bound."""
34
+ eb = EbScript.from_bytes(eb_bytes)
35
+ f = eb.entry(0).func_by_tag(0) if eb.entries else None
36
+ return sum(1 for ins in eb.instrs(f) if ins.op == INITOBJECT_OP) if f else 0
37
+
38
+
39
+ def rewrite_main_init(eb_bytes, slot_types, ai_entries=None) -> bytes:
40
+ """Rewrite Main_Init to one ``InitObject(<ai entry>, 0x80+slot)`` per enemy in ``slot_types`` (the spawned
41
+ slots, in order), then RETURN. The AI entry defaults to ``1+type`` (the standard donor layout) but can be
42
+ OVERRIDDEN per slot via ``ai_entries`` (a list parallel to ``slot_types``; a None element keeps the default).
43
+
44
+ The override is what makes an OFFSET-entry donor forkable: EF_R007 binds its Goblin (type 0) to entry **2**
45
+ via a ``SWITCH(B_SYSVAR[31])`` (entry 1 is a different type's AI), so the generic ``1+type`` rebind would run
46
+ the WRONG AI on the spawned model. ``[[scene.enemy]] ai_entry = 2`` pins the right one (read it from
47
+ ``battle-ai``). Raises ValueError if Main_Init is absent or a chosen AI entry is missing/empty."""
48
+ eb = EbScript.from_bytes(eb_bytes)
49
+ n = len(eb.entries)
50
+ if n == 0 or eb.entry(0).func_by_tag(0) is None:
51
+ raise ValueError("battle eb has no Main_Init (entry 0, tag 0) to re-author")
52
+ if ai_entries is None:
53
+ ai_entries = [None] * len(slot_types)
54
+ resolved = []
55
+ for s, t in enumerate(slot_types):
56
+ override = ai_entries[s] if s < len(ai_entries) else None
57
+ ai = int(override) if override is not None else _ai_entry(t)
58
+ if override is not None and ai < 1: # entry 0 IS Main_Init (always non-empty -> dodges the
59
+ raise ValueError(f"slot {s}: ai_entry = {ai} is invalid; entry 0 is Main_Init -- per-type enemy AI "
60
+ f"starts at entry 1 (use `battle-ai <scene>` to find the right one)") # empty check)
61
+ e = eb.entries[ai] if 0 <= ai < n else None
62
+ if e is None or e.empty:
63
+ if override is not None:
64
+ raise ValueError(f"slot {s}: ai_entry = {ai} is not a valid AI entry (the eb has {n} entries; "
65
+ f"entry {ai} is out of range or empty). Use `battle-ai <scene>` to find the entry.")
66
+ raise ValueError(f"battle eb has no AI entry for enemy type {t} (expected entry {ai}); this donor's "
67
+ f"eb layout is non-standard -- pin it with [[scene.enemy]] ai_entry = <entry>, or use "
68
+ f"a donor whose entries 1..TypCount are per-type AI.")
69
+ resolved.append(ai)
70
+ body = b"".join(opcodes.init_object(resolved[s], ENEMY_UID_BASE + s) for s in range(len(slot_types)))
71
+ body += opcodes.RETURN
72
+ return replace_function_body(eb_bytes, 0, 0, body)
@@ -0,0 +1,540 @@
1
+ """Fork a REAL FF9 battle background (BBG) out of the user's install -> editable working dir.
2
+
3
+ Offline, read-only on the install. UnityPy is a LAZY import (extract-only) reused from the field
4
+ extractor. Provenance: everything extracted is written to the caller's out_dir (never the package/repo)
5
+ and is gitignored. Ports the proven tools/extract_bbg_geometry.py decode — a manual struct-unpack of the
6
+ packed Unity-5 m_VertexData/m_IndexBuffer (the path that round-tripped in-game; UnityPy's OBJ export
7
+ flips X, so it is NOT used) — plus the Texture2D-by-m_Name PNG dump.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import struct
12
+ from pathlib import Path
13
+
14
+ from .. import config
15
+ from . import fbx as _fbx
16
+
17
+
18
+ def _unitypy():
19
+ from ..extract import _unitypy as _u # reuse the field extractor's lazy import + error message
20
+ return _u()
21
+
22
+
23
+ def _p0data2(game=None) -> Path:
24
+ return config.find_game_path(game) / "StreamingAssets" / "p0data2.bin"
25
+
26
+
27
+ def _comp_pptr(comp):
28
+ """A GameObject m_Component entry -> its component PPtr (across UnityPy shapes)."""
29
+ return comp.component if hasattr(comp, "component") else comp[1]
30
+
31
+
32
+ def _decode_mesh(mesh_pptr):
33
+ """Decode a Mesh PPtr -> (verts, normals|None, uvs, [per-submesh tris], vertexCount), verbatim."""
34
+ md = mesh_pptr.read()
35
+ vd = md.m_VertexData
36
+ data = bytes(vd.m_DataSize)
37
+ vcount = vd.m_VertexCount
38
+ chans = vd.m_Channels
39
+ stride = 0
40
+ for c in chans:
41
+ if c.dimension:
42
+ stride = max(stride, c.offset + c.dimension * 4) # format 0 = float32
43
+ if vcount * stride != len(data):
44
+ raise ValueError(f"vertex stride mismatch (v{vcount} * s{stride} != {len(data)})")
45
+ pos_c, nrm_c, uv_c = chans[0], chans[1], chans[3]
46
+
47
+ def rd(off, dim, vi):
48
+ return list(struct.unpack_from("<%df" % dim, data, vi * stride + off))
49
+
50
+ verts = [rd(pos_c.offset, pos_c.dimension, i) for i in range(vcount)]
51
+ normals = [rd(nrm_c.offset, nrm_c.dimension, i) for i in range(vcount)] if nrm_c.dimension else None
52
+ uvs = ([rd(uv_c.offset, uv_c.dimension, i) for i in range(vcount)]
53
+ if uv_c.dimension else [[0.0, 0.0]] * vcount)
54
+
55
+ ib = bytes(md.m_IndexBuffer)
56
+ use32 = getattr(md, "m_IndexFormat", None) == 1 or getattr(md, "m_Use16BitIndices", 1) in (0, False)
57
+ if use32:
58
+ idx = struct.unpack("<%dI" % (len(ib) // 4), ib)
59
+ ent = 4
60
+ else:
61
+ idx = struct.unpack("<%dH" % (len(ib) // 2), ib)
62
+ ent = 2
63
+ submeshes = []
64
+ for s in md.m_SubMeshes:
65
+ first = s.firstByte // ent
66
+ flat = idx[first:first + s.indexCount]
67
+ submeshes.append([[flat[i], flat[i + 1], flat[i + 2]] for i in range(0, len(flat) - 2, 3)])
68
+ return verts, normals, uvs, submeshes, vcount
69
+
70
+
71
+ def _maintex_name(mat_pptr):
72
+ """Material PPtr -> its _MainTex Texture2D m_Name (the on-disc PNG stem), or None."""
73
+ if not getattr(mat_pptr, "path_id", 0):
74
+ return None
75
+ md = mat_pptr.read()
76
+ try:
77
+ for kv in md.m_SavedProperties.m_TexEnvs:
78
+ key, val = kv[0], kv[1]
79
+ if getattr(key, "name", str(key)) == "_MainTex":
80
+ tex = val.m_Texture
81
+ return tex.read().m_Name if getattr(tex, "path_id", 0) else None
82
+ except Exception:
83
+ return None
84
+ return None
85
+
86
+
87
+ def read_bbg(bbg, game=None):
88
+ """Return (groups, env, bbg) for the named battle background, e.g. 'BBG_B013'.
89
+
90
+ `groups` is the canonical structure consumed by fbx.emit_fbx. `env` is the loaded bundle (reused to
91
+ save textures without re-reading p0data2.bin).
92
+ """
93
+ UnityPy = _unitypy()
94
+ env = UnityPy.load(str(_p0data2(game)))
95
+ needle = f"battlemap_all/{bbg.lower()}/"
96
+ by_id = {}
97
+ cont = []
98
+ for o in env.objects:
99
+ by_id[o.path_id] = o
100
+ if needle in (getattr(o, "container", None) or "").lower():
101
+ cont.append(o)
102
+ bbg_go = next((o for o in cont if o.type.name == "GameObject" and o.read().m_Name == bbg), None)
103
+ if bbg_go is None:
104
+ raise ValueError(f"battle map {bbg!r} not found (looked for container {needle!r}). "
105
+ f"Try `ff9mapkit battle-list` for the available names.")
106
+ bd = bbg_go.read()
107
+ tpid = None
108
+ for comp in bd.m_Component:
109
+ pp = _comp_pptr(comp)
110
+ if pp.type.name == "Transform":
111
+ tpid = pp.path_id
112
+ break
113
+ children = []
114
+ for o in env.objects:
115
+ if o.type.name != "Transform":
116
+ continue
117
+ try:
118
+ tt = o.read_typetree()
119
+ except Exception:
120
+ continue
121
+ fa = tt.get("m_Father", {})
122
+ if isinstance(fa, dict) and fa.get("m_PathID") == tpid:
123
+ goid = tt.get("m_GameObject", {}).get("m_PathID")
124
+ if goid in by_id:
125
+ children.append(by_id[goid])
126
+
127
+ groups = []
128
+ for go in children:
129
+ d = go.read()
130
+ mesh_pptr, mats = None, []
131
+ for comp in d.m_Component:
132
+ pp = _comp_pptr(comp)
133
+ cd = pp.read()
134
+ tn = pp.type.name
135
+ if tn == "MeshFilter":
136
+ mp = getattr(cd, "m_Mesh", None)
137
+ if mp is not None and getattr(mp, "path_id", 0):
138
+ mesh_pptr = mp
139
+ elif tn in ("MeshRenderer", "SkinnedMeshRenderer"):
140
+ mats = list(getattr(cd, "m_Materials", []) or [])
141
+ if mesh_pptr is None:
142
+ continue
143
+ verts, normals, uvs, submeshes_idx, _vc = _decode_mesh(mesh_pptr)
144
+ sm = []
145
+ for i, tris in enumerate(submeshes_idx):
146
+ tex = _maintex_name(mats[i]) if i < len(mats) else None
147
+ sm.append({"texture": tex, "tris": tris})
148
+ groups.append({"name": d.m_Name, "attr": _fbx.GROUP_ATTR.get(d.m_Name, "PLUS"),
149
+ "verts": verts, "normals": normals, "uvs": uvs, "submeshes": sm})
150
+ order = {"Group_0": 0, "Group_2": 1, "Group_4": 2, "Group_8": 3}
151
+ groups.sort(key=lambda g: order.get(g["name"], 99))
152
+ if not groups:
153
+ raise ValueError(f"{bbg}: no group meshes found under {needle!r}")
154
+ return groups, env, bbg
155
+
156
+
157
+ def _save_textures(env, bbg, out_dir, names) -> list[str]:
158
+ needle = f"battlemap_all/{bbg.lower()}/"
159
+ want = set(names)
160
+ saved = []
161
+ for o in env.objects:
162
+ if o.type.name != "Texture2D":
163
+ continue
164
+ if needle not in (getattr(o, "container", None) or "").lower():
165
+ continue
166
+ d = o.read()
167
+ if d.m_Name in want:
168
+ d.image.save(str(Path(out_dir) / f"{d.m_Name}.png"))
169
+ saved.append(d.m_Name)
170
+ return saved
171
+
172
+
173
+ def list_battle_maps(pattern=None, game=None) -> list[str]:
174
+ """List real BBG names available to fork (e.g. BBG_B013)."""
175
+ import re
176
+ UnityPy = _unitypy()
177
+ env = UnityPy.load(str(_p0data2(game)))
178
+ rx = re.compile(r"battlemap_all/(bbg_b\d+)/\1\.", re.I)
179
+ names = set()
180
+ for o in env.objects:
181
+ m = rx.search((getattr(o, "container", None) or "").lower())
182
+ if m:
183
+ names.add(m.group(1).upper())
184
+ rows = sorted(names, key=lambda n: int(n[5:]) if n[5:].isdigit() else 0)
185
+ if pattern:
186
+ rows = [n for n in rows if pattern.lower() in n.lower()]
187
+ return rows
188
+
189
+
190
+ def _p0data7(game=None) -> Path:
191
+ return config.find_game_path(game) / "StreamingAssets" / "p0data7.bin"
192
+
193
+
194
+ def _grab(env, suffixes: dict) -> dict:
195
+ """{key: bytes} for the TextAsset whose container ENDS WITH suffixes[key] (case-insensitive)."""
196
+ want = {k: v.lower() for k, v in suffixes.items()}
197
+ out: dict = {}
198
+ for o in env.objects:
199
+ if o.type.name != "TextAsset":
200
+ continue
201
+ c = (getattr(o, "container", None) or "").lower()
202
+ for k, suf in want.items():
203
+ if k not in out and c.endswith(suf):
204
+ from ..extract import _raw_bytes
205
+ out[k] = _raw_bytes(o.read())
206
+ return out
207
+
208
+
209
+ def _lang_of(text: str):
210
+ """A BEST-EFFORT content classifier for a battle-text variant -- the European markers are scene-specific
211
+ (drawn from the Goblin scene), so this only RELIABLY recognises Japanese (CJK) + a few stock words; the
212
+ English/duplicate + CJK anchors in :func:`_classify_battle_mes` do the heavy lifting now."""
213
+ if any("぀" <= c <= "ヿ" or "一" <= c <= "鿿" for c in text):
214
+ return "jp"
215
+ if "Coltellata" in text or "Niente" in text:
216
+ return "it"
217
+ if "Gobelin" in text or "Gobelipunch" in text:
218
+ return "fr"
219
+ if "Duende" in text:
220
+ return "es"
221
+ if "Isegrim" in text or "Nichts" in text:
222
+ return "gr"
223
+ if "Goblin" in text and "Fang" in text:
224
+ return "en"
225
+ return None
226
+
227
+
228
+ # A battle <id>.mes in resources.assets carries NO language path (env.container is empty) and the variant ORDER
229
+ # is not consistent across scenes, so the languages are picked STRUCTURALLY, not by position:
230
+ _MES_FIELD_MARKER = b"[TBLE=" # the SAME numeric mesID also names FIELD dialogue blocks (a [TBLE= string table,
231
+ _MES_BATTLE_MAX = 8192 # tens of KB); battle text is small + has no [TBLE= -> drop the field collisions.
232
+
233
+
234
+ def _has_cjk(raw: bytes) -> bool:
235
+ return any("぀" <= c <= "ヿ" or "一" <= c <= "鿿" for c in raw.decode("utf-8", "replace"))
236
+
237
+
238
+ def _classify_battle_mes(variants: list, donor_id: int) -> tuple:
239
+ """Map the ``{donor_id}.mes`` TextAsset variants (a list of raw bytes, all sharing the name) to per-language
240
+ battle text -> ``({lang: bytes}, note)``. Reliable structural anchors (the order/content are NOT):
241
+ * DROP field-text collisions (``[TBLE=`` blocks -- the same mesID also names field dialogue).
242
+ * ``jp`` = the CJK variant; ``en`` (us+uk) = the byte-IDENTICAL duplicate FF9 ships for the two English
243
+ locales -- a scene-independent signal.
244
+ * ``it/fr/es/gr`` = a best-effort :func:`_lang_of` match, else English.
245
+ ``note`` warns when the English variant can't be confidently identified (a name-collided / partially
246
+ localised id), so the fork surfaces it instead of silently shipping the wrong language (the bug this fixes)."""
247
+ battle = [b for b in variants if _MES_FIELD_MARKER not in b and len(b) <= _MES_BATTLE_MAX]
248
+ if not battle:
249
+ battle = list(variants) # don't lose everything if the filter is too strict
250
+ jp = next((b for b in battle if _has_cjk(b)), None)
251
+ counts: dict = {}
252
+ for b in battle:
253
+ counts[b] = counts.get(b, 0) + 1
254
+ eng = next((b for b, c in counts.items() if c >= 2), None) # us == uk duplicate = English
255
+ if eng is None:
256
+ eng = next((b for b in battle if _lang_of(b.decode("utf-8", "replace")) == "en"), None)
257
+ by: dict = {}
258
+ for b in battle:
259
+ if b == jp or b == eng:
260
+ continue
261
+ lang = _lang_of(b.decode("utf-8", "replace"))
262
+ if lang in ("it", "fr", "es", "gr"):
263
+ by.setdefault(lang, b)
264
+ latin = eng or next((b for b in battle if b is not jp), None) or jp
265
+ note = None
266
+ if eng is None:
267
+ note = (f"battle text {donor_id}.mes: couldn't confidently identify the ENGLISH variant among "
268
+ f"{len(battle)} candidate(s) -- a name-collided or partially-localised scene id; the US/UK "
269
+ f"text is a best-effort guess, so VERIFY the enemy names in-game (or pick another donor scene)")
270
+ pick = {"us": eng, "uk": eng, "jp": jp,
271
+ "fr": by.get("fr"), "it": by.get("it"), "es": by.get("es"), "gr": by.get("gr")}
272
+ mes = {l: (pick.get(l) or latin) for l in config.LANGS}
273
+ if any(v is None for v in mes.values()):
274
+ raise ValueError(f"battle text {donor_id}.mes: no usable variants found in resources.assets")
275
+ return mes, note
276
+
277
+
278
+ def _ff9_data_dir(game=None):
279
+ """The FF9_Data dir holding ``mainData`` + ``resources.assets`` (x64 build, with a non-x64 fallback)."""
280
+ d = config.find_game_path(game) / "x64" / "FF9_Data"
281
+ if not (d / "resources.assets").exists():
282
+ d = config.find_game_path(game) / "FF9_Data"
283
+ return d
284
+
285
+
286
+ def _read_battle_text(donor_id, game=None) -> tuple:
287
+ """Read each language's battle ``<id>.mes`` by its REAL resource path -- the faithful, collision-safe read.
288
+
289
+ The engine fetches battle text via ``AssetManager.LoadString("EmbeddedAsset/Text/<LANG>/Battle/<id>.mes")``
290
+ -> ``Resources.Load`` (FF9TextTool.GetBattleText / EmbadedTextResources.GetCurrentPath). That resource path
291
+ -> asset mapping is the ResourceManager's ``m_Container`` (in ``mainData``; its PPtrs resolve into
292
+ ``resources.assets``). Reading by that path gives the EXACT per-language text -- no content heuristics, no
293
+ order assumptions, and the FIELD text at ``.../Field/<id>`` no longer collides with the same numeric mesID.
294
+ Falls back to the structural classifier only if the index can't be read. Returns ``({lang: bytes}, note)``."""
295
+ UnityPy = _unitypy()
296
+ from ..extract import _raw_bytes
297
+ data_dir = _ff9_data_dir(game)
298
+ try: # mainData + resources.assets so the PPtrs resolve
299
+ env = UnityPy.load(str(data_dir / "mainData"), str(data_dir / "resources.assets"))
300
+ rm = next((o.read() for o in env.objects
301
+ if getattr(getattr(o, "type", None), "name", "") == "ResourceManager"), None)
302
+ if rm is None:
303
+ raise LookupError("no ResourceManager in mainData")
304
+ index = {str(path).lower(): ptr for path, ptr in rm.m_Container}
305
+ except Exception as ex: # noqa: BLE001 -- index unreadable -> heuristic scan
306
+ return _classify_from_scan(donor_id, game, note_prefix=f"battle-text index unreadable ({ex}); ")
307
+ mes, missing = {}, []
308
+ for lang in config.LANGS:
309
+ ptr = index.get(f"embeddedasset/text/{lang}/battle/{donor_id}.mes")
310
+ if ptr is None:
311
+ missing.append(lang)
312
+ else:
313
+ mes[lang] = _raw_bytes(ptr.read())
314
+ if not mes: # the id isn't in the battle-text index at all
315
+ return _classify_from_scan(donor_id, game,
316
+ note_prefix=f"no battle-text path for id {donor_id} in the index; ")
317
+ fill = mes.get("us") or mes.get("uk") or next(iter(mes.values())) # rare: a lang absent -> use English
318
+ for lang in missing:
319
+ mes[lang] = fill
320
+ note = (f"battle text {donor_id}.mes: language(s) {', '.join(missing)} absent from the index -- used English"
321
+ if missing else None)
322
+ return {l: mes[l] for l in config.LANGS}, note
323
+
324
+
325
+ def _classify_from_scan(donor_id, game, *, note_prefix=""):
326
+ """FALLBACK only (the ResourceManager index couldn't be read): the name-scan + structural classifier. Less
327
+ faithful (en/jp anchored, European best-effort) but keeps the fork working. See :func:`_classify_battle_mes`."""
328
+ UnityPy = _unitypy()
329
+ from ..extract import _raw_bytes
330
+ env_ra = UnityPy.load(str(_ff9_data_dir(game) / "resources.assets"))
331
+ variants = [_raw_bytes(d) for o in env_ra.objects if o.type.name == "TextAsset"
332
+ for d in [o.read()] if d.m_Name == f"{donor_id}.mes"]
333
+ if not variants:
334
+ raise ValueError(f"battle text {donor_id}.mes not found in resources.assets")
335
+ mes, note = _classify_battle_mes(variants, donor_id)
336
+ return mes, (note_prefix + note if note else (note_prefix or None))
337
+
338
+
339
+ def read_scene_assets(donor, game=None) -> dict:
340
+ """Fork a real battle SCENE's gameplay+sequence+text out of the install (for a tier-c mint).
341
+
342
+ `donor` is a battle-scene NAME (e.g. 'EF_R007', the part after 'EVT_BATTLE_'). Returns
343
+ ``{raw16, raw17, donor_id, eb:{lang:bytes}, mes:{lang:bytes}}`` -- everything a minted scene needs
344
+ EXCEPT the map (geometry) and the INB (authored at build time). The raw17/eb/mes carry the donor's
345
+ working camera + AI + text verbatim, so the minted clone is internally consistent. Provenance: these
346
+ are SE-derived; the caller writes them to a gitignored project dir, never the repo.
347
+ """
348
+ UnityPy = _unitypy()
349
+ donor = donor.upper()
350
+ needle = f"battlescene/evt_battle_{donor.lower()}/"
351
+ env2 = UnityPy.load(str(_p0data2(game)))
352
+ raw16 = raw17 = None
353
+ donor_id = None
354
+ for o in env2.objects:
355
+ if o.type.name != "TextAsset":
356
+ continue
357
+ c = (getattr(o, "container", None) or "").lower()
358
+ if needle not in c:
359
+ continue
360
+ from ..extract import _raw_bytes
361
+ if c.endswith("dbfile0000.raw16.bytes"):
362
+ raw16 = _raw_bytes(o.read())
363
+ elif c.endswith(".raw17.bytes"):
364
+ raw17 = _raw_bytes(o.read())
365
+ donor_id = int(c.rsplit("/", 1)[-1].split(".", 1)[0]) # '<id>.raw17.bytes' -> id
366
+ if raw16 is None or raw17 is None or donor_id is None:
367
+ raise ValueError(f"battle scene {donor!r} not found (looked for {needle!r}). "
368
+ f"Try a name from `ff9mapkit battle-list --scenes`.")
369
+
370
+ env7 = UnityPy.load(str(_p0data7(game)))
371
+ eb = _grab(env7, {l: f"eventbinary/battle/{l}/evt_battle_{donor.lower()}.eb.bytes"
372
+ for l in config.LANGS})
373
+ missing = [l for l in config.LANGS if l not in eb]
374
+ if missing:
375
+ raise ValueError(f"battle eb for {donor!r} missing langs: {missing}")
376
+
377
+ # battle text: read each language's <id>.mes by its REAL resource path (faithful + collision-safe).
378
+ mes, mes_note = _read_battle_text(donor_id, game=game)
379
+ return {"raw16": raw16, "raw17": raw17, "donor_id": donor_id, "eb": eb, "mes": mes, "mes_note": mes_note}
380
+
381
+
382
+ def write_scene_assets(out_dir, donor, game=None) -> dict:
383
+ """Fork `donor` scene assets into ``<out_dir>/scene/`` (gitignored). Layout consumed by build.py:
384
+ ``scene/dbfile0000.raw16.bytes``, ``scene/btlseq.raw17.bytes``, ``scene/eb/<lang>.eb.bytes``,
385
+ ``scene/mes/<lang>.mes``. Returns a small manifest (donor, donor_id, byte sizes)."""
386
+ a = read_scene_assets(donor, game)
387
+ sdir = Path(out_dir) / "scene"
388
+ (sdir / "eb").mkdir(parents=True, exist_ok=True)
389
+ (sdir / "mes").mkdir(parents=True, exist_ok=True)
390
+ (sdir / "dbfile0000.raw16.bytes").write_bytes(a["raw16"])
391
+ (sdir / "btlseq.raw17.bytes").write_bytes(a["raw17"])
392
+ for lang in config.LANGS:
393
+ (sdir / "eb" / f"{lang}.eb.bytes").write_bytes(a["eb"][lang])
394
+ (sdir / "mes" / f"{lang}.mes").write_bytes(a["mes"][lang])
395
+ return {"donor": donor.upper(), "donor_id": a["donor_id"],
396
+ "raw16": len(a["raw16"]), "raw17": len(a["raw17"]), "langs": len(config.LANGS),
397
+ "mes_note": a.get("mes_note")}
398
+
399
+
400
+ def list_battle_scenes(pattern=None, game=None) -> list[str]:
401
+ """List real battle-scene NAMES available to fork as a mint donor (e.g. EF_R007 = Evil Forest)."""
402
+ import re
403
+ UnityPy = _unitypy()
404
+ env = UnityPy.load(str(_p0data2(game)))
405
+ rx = re.compile(r"battlescene/evt_battle_([^/]+)/dbfile0000\.raw16", re.I)
406
+ names = set()
407
+ for o in env.objects:
408
+ m = rx.search((getattr(o, "container", None) or "").lower())
409
+ if m:
410
+ names.add(m.group(1).upper())
411
+ rows = sorted(names)
412
+ if pattern:
413
+ rows = [n for n in rows if pattern.lower() in n.lower()]
414
+ return rows
415
+
416
+
417
+ def write_battle_project(bbg, out_dir, *, name=None, scene_id=5000, game=None,
418
+ fork_scene=None, ship_as=None):
419
+ """Fork `bbg` into `out_dir`: <bbg>.fbx + image#.png + an editable battle.toml. Returns (meta, toml).
420
+
421
+ ``fork_scene`` (a donor battle-scene NAME, e.g. 'EF_R007') ALSO forks that scene's gameplay/sequence/
422
+ text into ``scene/`` and writes a tier-c MINT battle.toml -- a brand-new, independently-triggerable
423
+ battle. ``ship_as`` (e.g. 'BBG_B200') ships the geometry under a NEW bbg number instead of overriding
424
+ the forked slot (the kit authors a static INB for it at build time).
425
+ """
426
+ out = Path(out_dir)
427
+ out.mkdir(parents=True, exist_ok=True)
428
+ groups, env, bbg = read_bbg(bbg, game)
429
+ tex = _fbx.textures_used(groups)
430
+ text, ngeo = _fbx.emit_fbx(groups)
431
+ ship_bbg = ship_as or bbg
432
+ (out / f"{ship_bbg}.fbx").write_text(text, encoding="ascii", newline="\n")
433
+ saved = _save_textures(env, bbg, out, tex)
434
+ name = name or f"{bbg}_FORK"
435
+ meta = {"bbg": ship_bbg, "src_bbg": bbg, "groups": len(groups), "geometries": ngeo, "textures": saved}
436
+ toml_path = out / "battle.toml"
437
+ if fork_scene:
438
+ scene_meta = write_scene_assets(out, fork_scene, game)
439
+ meta["scene"] = scene_meta
440
+ toml_path.write_text(_mint_toml(ship_bbg, name, scene_id, ngeo, len(groups), scene_meta,
441
+ new_bbg=bool(ship_as)),
442
+ encoding="utf-8", newline="\n")
443
+ else:
444
+ toml_path.write_text(_battle_toml(ship_bbg, name, scene_id, ngeo, len(groups)),
445
+ encoding="utf-8", newline="\n")
446
+ return meta, toml_path
447
+
448
+
449
+ def _mint_toml(bbg, name, scene_id, ngeo, ngroups, scene_meta, *, new_bbg) -> str:
450
+ tint = "" if not new_bbg else (
451
+ '\n# char_tint = [128, 128, 128] # optional RGB tint the engine lights party/enemies with on this'
452
+ '\n# # map (0-255); default neutral. shadow = 32 by default.')
453
+ return f'''# Tier-c MINT: a brand-new battle SCENE forked by `ff9mapkit battle-import --fork-scene`.
454
+ # Geometry: {bbg}.fbx ({ngeo} meshes / {ngroups} groups) + image#.png. Gameplay/camera/text forked from
455
+ # donor scene {scene_meta["donor"]} (id {scene_meta["donor_id"]}) into scene/ (raw16 + raw17 + eb + mes).
456
+ # Edit {bbg}.fbx in Blender / repaint the PNGs to make the map your own, then:
457
+ # ff9mapkit battle-build battle.toml --out dist
458
+ # py tools/deploy_battle.py battle.toml --trigger-field 5000 # reversible; repoints a field's encounter
459
+ # Then RELAUNCH FF9 (a new BattleScene id loads at launch) and trigger the battle on the trigger field.
460
+
461
+ [battlemap]
462
+ bbg = "{bbg}" # ships AS this slot. A NEW number (BBG_B178+) = a wholly original map (the kit
463
+ # authors a static INB for it). An existing number would OVERRIDE that real map.
464
+ fbx = "{bbg}.fbx"
465
+ scene_id = {scene_id} # the net-new battle id this mint registers (must not collide with any field/scene id)
466
+ scene_name = "{name}" # -> EVT_BATTLE_{name} + BSC_{name}{tint}
467
+
468
+ # --- tune the fight (optional) -----------------------------------------------------------------------
469
+ # The donor's enemies/camera are forked verbatim; uncomment to OVERRIDE. Enemy TYPES are kept (so the
470
+ # forked attack sequences stay valid) -- you reposition / restat / re-reward them and pick the camera.
471
+ # [scene]
472
+ # camera = 0 # pattern camera: 0/1/2 = a fixed PSX pose, >=3 = random (default = donor's).
473
+ # # Pin it 0-2 to make the OPENING-camera tweaks below deterministic.
474
+ # camera_yaw = 0 # rotate the opening sweep N degrees around the battle (in place, no repack)
475
+ # camera_pitch = 0 # tilt N degrees -- FINICKY: an offset onto the donor's base pitch, so a large
476
+ # # value can dip the camera below the floor (the ground mesh is see-through from
477
+ # # under). Use small steps + test; yaw + zoom are the predictable knobs.
478
+ # camera_zoom = 1.0 # opening-camera distance multiplier (1.5 = farther out, 0.7 = closer in)
479
+ #
480
+ # --- author the opening camera SWEEP from keyframes (optional, advanced) ------------------------------
481
+ # Replaces the opening swoop with your own, in the SAME grammar the base game uses. Keyframes ADJUST the
482
+ # battle's PROVEN framing (the shot it normally settles into): yaw/pitch/roll are degree OFFSETS and `zoom`
483
+ # is a distance multiplier (a keyframe with no offsets = the normal framing). Keyframe 0 is the instant
484
+ # START pose; each later one MOVES the camera there over `move` frames. End on a no-offset keyframe (or
485
+ # small offsets) so the fight stays framed --
486
+ # the camera's origin is the battle centre, and distance is measured from it, so anchoring on the proven
487
+ # pose is what stops a sweep from mis-framing. The donor's on-fight look-at + the intro->battle handoff are
488
+ # kept. Pin `camera = 0` above. Needs >= 2 keyframes (a start + a move).
489
+ # [[scene.camera_keyframes]] # START: swing 76deg to one side, 2.5x farther out, slightly higher
490
+ # yaw = -76 # degrees to orbit from the normal shot (+/-)
491
+ # pitch = 5 # degrees to tilt (small; +tips the camera down toward the floor)
492
+ # zoom = 2.5 # distance x2.5 (start wide); 1.0 = the normal framing, <1 = closer
493
+ # [[scene.camera_keyframes]] # swoop IN and around
494
+ # yaw = -20
495
+ # zoom = 1.6
496
+ # move = 45 # frames to reach this pose
497
+ # ease = "in" # in (start slow) | out (end slow) | linear
498
+ # [[scene.camera_keyframes]] # settle EXACTLY on the battle's normal framing
499
+ # move = 30
500
+ # ease = "out"
501
+ #
502
+ # monster_count = 4 # how many of the 4 slots SPAWN (1-4). The kit re-authors the eb's enemy-AI
503
+ # # binding to match, so you CAN exceed the donor's natural count. Give each
504
+ # # active slot a 'type' (an existing scene type).
505
+ # [[scene.enemy]]
506
+ # slot = 0 # which placed enemy (0-3) in the pattern
507
+ # type = 0 # which enemy TYPE fills this slot (0..N-1; must be a type ALREADY in this
508
+ # # scene so the forked attack sequences/models cover it). Makes it targetable.
509
+ # pos = [300, -400] # [x, z] on the battle ground (or [x, y, z] to set height); rot = 0..4095
510
+ # hp = 1500 # this enemy TYPE's stats (hp/mp/gil/exp/level/speed/strength/magic/spirit)
511
+ # gil = 999
512
+ # drop = ["Hi-Potion", "Ether", "none", "none"] # WinItems[4] by name/id ("none" = empty)
513
+ # steal = ["Phoenix Down", "none", "none", "none"] # StealItems[4]
514
+ '''
515
+
516
+
517
+ def _battle_toml(bbg, name, scene_id, ngeo, ngroups) -> str:
518
+ return f'''# Battle background forked from {bbg} by `ff9mapkit battle-import`.
519
+ # Geometry: {bbg}.fbx ({ngeo} meshes / {ngroups} groups). Textures: image#.png in this dir.
520
+ # Edit {bbg}.fbx in Blender (KEEP the mesh objects named Group_0/2/4/8) and/or repaint the PNGs, then:
521
+ # ff9mapkit battle-build battle.toml --out dist
522
+ # py tools/deploy_battle.py battle.toml # reversible install into your mod folder
523
+
524
+ [battlemap]
525
+ bbg = "{bbg}" # the slot this map SHIPS AS. Keep it = the forked slot to OVERRIDE that real
526
+ # battle map (proven, no relaunch). Rename to a new BBG_* only if you ALSO wire
527
+ # a scene below.
528
+ fbx = "{bbg}.fbx" # geometry file in this dir (edit in Blender, re-export over it)
529
+
530
+ # --- scene wiring (optional) -------------------------------------------------------------------------
531
+ # With bbg = the forked slot (above), this OVERRIDES the real map for every battle that uses it -- no
532
+ # wiring needed. To point a DIFFERENT existing battle at this map instead, uncomment:
533
+ # repoint_scene = 67 # an existing battle-scene id; its bg becomes `bbg` (via BattlePatch.txt)
534
+ #
535
+ # EXPERIMENTAL (tier c) -- mint a brand-new battle scene. A new scene id also needs its own .raw16/.raw17
536
+ # scene assets + a camera, which the kit does NOT yet author, so a bare new id will not load. Leave off
537
+ # unless you know what you're doing:
538
+ # scene_id = {scene_id}
539
+ # scene_name = "{name}"
540
+ '''