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.
- ff9mapkit/__init__.py +18 -0
- ff9mapkit/__main__.py +36 -0
- ff9mapkit/_animdb.py +2994 -0
- ff9mapkit/_animdb_all.py +14125 -0
- ff9mapkit/_fieldtable.py +1516 -0
- ff9mapkit/_fieldtext.py +845 -0
- ff9mapkit/_held_poses.py +44 -0
- ff9mapkit/_itemdb.py +65 -0
- ff9mapkit/_modeldb.py +725 -0
- ff9mapkit/_narrowmap_data.py +10 -0
- ff9mapkit/_npcparams.py +634 -0
- ff9mapkit/_regen_animdb.py +72 -0
- ff9mapkit/_regen_animdb_all.py +66 -0
- ff9mapkit/_regen_fieldtable.py +95 -0
- ff9mapkit/_regen_fieldtext.py +66 -0
- ff9mapkit/_regen_modeldb.py +67 -0
- ff9mapkit/_regen_npcparams.py +123 -0
- ff9mapkit/_regen_scenedb.py +57 -0
- ff9mapkit/_scenedb.py +869 -0
- ff9mapkit/abilities.py +225 -0
- ff9mapkit/animations.py +120 -0
- ff9mapkit/archetypes.py +218 -0
- ff9mapkit/areatitle.py +76 -0
- ff9mapkit/battle/__init__.py +21 -0
- ff9mapkit/battle/abilityfeatures.py +294 -0
- ff9mapkit/battle/actiondelta.py +441 -0
- ff9mapkit/battle/aiauthor.py +305 -0
- ff9mapkit/battle/ailint.py +140 -0
- ff9mapkit/battle/aipatch.py +175 -0
- ff9mapkit/battle/battleai.py +148 -0
- ff9mapkit/battle/battlecsv.py +390 -0
- ff9mapkit/battle/battlepatch.py +395 -0
- ff9mapkit/battle/build.py +558 -0
- ff9mapkit/battle/camera_codec.py +332 -0
- ff9mapkit/battle/camera_data.py +128 -0
- ff9mapkit/battle/characterdelta.py +789 -0
- ff9mapkit/battle/event_data.py +72 -0
- ff9mapkit/battle/extract.py +540 -0
- ff9mapkit/battle/fbx.py +223 -0
- ff9mapkit/battle/reskin.py +149 -0
- ff9mapkit/battle/scene_codec.py +314 -0
- ff9mapkit/battle/scene_data.py +369 -0
- ff9mapkit/battle/scenelint.py +125 -0
- ff9mapkit/battle/seqasm.py +131 -0
- ff9mapkit/battle/seqauthor.py +220 -0
- ff9mapkit/battle/seqcodec.py +300 -0
- ff9mapkit/battle/seqdis.py +106 -0
- ff9mapkit/battle/seqpatch.py +137 -0
- ff9mapkit/battle_bgm.py +133 -0
- ff9mapkit/binutils.py +60 -0
- ff9mapkit/build.py +5445 -0
- ff9mapkit/campaign.py +1276 -0
- ff9mapkit/catalog.py +316 -0
- ff9mapkit/chain.py +358 -0
- ff9mapkit/cli.py +3114 -0
- ff9mapkit/config.py +360 -0
- ff9mapkit/content/__init__.py +13 -0
- ff9mapkit/content/areatitle.py +36 -0
- ff9mapkit/content/ate.py +118 -0
- ff9mapkit/content/camera.py +123 -0
- ff9mapkit/content/chest.py +186 -0
- ff9mapkit/content/choice.py +163 -0
- ff9mapkit/content/conductor.py +217 -0
- ff9mapkit/content/cutscene.py +290 -0
- ff9mapkit/content/encounter.py +41 -0
- ff9mapkit/content/entry_settle.py +50 -0
- ff9mapkit/content/equipment.py +93 -0
- ff9mapkit/content/event.py +191 -0
- ff9mapkit/content/gateway.py +101 -0
- ff9mapkit/content/inventory.py +59 -0
- ff9mapkit/content/itemdata.py +644 -0
- ff9mapkit/content/itemtext.py +168 -0
- ff9mapkit/content/jump.py +114 -0
- ff9mapkit/content/ladder.py +633 -0
- ff9mapkit/content/movement.py +53 -0
- ff9mapkit/content/music.py +97 -0
- ff9mapkit/content/npc.py +348 -0
- ff9mapkit/content/object.py +340 -0
- ff9mapkit/content/onentry.py +135 -0
- ff9mapkit/content/party.py +111 -0
- ff9mapkit/content/pathfind.py +138 -0
- ff9mapkit/content/platform.py +314 -0
- ff9mapkit/content/player.py +168 -0
- ff9mapkit/content/prop.py +75 -0
- ff9mapkit/content/region.py +340 -0
- ff9mapkit/content/reinit.py +59 -0
- ff9mapkit/content/savepoint.py +90 -0
- ff9mapkit/content/shop.py +178 -0
- ff9mapkit/content/sps_trigger.py +66 -0
- ff9mapkit/content/startup.py +71 -0
- ff9mapkit/content/synthesis.py +106 -0
- ff9mapkit/content/text.py +183 -0
- ff9mapkit/content/textcarry.py +290 -0
- ff9mapkit/content/verbatim.py +86 -0
- ff9mapkit/content/walkmesh_hotfix.py +38 -0
- ff9mapkit/data/__init__.py +48 -0
- ff9mapkit/data/_regen_provenance.py +142 -0
- ff9mapkit/data/provenance/blank.es.patch +1 -0
- ff9mapkit/data/provenance/blank.fr.patch +1 -0
- ff9mapkit/data/provenance/blank.gr.patch +1 -0
- ff9mapkit/data/provenance/blank.it.patch +1 -0
- ff9mapkit/data/provenance/blank.jp.patch +1 -0
- ff9mapkit/data/provenance/blank.uk.patch +1 -0
- ff9mapkit/data/provenance/blank.us.patch +1 -0
- ff9mapkit/data/provenance/manifest.json +65 -0
- ff9mapkit/data/provenance/region_template.patch +1 -0
- ff9mapkit/data/reference_arcs.toml +89 -0
- ff9mapkit/data/region_catalog.toml +593 -0
- ff9mapkit/deploystack.py +358 -0
- ff9mapkit/dialogue.py +803 -0
- ff9mapkit/eb/__init__.py +12 -0
- ff9mapkit/eb/_exprtable.py +59 -0
- ff9mapkit/eb/_membertable.py +38 -0
- ff9mapkit/eb/_optables.py +537 -0
- ff9mapkit/eb/_regen_optables.py +76 -0
- ff9mapkit/eb/cmdasm.py +323 -0
- ff9mapkit/eb/disasm.py +332 -0
- ff9mapkit/eb/edit.py +439 -0
- ff9mapkit/eb/exprasm.py +158 -0
- ff9mapkit/eb/model.py +178 -0
- ff9mapkit/eb/opcodes.py +463 -0
- ff9mapkit/eblint.py +177 -0
- ff9mapkit/editor/__init__.py +20 -0
- ff9mapkit/editor/app.py +950 -0
- ff9mapkit/editor/battle_forms.py +240 -0
- ff9mapkit/editor/breadcrumb.py +89 -0
- ff9mapkit/editor/dialogs.py +116 -0
- ff9mapkit/editor/feedback.py +208 -0
- ff9mapkit/editor/forms.py +632 -0
- ff9mapkit/editor/graphview.py +350 -0
- ff9mapkit/editor/jobs.py +342 -0
- ff9mapkit/editor/model.py +243 -0
- ff9mapkit/editor/picker.py +120 -0
- ff9mapkit/editor/theme.py +212 -0
- ff9mapkit/eventscan.py +1441 -0
- ff9mapkit/extract.py +2279 -0
- ff9mapkit/flags.py +693 -0
- ff9mapkit/forkreport.py +1383 -0
- ff9mapkit/hub.py +477 -0
- ff9mapkit/idgated.py +101 -0
- ff9mapkit/infohub.py +580 -0
- ff9mapkit/items.py +63 -0
- ff9mapkit/itemstats.py +346 -0
- ff9mapkit/journey.py +1902 -0
- ff9mapkit/keyitems.py +93 -0
- ff9mapkit/logic_add.py +632 -0
- ff9mapkit/logic_edit.py +728 -0
- ff9mapkit/logic_map.py +526 -0
- ff9mapkit/pack.py +175 -0
- ff9mapkit/playerswap.py +231 -0
- ff9mapkit/prop_archetypes.py +228 -0
- ff9mapkit/provision.py +282 -0
- ff9mapkit/refarc.py +825 -0
- ff9mapkit/save.py +337 -0
- ff9mapkit/save_items.py +1673 -0
- ff9mapkit/scene/__init__.py +11 -0
- ff9mapkit/scene/arena.py +63 -0
- ff9mapkit/scene/bgart.py +140 -0
- ff9mapkit/scene/bgi.py +732 -0
- ff9mapkit/scene/bgs.py +174 -0
- ff9mapkit/scene/bgx.py +185 -0
- ff9mapkit/scene/cam.py +345 -0
- ff9mapkit/scene/guide.py +311 -0
- ff9mapkit/scene/paint.py +506 -0
- ff9mapkit/scene/placeholder.py +107 -0
- ff9mapkit/sjbinary.py +285 -0
- ff9mapkit/sps/__init__.py +17 -0
- ff9mapkit/sps/author.py +294 -0
- ff9mapkit/sps/catalog.py +88 -0
- ff9mapkit/sps/codec.py +264 -0
- ff9mapkit/sps/edit.py +184 -0
- ff9mapkit/sps/lint.py +58 -0
- ff9mapkit/sps/render.py +116 -0
- ff9mapkit/sps/templates.py +47 -0
- ff9mapkit/sps/texture.py +131 -0
- ff9mapkit/walkmesh_hotfixes.py +163 -0
- ff9mapkit/workspace/__init__.py +18 -0
- ff9mapkit/workspace/battledoc.py +985 -0
- ff9mapkit/workspace/builddoc.py +607 -0
- ff9mapkit/workspace/forms_qt.py +586 -0
- ff9mapkit/workspace/importdoc.py +665 -0
- ff9mapkit/workspace/mapview.py +131 -0
- ff9mapkit/workspace/palette.py +85 -0
- ff9mapkit/workspace/savedoc.py +664 -0
- ff9mapkit/workspace/shell.py +6907 -0
- ff9mapkit/workspace/style.py +105 -0
- ff9mapkit/workspace/tuningdialog.py +223 -0
- ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
- ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
- ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
- ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
- ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
- ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
ff9mapkit/battle/fbx.py
ADDED
|
@@ -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
|