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
|
@@ -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
|
+
'''
|