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/pack.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Field-ID allocation + mod packaging + project scaffolding.
|
|
2
|
+
|
|
3
|
+
Custom field IDs and FBG names form a shared namespace across all installed mods, so two
|
|
4
|
+
independently-authored mods must not collide. There is no central registry, so the convention
|
|
5
|
+
is: custom fields use ids >= :data:`CUSTOM_ID_MIN`, and a mod claims a contiguous *block*.
|
|
6
|
+
:func:`suggest_base` derives a deterministic per-mod block from the mod name (reducing the odds
|
|
7
|
+
of an accidental clash); for a public release, coordinate the block with the community.
|
|
8
|
+
|
|
9
|
+
:func:`pack_mod` zips a built mod for distribution; :func:`new_project` scaffolds a fresh
|
|
10
|
+
``field.toml`` project directory.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import zipfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
CUSTOM_ID_MIN = 4000 # shipped-content custom field ids start here (below this is base-game territory)
|
|
20
|
+
CUSTOM_ID_MAX = 9899 # ...content blocks live in [CUSTOM_ID_MIN..CUSTOM_ID_MAX]
|
|
21
|
+
BLOCK_SIZE = 100 # ids per mod block
|
|
22
|
+
# HARD engine cap: the live current-field id (engine `FF9StateSystem.Common.FF9.fldMapNo`) is Int16, so a
|
|
23
|
+
# field id above FIELD_ID_MAX overflows and is unreachable -- the DictionaryPatch *key* is Int32, but the
|
|
24
|
+
# runtime current-field variable is 16-bit (FF9Snd.cs:2651), so you can register a higher id yet never
|
|
25
|
+
# navigate to it. Ephemeral dev/test "scratch" slots (per-worktree, gitignored .ff9deploy.toml) sit in
|
|
26
|
+
# [SCRATCH_ID_MIN..SCRATCH_ID_MAX] -- the top of the valid range, clearly ABOVE shipped content.
|
|
27
|
+
FIELD_ID_MAX = 32767
|
|
28
|
+
SCRATCH_ID_MIN = 30000
|
|
29
|
+
SCRATCH_ID_MAX = FIELD_ID_MAX
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def suggest_base(mod_name: str) -> int:
|
|
33
|
+
"""A deterministic custom-field-id block base for a mod name (e.g. 4300).
|
|
34
|
+
|
|
35
|
+
Hashes the name into one of the 100-id blocks in [CUSTOM_ID_MIN, CUSTOM_ID_MAX]. Two
|
|
36
|
+
different names usually land in different blocks; collisions should be resolved by hand for
|
|
37
|
+
a public release.
|
|
38
|
+
"""
|
|
39
|
+
n_blocks = (CUSTOM_ID_MAX - CUSTOM_ID_MIN) // BLOCK_SIZE
|
|
40
|
+
h = int.from_bytes(hashlib.sha1(mod_name.encode("utf-8")).digest()[:4], "big")
|
|
41
|
+
return CUSTOM_ID_MIN + (h % n_blocks) * BLOCK_SIZE
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def suggest_ids(base: int, count: int) -> list[int]:
|
|
45
|
+
"""``count`` consecutive ids from ``base`` (validated to stay in the custom range)."""
|
|
46
|
+
if base < CUSTOM_ID_MIN or base + count - 1 > CUSTOM_ID_MAX:
|
|
47
|
+
raise ValueError(f"id block [{base}..{base + count - 1}] outside custom range "
|
|
48
|
+
f"[{CUSTOM_ID_MIN}..{CUSTOM_ID_MAX}]")
|
|
49
|
+
return [base + i for i in range(count)]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def pack_mod(mod_root, out_path) -> Path:
|
|
53
|
+
"""Zip a built mod folder for distribution. Returns the zip path.
|
|
54
|
+
|
|
55
|
+
The archive contains the mod folder itself (so unzipping next to FF9_Launcher.exe installs
|
|
56
|
+
it). Skips ``*.bak`` and editor leftovers.
|
|
57
|
+
"""
|
|
58
|
+
mod_root = Path(mod_root).resolve()
|
|
59
|
+
if not mod_root.is_dir():
|
|
60
|
+
raise FileNotFoundError(mod_root)
|
|
61
|
+
out_path = Path(out_path)
|
|
62
|
+
with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
63
|
+
for p in sorted(mod_root.rglob("*")):
|
|
64
|
+
if p.is_dir() or p.suffix in (".bak",) or p.name.endswith(".prefix.bak"):
|
|
65
|
+
continue
|
|
66
|
+
zf.write(p, arcname=str(Path(mod_root.name) / p.relative_to(mod_root)))
|
|
67
|
+
return out_path
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_FIELD_TOML_TEMPLATE = '''\
|
|
71
|
+
# {title} — a custom FF9 field. Compile with: ff9mapkit build {fname}
|
|
72
|
+
#
|
|
73
|
+
# Human-supplied (Hard Constraint): paint the background layer PNGs and (optionally) model the
|
|
74
|
+
# walkmesh in Blender. `ff9mapkit guide --pitch {pitch} ...` prints where the floor lands on
|
|
75
|
+
# the painted canvas so you can paint to match.
|
|
76
|
+
|
|
77
|
+
[field]
|
|
78
|
+
id = {field_id} # custom field id (>= 4000; claim a block for your mod, see docs)
|
|
79
|
+
name = "{name}" # -> FBG_N{area}_{name} (background) + EVT_{name}.eb (script)
|
|
80
|
+
area = {area} # must be >= 10
|
|
81
|
+
text_block = 1073
|
|
82
|
+
title = "{title}"
|
|
83
|
+
|
|
84
|
+
[camera]
|
|
85
|
+
pitch = {pitch} # downward tilt (degrees); real FF9 fields are <= ~48
|
|
86
|
+
distance = 4500
|
|
87
|
+
fov = 42.2
|
|
88
|
+
[camera.frame]
|
|
89
|
+
back = 205 # painted-canvas rows the floor's back/front edges sit on
|
|
90
|
+
front = 432
|
|
91
|
+
|
|
92
|
+
[walkmesh]
|
|
93
|
+
# The walkmesh is in TRUE world coords = where the painted floor is (frame = "world"): the player
|
|
94
|
+
# renders exactly on it, no fudge (measured in-game, Session 18). `quad` = the floor's 4 corners,
|
|
95
|
+
# matching the placeholder floor below. Swap for an exported Blender mesh with `obj = "walkmesh.obj"`.
|
|
96
|
+
quad = {quad}
|
|
97
|
+
frame = "world"
|
|
98
|
+
|
|
99
|
+
[[layers]] # background layers, back-to-front (z = depth; smaller = nearer)
|
|
100
|
+
image = "art/back.png" # PLACEHOLDER (solid) -- repaint to match your camera
|
|
101
|
+
z = 4000
|
|
102
|
+
[[layers]]
|
|
103
|
+
image = "art/floor.png" # PLACEHOLDER (checkerboard floor) -- repaint to match your camera
|
|
104
|
+
z = 3000
|
|
105
|
+
|
|
106
|
+
[player]
|
|
107
|
+
spawn = [0, {spawn_z}]
|
|
108
|
+
|
|
109
|
+
# [[npc]]
|
|
110
|
+
# name = "Someone"
|
|
111
|
+
# preset = "vivi"
|
|
112
|
+
# pos = [0, -700]
|
|
113
|
+
# dialogue = "Hello there."
|
|
114
|
+
|
|
115
|
+
# [[gateway]]
|
|
116
|
+
# to = 100 # warp to this field id
|
|
117
|
+
# entrance = 0
|
|
118
|
+
# zone = [[-1100, -2400], [1100, -2400], [1100, -1750], [-1100, -1750]]
|
|
119
|
+
'''
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
_DISTANCE, _FOV, _BACK, _FRONT = 4500.0, 42.2, 205, 432 # template camera defaults (match the toml)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def new_project(name: str, dest, *, field_id: int | None = None, area: int = 11,
|
|
126
|
+
pitch: float = 48.0, title: str | None = None, placeholder_art: bool = True) -> Path:
|
|
127
|
+
"""Scaffold a new field project under ``dest/<name>/``. Returns the project dir.
|
|
128
|
+
|
|
129
|
+
With ``placeholder_art`` (default), derives the walkmesh quad from the template camera frame and
|
|
130
|
+
writes PLACEHOLDER ``art/back.png`` + ``art/floor.png`` (pure stdlib, no PIL) so the project
|
|
131
|
+
BUILDS and is walkable straight away -- the human then replaces the art with painted layers. If
|
|
132
|
+
the camera frame can't be solved (an extreme ``pitch``), falls back to a no-art scaffold.
|
|
133
|
+
"""
|
|
134
|
+
title = title or name
|
|
135
|
+
if field_id is None:
|
|
136
|
+
field_id = suggest_base(name)
|
|
137
|
+
proj_dir = Path(dest) / name
|
|
138
|
+
(proj_dir / "art").mkdir(parents=True, exist_ok=True)
|
|
139
|
+
fname = f"{name.lower()}.field.toml"
|
|
140
|
+
|
|
141
|
+
quad = [[-1400, -2400], [1400, -2400], [1400, -800], [-1400, -800]] # fallback
|
|
142
|
+
spawn_z = -1350
|
|
143
|
+
wrote_art = False
|
|
144
|
+
if placeholder_art:
|
|
145
|
+
try:
|
|
146
|
+
from .scene import guide, placeholder
|
|
147
|
+
camera = guide.make_camera(pitch, _DISTANCE, fov_x_deg=_FOV)
|
|
148
|
+
frame = guide.frame_floor(camera, back_canvas_y=_BACK, front_canvas_y=_FRONT)
|
|
149
|
+
quad = [[int(x), int(z)] for (x, z) in guide.walkmesh_corners(frame)]
|
|
150
|
+
spawn_z = int(round((frame.zb + frame.zf) / 2))
|
|
151
|
+
placeholder.write_placeholders(camera, frame, proj_dir / "art" / "back.png",
|
|
152
|
+
proj_dir / "art" / "floor.png")
|
|
153
|
+
wrote_art = True
|
|
154
|
+
except (ValueError, ZeroDivisionError):
|
|
155
|
+
pass # extreme camera -> keep the fallback quad + no placeholder art
|
|
156
|
+
|
|
157
|
+
quad_toml = "[" + ", ".join(f"[{x}, {z}]" for x, z in quad) + "]"
|
|
158
|
+
(proj_dir / fname).write_text(
|
|
159
|
+
_FIELD_TOML_TEMPLATE.format(name=name, area=area, field_id=field_id, pitch=pitch,
|
|
160
|
+
title=title, fname=fname, quad=quad_toml, spawn_z=spawn_z),
|
|
161
|
+
encoding="utf-8", newline="\n")
|
|
162
|
+
if wrote_art:
|
|
163
|
+
readme = (
|
|
164
|
+
"PLACEHOLDER background art (back.png = solid slate, floor.png = perspective checkerboard)\n"
|
|
165
|
+
"was generated by `ff9mapkit new` so the project builds + is walkable right away.\n"
|
|
166
|
+
"REPLACE back.png and floor.png with your painted layers (same size/aspect).\n\n"
|
|
167
|
+
"Run ff9mapkit guide --pitch <p> --png guide.png for a paint guide showing exactly\n"
|
|
168
|
+
"where the floor + its edges land on the canvas for your camera.\n")
|
|
169
|
+
else:
|
|
170
|
+
readme = (
|
|
171
|
+
"Place your painted background layer PNGs here (back.png, floor.png, ...).\n"
|
|
172
|
+
"Run ff9mapkit guide --pitch <p> --png guide.png to get a paint guide that shows\n"
|
|
173
|
+
"exactly where the floor and its edges land on the canvas for your camera.\n")
|
|
174
|
+
(proj_dir / "art" / "README.txt").write_text(readme, encoding="utf-8", newline="\n")
|
|
175
|
+
return proj_dir
|
ff9mapkit/playerswap.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Swap who you WALK as in a forked field -- patch the player entry's ``SetModel`` + movement anim ids to a
|
|
2
|
+
different rig: one of the 8 PLAYABLES (a proven home-field table) or ANY registered model -- a moogle, an NPC,
|
|
3
|
+
a creature -- resolved through the kit's model->animation join (:func:`catalog.npc_anims`). The latter is the
|
|
4
|
+
field-side bridge toward CUSTOM characters: a custom model would be driven by exactly this path.
|
|
5
|
+
|
|
6
|
+
Field control (who you walk as) is DECOUPLED from party state (the menu/battle roster), so this changes only
|
|
7
|
+
the field-controlled character; the party is untouched (use a party-membership op for that). It is the
|
|
8
|
+
productionized form of the in-game-proven Tier-A probe: a same-length, width-aware byte patch of the player
|
|
9
|
+
entry's tag-0 Init -- ``.eb``-only, no DLL (memory ``project-ff9-pc-party-system`` / ``project-ff9-non-zidane-donors``).
|
|
10
|
+
|
|
11
|
+
:data:`CHARACTERS` holds each playable's canonical field player-Init values (model id + eye-height + the
|
|
12
|
+
movement clips), EXTRACTED from that character's own home field. The animation ids are rig-partitioned -- the
|
|
13
|
+
engine does NOT translate a Zidane clip id onto another rig -- so every movement clip the field's player Init
|
|
14
|
+
sets must be repointed to the target rig (else a wrong-skeleton anim / T-pose).
|
|
15
|
+
|
|
16
|
+
CAVEAT -- free-roam vs cutscene fields: this swaps only the 6 MOVEMENT clips (idle/walk/run/turns), which is
|
|
17
|
+
everything a free-roam field needs (proven clean: walk Quina/Steiner around the Hangar). But a STORY-EVENT
|
|
18
|
+
field can make the PLAYER play scripted GESTURES in a cutscene via ``RunAnimation`` (0x40) with a specific
|
|
19
|
+
clip id -- that id is NOT swapped, so it would try to play the ORIGINAL rig's clip on the new model and
|
|
20
|
+
glitch/T-pose. So ``--swap-player`` is clean on free-roam fields and cosmetic-and-risky on cutscene-heavy
|
|
21
|
+
ones; :func:`scripted_gesture_ops` flags that risk. For STORY fidelity (be a character THROUGH the story),
|
|
22
|
+
the right tool is a verbatim fork at the right beat with the right party, not a model swap.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from .eb import EbScript
|
|
27
|
+
from .eb.disasm import argsize
|
|
28
|
+
|
|
29
|
+
SETMODEL_OP = 0x2F
|
|
30
|
+
ANIM_OPS = {0x33: "idle", 0x34: "walk", 0x35: "run", 0x7A: "left", 0x7B: "right", 0x52: "inactive"}
|
|
31
|
+
RUN_ANIM_OPS = frozenset({0x40, 0xBD}) # RunAnimation / RunAnimationEx -- scripted gesture plays (rig-specific)
|
|
32
|
+
ZIDANE_LEADER_MODELS = frozenset({98, 532}) # the controllable Zidane FIELD forms (ZDN + ZDD disguise)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NoSwappablePlayer(ValueError):
|
|
36
|
+
"""No player entry has a ``SetModel`` to swap (a benign per-member skip in a chain). A subclass of
|
|
37
|
+
ValueError so existing handlers still catch it, but distinguishable from a real patch/corruption error."""
|
|
38
|
+
|
|
39
|
+
# Canonical field player-Init values per playable, read from each character's home field (model, eye-height,
|
|
40
|
+
# movement clips). idle/walk/run/left/right exist for all 8; ``inactive`` (the idle-break) only where the home
|
|
41
|
+
# field set a generic one -- :func:`swap_player` falls back to ``idle`` for a clip the target lacks (a valid
|
|
42
|
+
# clip on that rig). Verified in-game for Steiner (the Tier-A probe).
|
|
43
|
+
CHARACTERS = {
|
|
44
|
+
"zidane": {"model": 98, "eye": 93, "idle": 200, "walk": 25, "run": 38, "left": 40, "right": 41, "inactive": 57},
|
|
45
|
+
"vivi": {"model": 8, "eye": 61, "idle": 148, "walk": 571, "run": 419, "left": 917, "right": 918, "inactive": 912},
|
|
46
|
+
"steiner": {"model": 5489, "eye": 104, "idle": 2001, "walk": 1996, "run": 2005, "left": 1986, "right": 2010, "inactive": 119},
|
|
47
|
+
"garnet": {"model": 185, "eye": 91, "idle": 2089, "walk": 2086, "run": 2091, "left": 2088, "right": 2084},
|
|
48
|
+
"freya": {"model": 192, "eye": 94, "idle": 2556, "walk": 2553, "run": 2558, "left": 2555, "right": 2551},
|
|
49
|
+
"quina": {"model": 273, "eye": 92, "idle": 3228, "walk": 3237, "run": 3230, "left": 3235, "right": 3227},
|
|
50
|
+
"eiko": {"model": 443, "eye": 63, "idle": 7503, "walk": 7518, "run": 7506, "left": 7516, "right": 7514},
|
|
51
|
+
"amarant": {"model": 509, "eye": 122, "idle": 8307, "walk": 8316, "run": 8312, "left": 8310, "right": 8314},
|
|
52
|
+
}
|
|
53
|
+
ALIASES = {"dagger": "garnet", "salamander": "amarant"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_char(name):
|
|
57
|
+
"""``(canonical_name, spec)`` for a swap target. A playable name (zidane..amarant; aliases ``dagger``->
|
|
58
|
+
garnet, ``salamander``->amarant) returns its proven home-field rig table. ANY OTHER registered model -- a
|
|
59
|
+
``GEO_..`` name or a numeric id (a moogle, an NPC, a creature) -- returns a spec built from the kit's
|
|
60
|
+
model->animation join (:func:`catalog.npc_anims`), so you can walk as it: the field-side bridge to custom
|
|
61
|
+
characters (a custom model would use the same path). ``spec`` always has ``model`` + the movement clips;
|
|
62
|
+
a playable also carries ``eye`` + ``inactive``. Raises ``ValueError`` on an unknown target or a model with
|
|
63
|
+
no movement animations (e.g. a static monster)."""
|
|
64
|
+
k = ALIASES.get(str(name).lower().strip(), str(name).lower().strip())
|
|
65
|
+
if k in CHARACTERS:
|
|
66
|
+
return k, CHARACTERS[k]
|
|
67
|
+
from . import catalog
|
|
68
|
+
try:
|
|
69
|
+
mid = catalog.resolve_model(name)
|
|
70
|
+
except Exception:
|
|
71
|
+
raise ValueError("unknown swap target %r -- a playable (%s; aliases dagger, salamander) or a model "
|
|
72
|
+
"name/id (see `ff9mapkit models`)" % (name, ", ".join(sorted(CHARACTERS))))
|
|
73
|
+
na = catalog.npc_anims(mid) # {stand,walk,run,left,right} via the model->anim join
|
|
74
|
+
if not na:
|
|
75
|
+
from ._modeldb import MODELS
|
|
76
|
+
raise ValueError("model %s has no movement animations -- can't walk as it"
|
|
77
|
+
% MODELS.get(mid, mid))
|
|
78
|
+
from ._modeldb import MODELS
|
|
79
|
+
spec = {"model": mid, "idle": na["stand"], "walk": na["walk"], "run": na["run"],
|
|
80
|
+
"left": na["left"], "right": na["right"]} # no 'eye' (keep the field's) / no 'inactive' (-> idle)
|
|
81
|
+
return MODELS.get(mid, str(mid)), spec
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _arg_off(ins, ai):
|
|
85
|
+
"""Byte offset (relative to ``ins.off``) of literal operand ``ai`` -- opcode head + the argflag byte
|
|
86
|
+
(ops >= 0x10 with args) + the widths of the preceding operands."""
|
|
87
|
+
off = 2 if ins.op >= 0x100 else 1
|
|
88
|
+
if ins.op >= 0x10 and len(ins.args) != 0:
|
|
89
|
+
off += 1
|
|
90
|
+
for k in range(ai):
|
|
91
|
+
off += argsize(ins.op, k)
|
|
92
|
+
return off
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _put_arg(out: bytearray, ins, ai: int, value: int) -> None:
|
|
96
|
+
"""Overwrite literal operand ``ai`` of ``ins`` in ``out`` with ``value`` (little-endian, same width).
|
|
97
|
+
Raises ``ValueError`` if ``value`` doesn't fit the operand's byte width. Shared by swap_player +
|
|
98
|
+
neutralize_gestures -- a same-width in-place patch never shifts offsets."""
|
|
99
|
+
w = argsize(ins.op, ai)
|
|
100
|
+
if int(value) >= (1 << (8 * w)):
|
|
101
|
+
raise ValueError("value %d does not fit arg %d (%d byte(s)) of op %#x" % (value, ai, w, ins.op))
|
|
102
|
+
o = ins.off + _arg_off(ins, ai)
|
|
103
|
+
out[o:o + w] = int(value).to_bytes(w, "little")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _has_setmodel(eb, p):
|
|
107
|
+
init = eb.entry(p).func_by_tag(0)
|
|
108
|
+
return init is not None and any(i.op == SETMODEL_OP for i in eb.instrs(init))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def leader_model(eb):
|
|
112
|
+
"""The model of the character you actually CONTROL -- the swap target. On a ZIDANE-PRESENT field control
|
|
113
|
+
routes through the party SLOT to the Zidane party-leader, NOT the last-``DefinePlayerCharacter`` binder
|
|
114
|
+
(:func:`forkreport.controlled_player` mispredicts there -- it returns a co-actor on 66/169 such fields), so
|
|
115
|
+
target a Zidane field form (98/532) when one is defined. On a no-Zidane FIXED-SID field, use the proven
|
|
116
|
+
binder (Treno -> Garnet). Single-PC -> the one. Returns the model id, or ``None`` if no swappable entry."""
|
|
117
|
+
from . import eventscan, forkreport
|
|
118
|
+
pents = eventscan.resolve_player_entries(eb)
|
|
119
|
+
have = {p: eventscan._player_model(eb, p) for p in pents
|
|
120
|
+
if eventscan._player_model(eb, p) is not None and _has_setmodel(eb, p)}
|
|
121
|
+
if not have:
|
|
122
|
+
return None
|
|
123
|
+
zid = [m for m in have.values() if m in ZIDANE_LEADER_MODELS]
|
|
124
|
+
if zid:
|
|
125
|
+
return zid[0] # Zidane-present -> you control the Zidane party-leader
|
|
126
|
+
ctrl = forkreport.controlled_player(eb)[0] # no Zidane -> the proven last-0x2C binder
|
|
127
|
+
return have.get(ctrl, next(iter(have.values())))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def swap_targets(eb):
|
|
131
|
+
"""The player entries the swap patches = ALL entries whose Init ``SetModel`` == the controlled-leader model
|
|
132
|
+
(so a Zidane-present field hits the real leader -- not a companion -- and a duplicate leader entry is handled
|
|
133
|
+
too). Empty when no swappable entry exists."""
|
|
134
|
+
from . import eventscan
|
|
135
|
+
m = leader_model(eb)
|
|
136
|
+
if m is None:
|
|
137
|
+
return []
|
|
138
|
+
return [p for p in eventscan.resolve_player_entries(eb)
|
|
139
|
+
if eventscan._player_model(eb, p) == m and _has_setmodel(eb, p)]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _targets(eb, entry):
|
|
143
|
+
"""Normalize the ``entry`` override to a list of player-entry indices: ``None`` -> :func:`swap_targets`,
|
|
144
|
+
a single int -> ``[int]``, an iterable -> ``list(it)``. Lets a caller PIN the target set computed on the
|
|
145
|
+
ORIGINAL bytes: ``swap_targets``/``leader_model`` key on the Init ``SetModel`` id, which ``swap_player``
|
|
146
|
+
MUTATES, so re-deriving the targets on the swapped bytes would drift to a different entry on a
|
|
147
|
+
Zidane-present multi-PC field (the gesture-neutralize-on-the-wrong-actor bug)."""
|
|
148
|
+
if entry is None:
|
|
149
|
+
return swap_targets(eb)
|
|
150
|
+
if isinstance(entry, int):
|
|
151
|
+
return [entry]
|
|
152
|
+
return list(entry)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def scripted_gesture_ops(eb_bytes, *, entry=None) -> int:
|
|
156
|
+
"""How many scripted-gesture ops (``RunAnimation``/``RunAnimationEx``) the player entry plays. These
|
|
157
|
+
reference the ORIGINAL rig's clips (the swap only repoints the 6 movement clips), so any count > 0 means
|
|
158
|
+
``--swap-player`` will glitch those gestures on the new model -- i.e. the field is a cutscene-heavy one
|
|
159
|
+
where the swap is cosmetic-and-risky, not a clean free-roam swap. Used to WARN at swap time."""
|
|
160
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
161
|
+
targets = _targets(eb, entry)
|
|
162
|
+
return sum(1 for e in targets for f in eb.entry(e).funcs for i in eb.instrs(f) if i.op in RUN_ANIM_OPS)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def swap_player(eb_bytes, char, *, entry=None) -> bytes:
|
|
166
|
+
"""Patch the controlled-leader player entr(ies)' Init ``SetModel`` + movement anim ids to ``char``'s rig
|
|
167
|
+
(same-length, width-aware). ``entry`` overrides the target; otherwise ALL :func:`swap_targets` (every entry
|
|
168
|
+
matching the controlled-leader model) are patched. Returns new ``.eb`` bytes (round-trip-checked). Raises
|
|
169
|
+
:class:`NoSwappablePlayer` when no player entry has a ``SetModel``, ``ValueError`` on an unknown char or a
|
|
170
|
+
patch that would overflow / corrupt the script."""
|
|
171
|
+
_name, spec = resolve_char(char)
|
|
172
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
173
|
+
targets = _targets(eb, entry)
|
|
174
|
+
if not targets:
|
|
175
|
+
raise NoSwappablePlayer("no player entry with a SetModel to swap")
|
|
176
|
+
out = bytearray(eb_bytes)
|
|
177
|
+
for tgt in targets:
|
|
178
|
+
for ins in eb.instrs(eb.entry(tgt).func_by_tag(0)):
|
|
179
|
+
if any(ins.arg_is_expr):
|
|
180
|
+
continue
|
|
181
|
+
if ins.op == SETMODEL_OP and ins.args:
|
|
182
|
+
_put_arg(out, ins, 0, spec["model"])
|
|
183
|
+
if "eye" in spec and len(ins.args) >= 2: # playables carry an eye-height; an arbitrary
|
|
184
|
+
_put_arg(out, ins, 1, spec["eye"]) # model keeps the field's (cosmetic dialog anchor)
|
|
185
|
+
elif ins.op in ANIM_OPS and ins.args:
|
|
186
|
+
_put_arg(out, ins, 0, spec.get(ANIM_OPS[ins.op], spec["idle"]))
|
|
187
|
+
|
|
188
|
+
out = bytes(out)
|
|
189
|
+
if EbScript.from_bytes(out).to_bytes() != out: # the patch must not corrupt the structure
|
|
190
|
+
raise ValueError("player swap produced a non-round-tripping .eb")
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def neutralize_gestures(eb_bytes, char, *, entry=None) -> bytes:
|
|
195
|
+
"""Make a SWAPPED player stand/idle cleanly through a cutscene instead of T-posing on its foreign gesture
|
|
196
|
+
clips. On every swap-target player entry (ALL funcs -- gestures live in the LOOP/talk funcs, never Init), it
|
|
197
|
+
rewrites each scripted ``RunAnimation`` (0x40) clip operand to the swapped rig's OWN idle clip, and repoints
|
|
198
|
+
any LOOP movement re-sets (``ANIM_OPS``: SetStand/Walk/... ) to the rig's matching clips. The paired
|
|
199
|
+
``WaitAnimation``/``Wait``/``SetAnimationFlags`` are LEFT INTACT -- the rig's idle is an already-loaded valid
|
|
200
|
+
clip, so the wait completes normally (no hang; the donor clip would load a foreign-skeleton clip = the glitch).
|
|
201
|
+
|
|
202
|
+
Engine-grounded (Memoria ``DoEventCode``/``ProcessAnime``): RunAnimation is NAME-keyed via a global clip dict,
|
|
203
|
+
so the substitute idle (loaded by ``--swap-player``'s SetStandAnimation) gives a real frame count and clears
|
|
204
|
+
``afExec`` at clip end. Pair with :func:`swap_player` (SAME char); run it on the swapped bytes. A same-width
|
|
205
|
+
2-byte patch (round-trip-checked). Raises :class:`NoSwappablePlayer` / ``ValueError`` like swap_player.
|
|
206
|
+
|
|
207
|
+
NOTE on ``RunAnimationEx`` (0xBD): it never targets the player in the surveyed corpus (its clip arg is at
|
|
208
|
+
index 1 with a separate object arg), so to avoid rewriting a foreign object's animation it is NOT touched;
|
|
209
|
+
the gesture WARN still flags it if a field ever has one."""
|
|
210
|
+
from . import eventscan
|
|
211
|
+
_name, spec = resolve_char(char)
|
|
212
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
213
|
+
targets = _targets(eb, entry)
|
|
214
|
+
if not targets:
|
|
215
|
+
raise NoSwappablePlayer("no player entry with a SetModel to neutralize gestures on")
|
|
216
|
+
out = bytearray(eb_bytes)
|
|
217
|
+
for tgt in targets:
|
|
218
|
+
if eventscan._player_model(eb, tgt) != spec["model"]: # only an entry actually swapped to `char`:
|
|
219
|
+
continue # never rewrite a foreign rig's gestures (drift/misuse guard)
|
|
220
|
+
for f in eb.entry(tgt).funcs:
|
|
221
|
+
for ins in eb.instrs(f):
|
|
222
|
+
if any(ins.arg_is_expr) or not ins.args:
|
|
223
|
+
continue
|
|
224
|
+
if ins.op == 0x40: # RunAnimation: clip arg 0 -> the rig's idle
|
|
225
|
+
_put_arg(out, ins, 0, spec["idle"])
|
|
226
|
+
elif ins.op in ANIM_OPS: # a LOOP movement re-set -> the rig's matching clip
|
|
227
|
+
_put_arg(out, ins, 0, spec.get(ANIM_OPS[ins.op], spec["idle"]))
|
|
228
|
+
out = bytes(out)
|
|
229
|
+
if EbScript.from_bytes(out).to_bytes() != out:
|
|
230
|
+
raise ValueError("gesture neutralize produced a non-round-tripping .eb")
|
|
231
|
+
return out
|