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,223 @@
1
+ """Battle-map geometry model + ASCII-FBX emitter (PURE — no I/O, no UnityPy; offline-testable).
2
+
3
+ A battle background ("BBG") is a Unity model whose child meshes are named Group_0/2/4/8 and classified
4
+ by battlebg.getBbgAttr (battlebg.cs:266): Group_0=PLUS(additive), Group_2=GROUND, Group_4=MINUS
5
+ (subtractive), Group_8=SKY. Memoria's ModelImporter/FbxIO loads a loose FBX from the mod folder INSTEAD
6
+ of the bundle, so a custom battle map ships as an ASCII FBX (+ image#.png textures).
7
+
8
+ Recipe verified in-game 2026-06-09 (a synthetic quad AND a faithful BBG_B013 round-trip):
9
+ * one FBX Geometry per submesh, named "Geometry::Group_N" (duplicate names are fine — getBbgAttr
10
+ matches the literal child-name string);
11
+ * verts / uvs / normals VERBATIM (no axis flip, no scale, no UV V-flip);
12
+ * triangles use the FBX polygon-end convention (last index of each face = -i-1); native winding kept;
13
+ * the Model node is typed "Mesh" (NOT LimbNode/Root — else a skeleton NRE in CreateCustomModelFromFbx)
14
+ with identity transform, so GetVertices applies an identity bone matrix;
15
+ * the Material's ShadingModel is set DIRECTLY to the group's PSX shader — imported meshes become
16
+ SkinnedMeshRenderers, which battlebg's MeshRenderer-only shader pass (SetMaterialShader/setBGColor)
17
+ skips, so the in-FBX shader is what sticks (GetShaderPathFromType passes non-Default/Phong verbatim);
18
+ * texture via a Texture node RelativeFilename + an "OP" connection; loaded from disc next to the FBX.
19
+
20
+ `groups` is the canonical geometry structure (also what battle.extract produces):
21
+ [ { "name": "Group_2",
22
+ "verts": [[x,y,z], ...], # per vertex (Unity space, verbatim)
23
+ "normals":[[x,y,z], ...] | None, # per vertex
24
+ "uvs": [[u,v], ...], # per vertex
25
+ "submeshes": [ {"texture": "image6", "tris": [[a,b,c], ...]} ] }, # tris index into verts
26
+ ... ]
27
+ """
28
+ from __future__ import annotations
29
+
30
+ # Group child-name -> PSX shader (battlebg.SetDefaultShader, battlebg.cs:52-75) and attribute name.
31
+ GROUP_SHADER = {
32
+ "Group_0": "PSX/BattleMap_Plus",
33
+ "Group_2": "PSX/BattleMap_Ground",
34
+ "Group_4": "PSX/BattleMap_Minus",
35
+ "Group_8": "PSX/BattleMap_Sky",
36
+ }
37
+ GROUP_ATTR = {"Group_0": "PLUS", "Group_2": "GROUND", "Group_4": "MINUS", "Group_8": "SKY"}
38
+
39
+
40
+ def validate_groups(groups) -> list[str]:
41
+ """Return human-readable problems with a `groups` structure (empty => OK)."""
42
+ problems: list[str] = []
43
+ if not groups:
44
+ problems.append("no geometry groups")
45
+ for g in groups:
46
+ name = g.get("name")
47
+ if name not in GROUP_SHADER:
48
+ problems.append(f"group {name!r} is not one of Group_0/2/4/8 "
49
+ f"(getBbgAttr would default it to PLUS)")
50
+ vc = len(g.get("verts", []))
51
+ if vc == 0:
52
+ problems.append(f"group {name!r} has no vertices")
53
+ if g.get("normals") is not None and len(g["normals"]) != vc:
54
+ problems.append(f"group {name!r}: normals count ({len(g['normals'])}) != verts count ({vc})")
55
+ if len(g.get("uvs", [])) != vc:
56
+ problems.append(f"group {name!r}: uvs count ({len(g.get('uvs', []))}) != verts count ({vc})")
57
+ for sm in g.get("submeshes", []):
58
+ if not sm.get("texture"):
59
+ problems.append(f"group {name!r}: a submesh has no texture")
60
+ for tri in sm.get("tris", []):
61
+ if any(i < 0 or i >= vc for i in tri):
62
+ problems.append(f"group {name!r}: triangle index out of range (verts={vc})")
63
+ break
64
+ return problems
65
+
66
+
67
+ def textures_used(groups) -> list[str]:
68
+ """Sorted unique texture stems referenced by the geometry (the image#.png siblings to ship)."""
69
+ return sorted({sm["texture"] for g in groups for sm in g.get("submeshes", []) if sm.get("texture")})
70
+
71
+
72
+ def emit_fbx(groups) -> tuple[str, int]:
73
+ """Render `groups` to ASCII FBX text. Returns (fbx_text, geometry_count).
74
+
75
+ One Geometry/Model/Material/Texture per submesh. The Model is typed "Mesh" with no transform, so
76
+ Memoria reads vertices verbatim; the Material's ShadingModel is the group's PSX shader.
77
+ """
78
+ objs: list[str] = []
79
+ conns: list[str] = []
80
+ nid = 1000
81
+ ngeo = 0
82
+ for g in groups:
83
+ name = g["name"]
84
+ shader = GROUP_SHADER.get(name, "PSX/BattleMap_Plus")
85
+ verts = g["verts"]
86
+ norms = g.get("normals")
87
+ uvs = g["uvs"]
88
+ vc = len(verts)
89
+ has_n = norms is not None and len(norms) == vc
90
+ vflat = ",".join(f"{c:g}" for v in verts for c in v)
91
+ nflat = ",".join(f"{c:g}" for n in norms for c in n) if has_n else ""
92
+ uflat = ",".join(f"{c:g}" for uv in uvs for c in uv)
93
+ for sm in g["submeshes"]:
94
+ gid, mid, matid, texid = nid, nid + 1, nid + 2, nid + 3
95
+ nid += 10
96
+ ngeo += 1
97
+ pvi: list[int] = []
98
+ for (a, b, c) in sm["tris"]:
99
+ pvi += [a, b, -c - 1] # FBX polygon-end convention
100
+ pviflat = ",".join(str(i) for i in pvi)
101
+ tex = sm["texture"]
102
+ geo = [f'\tGeometry: {gid}, "Geometry::{name}", "Mesh" {{']
103
+ geo.append(f'\t\tVertices: *{vc * 3} {{\n\t\t\ta: {vflat}\n\t\t}}')
104
+ geo.append(f'\t\tPolygonVertexIndex: *{len(pvi)} {{\n\t\t\ta: {pviflat}\n\t\t}}')
105
+ if has_n:
106
+ geo.append(f'\t\tLayerElementNormal: 0 {{\n\t\t\tMappingInformationType: "ByVertex"\n'
107
+ f'\t\t\tReferenceInformationType: "Direct"\n'
108
+ f'\t\t\tNormals: *{vc * 3} {{\n\t\t\t\ta: {nflat}\n\t\t\t}}\n\t\t}}')
109
+ geo.append(f'\t\tLayerElementUV: 0 {{\n\t\t\tMappingInformationType: "ByVertex"\n'
110
+ f'\t\t\tReferenceInformationType: "Direct"\n'
111
+ f'\t\t\tUV: *{vc * 2} {{\n\t\t\t\ta: {uflat}\n\t\t\t}}\n\t\t}}')
112
+ geo.append('\t}')
113
+ objs.append("\n".join(geo))
114
+ objs.append(f'\tModel: {mid}, "Model::{name}", "Mesh" {{\n\t}}')
115
+ objs.append(f'\tMaterial: {matid}, "Material::{name}_{texid}", "" {{\n'
116
+ f'\t\tShadingModel: "{shader}"\n\t}}')
117
+ objs.append(f'\tTexture: {texid}, "Texture::{tex}", "" {{\n'
118
+ f'\t\tRelativeFilename: "{tex}.png"\n\t}}')
119
+ conns.append(f'\tC: "OO", {gid}, {mid}') # geometry -> model
120
+ conns.append(f'\tC: "OO", {matid}, {mid}') # material -> model
121
+ conns.append(f'\tC: "OP", {texid}, {matid}, "DiffuseColor"') # texture -> material
122
+ return (
123
+ "; FBX 7.4.0 project file\n\nFBXHeaderExtension: {\n\tFBXVersion: 7400\n}\n"
124
+ "Objects: {\n" + "\n".join(objs) + "\n}\n"
125
+ "Connections: {\n" + "\n".join(conns) + "\n}\n"
126
+ ), ngeo
127
+
128
+
129
+ # --------------------------------------------------------------------- parse (inverse of emit_fbx)
130
+ import re as _re
131
+
132
+ _NODE_RE = _re.compile(r'(Geometry|Model|Material|Texture):\s*(\d+),\s*"([^"]*)",\s*"[^"]*"\s*\{')
133
+ _CONN_RE = _re.compile(r'C:\s*"(OO|OP)",\s*(\d+),\s*(\d+)')
134
+
135
+
136
+ def _block(text, start):
137
+ """The text of the brace-balanced block beginning at the '{' at/after `start`. Tolerant of the
138
+ nested `{ }` of LayerElement*/array sub-blocks our emitter produces."""
139
+ depth = 0
140
+ i = text.index("{", start)
141
+ j = i
142
+ while j < len(text):
143
+ ch = text[j]
144
+ if ch == "{":
145
+ depth += 1
146
+ elif ch == "}":
147
+ depth -= 1
148
+ if depth == 0:
149
+ return text[i + 1:j]
150
+ j += 1
151
+ return text[i + 1:]
152
+
153
+
154
+ def _floats(s):
155
+ return [float(x) for x in s.replace("\n", " ").split(",") if x.strip()]
156
+
157
+
158
+ def _ints(s):
159
+ return [int(float(x)) for x in s.replace("\n", " ").split(",") if x.strip()]
160
+
161
+
162
+ def parse_fbx(text):
163
+ """Inverse of :func:`emit_fbx`: parse an FBX WE emitted back to the canonical `groups` structure.
164
+
165
+ NOT a general FBX reader -- it understands exactly our emitter's layout (one Geometry/Model/Material/
166
+ Texture per submesh, the Connections tying texture->material->model<-geometry). Geometries that share a
167
+ child-name (Group_N) are merged into one group with one submesh each, so
168
+ ``emit_fbx(parse_fbx(emit_fbx(g))) == emit_fbx(g)`` (round-trip; tested). Lets the Blender add-on load a
169
+ kit-built/forked BBG_B###.fbx for reshaping, then re-export it through the same emitter.
170
+ """
171
+ # 1) index every Objects node by id, capturing its inner block text
172
+ nodes = {} # id -> (kind, child_name, block_text)
173
+ for m in _NODE_RE.finditer(text):
174
+ kind, nid, label = m.group(1), int(m.group(2)), m.group(3)
175
+ child = label.split("::", 1)[1] if "::" in label else label
176
+ nodes[nid] = (kind, child, _block(text, m.end() - 1))
177
+ # 2) connections: geo->model (OO), material->model (OO), texture->material (OP)
178
+ model_of_geo, mat_of_model, tex_of_mat = {}, {}, {}
179
+ for ck, src, dst in _CONN_RE.findall(text):
180
+ src, dst = int(src), int(dst)
181
+ sk = nodes.get(src, (None,))[0]
182
+ if ck == "OO" and sk == "Geometry":
183
+ model_of_geo[src] = dst
184
+ elif ck == "OO" and sk == "Material":
185
+ mat_of_model[dst] = src # model <- material
186
+ elif ck == "OP" and sk == "Texture":
187
+ tex_of_mat[dst] = src # material <- texture
188
+ # 3) per geometry -> its texture stem, via model<-material<-texture
189
+ groups, by_name = [], {}
190
+ for gid, (kind, name, body) in nodes.items():
191
+ if kind != "Geometry":
192
+ continue
193
+ verts = [list(v) for v in _chunk(_floats(_named_array(body, "Vertices")), 3)]
194
+ pvi = _ints(_named_array(body, "PolygonVertexIndex"))
195
+ tris = [[pvi[i], pvi[i + 1], -pvi[i + 2] - 1] for i in range(0, len(pvi) - 2, 3)]
196
+ nrm_raw = _named_array(body, "Normals")
197
+ normals = [list(v) for v in _chunk(_floats(nrm_raw), 3)] if nrm_raw is not None else None
198
+ uvs = [list(v) for v in _chunk(_floats(_named_array(body, "UV") or ""), 2)]
199
+ tex = None
200
+ mid = model_of_geo.get(gid)
201
+ if mid is not None:
202
+ matid = mat_of_model.get(mid)
203
+ if matid is not None:
204
+ texid = tex_of_mat.get(matid)
205
+ if texid is not None:
206
+ tex = nodes[texid][1] # Texture child-name == the stem
207
+ g = by_name.get(name)
208
+ if g is None:
209
+ g = {"name": name, "verts": verts, "normals": normals, "uvs": uvs, "submeshes": []}
210
+ by_name[name] = g
211
+ groups.append(g)
212
+ g["submeshes"].append({"texture": tex, "tris": tris})
213
+ return groups
214
+
215
+
216
+ def _named_array(body, key):
217
+ """The flat ``a: ...`` payload string of the first ``<key>: *N { a: ... }`` in `body`, or None."""
218
+ m = _re.search(rf'{key}:\s*\*\d+\s*\{{\s*a:\s*([^}}]*)\}}', body, _re.S)
219
+ return m.group(1) if m else None
220
+
221
+
222
+ def _chunk(flat, n):
223
+ return [flat[i:i + n] for i in range(0, len(flat) - n + 1, n)]
@@ -0,0 +1,149 @@
1
+ """Resolve a re-skin's DONOR -- a real battle enemy whose model+animation block we transplant.
2
+
3
+ A re-skin makes a forked enemy's BODY look like a different creature while keeping its own gameplay. The visual
4
+ identity is a self-consistent group of SB2_MON_PARM fields (Geo + the six Mot animation ids + Mesh + Radius +
5
+ the model-attached cosmetics -- see :data:`scene_data._RESKIN_RANGES`), so it can't be set field-by-field: a
6
+ Mot id that doesn't belong to the loaded Geo names a clip the model lacks, and the battle FREEZES
7
+ (`btl_init.cs:240`/`:521-522`). The only safe source is a real enemy that ALREADY uses the target model --
8
+ Square-Enix shipped that whole block together, so it is guaranteed engine-valid.
9
+
10
+ SCOPE -- this is a BODY re-skin, NOT a full one (★ IN-GAME PROVEN 2026-06-13). The transplanted Mot[6] DO drive
11
+ the new model's OWN idle / damage / death animations (`btl_init.cs:240`); but the per-ATTACK animation is bound
12
+ by the donor SCENE's raw17 btlseq (keyed by the per-type Konran@78 selector, `btlseq.cs:1150-1151`), which a
13
+ re-skin KEEPS -- so the ATTACK plays the TARGET enemy's clip, RETARGETED onto the new mesh (clip load path is by
14
+ clip NAME, `AnimationFactory.cs:60`, so the cross-model retarget never crashes). Proven: a Goblin re-skinned to
15
+ the Fang IDLED as a quadruped Fang but KNIFED / Goblin-Punched with the Goblin's upright animation. So the body
16
+ looks like the new creature at rest / when hit / dying, but its ATTACK gesture stays the target's. A faithful
17
+ FULL re-skin would also need the donor's raw17 attack binding + AA_DATA -- the deferred raw17-sequence work. The
18
+ build warns per re-skinned slot.
19
+
20
+ This module reads the donor block LIVE from the user's install (provenance: never committed; only the kit's
21
+ open-source name->geo catalog ships). Two donor specs:
22
+ * ``{"scene": "EF_R007", "type": 0}`` -- an explicit donor battle scene + enemy type (deterministic, the
23
+ most reliable form: "look like THAT enemy"). Names from ``ff9mapkit battle-list --scenes``.
24
+ * ``{"name": "GEO_MON_B3_001"}`` -- a battle GEO model name (``GEO_MON_B3_*``) or a numeric geo id; resolved
25
+ to a geo id, then the install is SCANNED for the first real enemy that uses it (so the copied bytes are
26
+ always a real, shipped block). NOTE: friendly creature names (e.g. "goblin") are FIELD models
27
+ (``GEO_MON_F0_*``) and are NOT used by battle enemies -- use a donor scene or a ``GEO_MON_B3_*`` id instead.
28
+
29
+ The pure byte-copy lives in :mod:`scene_data`; this module only does the install I/O + name resolution.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import re
34
+
35
+ from . import extract, scene_codec, scene_data
36
+
37
+ _RX_RAW16 = re.compile(r"battlescene/evt_battle_([^/]+)/dbfile0000\.raw16", re.I)
38
+ # infra failures that mean "can't reach the install" (find_game_path raises ConfigError, a RuntimeError; a
39
+ # missing UnityPy raises RuntimeError) -- caught + re-raised as an ACTIONABLE ReskinError, not a raw traceback.
40
+ _INFRA_ERRORS = (RuntimeError, FileNotFoundError)
41
+ _INSTALL_HINT = ("can't reach the FF9 install to read the donor model -- pass `--game <FF9 dir>`, set "
42
+ "$FF9_GAME_PATH, or `pip install UnityPy`")
43
+
44
+
45
+ class ReskinError(scene_data.SceneEditError):
46
+ pass
47
+
48
+
49
+ def _geo_for_name(name) -> int | None:
50
+ """A battle GEO name / numeric id (or a friendly creature name) -> its geo (model) id, or None. Uses the
51
+ kit's baked, open-source model catalog (``catalog.model`` is the same id ``SetModel``/SB2_MON_PARM.Geo
52
+ take). Friendly creature names resolve to FIELD-form ids (``GEO_MON_F0_*``) that no battle enemy uses --
53
+ they'll fail the enemy scan with a clear message; battle enemies are ``GEO_MON_B3_*``."""
54
+ from .. import archetypes as _arch
55
+ from .. import catalog as _cat
56
+ key = str(name).strip()
57
+ m = _cat.model(key) # exact GEO name or numeric id
58
+ if m:
59
+ return m.id
60
+ spec = _arch.CREATURES.get(key.lower()) # friendly creature name -> a (FIELD) GEO model name
61
+ if spec:
62
+ m = _cat.model(spec["model"])
63
+ if m:
64
+ return m.id
65
+ return None
66
+
67
+
68
+ def _scan_for_geo(geo_id: int, game=None):
69
+ """Scan the install's battle scenes for the FIRST enemy whose Geo == ``geo_id``. Loads p0data2 ONCE and
70
+ parses each scene's raw16. Returns (scene_name, type_no, donor_raw16) or None. (Used by the ``name`` form
71
+ so the transplanted block is always a real shipped record, even if a name->id map were imperfect.)"""
72
+ UnityPy = extract._unitypy()
73
+ from ..extract import _raw_bytes
74
+ env = UnityPy.load(str(extract._p0data2(game)))
75
+ for o in env.objects:
76
+ if o.type.name != "TextAsset":
77
+ continue
78
+ mm = _RX_RAW16.search((getattr(o, "container", None) or "").lower())
79
+ if not mm:
80
+ continue
81
+ try:
82
+ raw16 = _raw_bytes(o.read())
83
+ scene = scene_codec.parse_scene(raw16)
84
+ except Exception: # noqa: BLE001 -- a malformed/odd scene just isn't a donor
85
+ continue
86
+ for t, mon in enumerate(scene.monsters):
87
+ if mon.geo == geo_id:
88
+ return mm.group(1).upper(), t, raw16
89
+ return None
90
+
91
+
92
+ def resolve_donor_block(spec: dict, *, game=None) -> tuple[bytes, str]:
93
+ """Resolve a donor ``spec`` -> (116-byte monster block, human provenance string). Install-gated.
94
+
95
+ ``spec`` is ``{"scene": NAME, "type": N}`` (explicit) or ``{"name": NAME}`` (a friendly/GEO name, scanned).
96
+ Raises :class:`ReskinError` with an actionable message on an unknown name / no enemy using that model /
97
+ an out-of-range donor type."""
98
+ if spec.get("scene"):
99
+ donor = str(spec["scene"]).upper()
100
+ t = int(spec.get("type", 0))
101
+ try:
102
+ raw16 = extract.read_scene_assets(donor, game)["raw16"]
103
+ except (ValueError, KeyError) as ex:
104
+ raise ReskinError(f"re-skin donor scene {donor!r} not readable: {ex}. Use a name from "
105
+ f"`ff9mapkit battle-list --scenes`.")
106
+ except _INFRA_ERRORS as ex:
107
+ raise ReskinError(f"re-skin donor scene {donor!r}: {_INSTALL_HINT} ({ex})")
108
+ return scene_data.mon_block(raw16, t), f"{donor} type {t}"
109
+
110
+ name = spec.get("name")
111
+ if not name:
112
+ raise ReskinError("re-skin spec needs a 'name' (a GEO model name / geo id) or a 'scene' (+ 'type')")
113
+ geo = _geo_for_name(name)
114
+ if geo is None:
115
+ raise ReskinError(f"re-skin model {name!r}: unknown GEO model name / id. Battle enemies use "
116
+ f"`GEO_MON_B3_*` names (browse with `ff9mapkit models`), or point at a real donor "
117
+ f"enemy with model_scene = \"<SCENE>\" (+ model_type = N).")
118
+ try:
119
+ found = _scan_for_geo(geo, game)
120
+ except _INFRA_ERRORS as ex:
121
+ raise ReskinError(f"re-skin model {name!r}: {_INSTALL_HINT} ({ex})")
122
+ if found is None:
123
+ raise ReskinError(f"re-skin model {name!r} (geo {geo}) is not used by any BATTLE enemy, so there is "
124
+ f"no real animation set to transplant. (Friendly creature names are FIELD models, "
125
+ f"not battle enemies.) Use a `GEO_MON_B3_*` id, or a donor enemy with "
126
+ f"model_scene = \"<SCENE>\" (+ model_type = N).")
127
+ scene_name, t, donor_raw16 = found
128
+ return scene_data.mon_block(donor_raw16, t), f"{name} (geo {geo}) from {scene_name} type {t}"
129
+
130
+
131
+ def reskin_spec(enemy: dict):
132
+ """The donor spec for a ``[[scene.enemy]]`` dict, or None if it has no re-skin. ``model_scene`` (+ optional
133
+ ``model_type``) is the explicit donor; ``model`` is a friendly/GEO name. Raises on a contradictory combo."""
134
+ has_scene = enemy.get("model_scene") is not None
135
+ has_name = enemy.get("model") is not None
136
+ if has_scene and has_name:
137
+ raise ReskinError(f"slot {enemy.get('slot')}: set EITHER model = \"<name>\" OR model_scene = "
138
+ f"\"<SCENE>\" (+ model_type), not both")
139
+ if has_scene:
140
+ return {"scene": enemy["model_scene"], "type": int(enemy.get("model_type", 0))}
141
+ if has_name:
142
+ if enemy.get("model_type") is not None:
143
+ raise ReskinError(f"slot {enemy.get('slot')}: model_type only applies with model_scene (the "
144
+ f"model = \"<name>\" form auto-finds the donor enemy)")
145
+ return {"name": enemy["model"]}
146
+ if enemy.get("model_type") is not None: # model_type alone = a typo (meant model_scene); don't drop it silently
147
+ raise ReskinError(f"slot {enemy.get('slot')}: model_type has no effect without model_scene = "
148
+ f"\"<SCENE>\" (the donor battle scene to copy the model from)")
149
+ return None