ff9mapkit 1.0.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. ff9mapkit/__init__.py +18 -0
  2. ff9mapkit/__main__.py +36 -0
  3. ff9mapkit/_animdb.py +2994 -0
  4. ff9mapkit/_animdb_all.py +14125 -0
  5. ff9mapkit/_fieldtable.py +1516 -0
  6. ff9mapkit/_fieldtext.py +845 -0
  7. ff9mapkit/_held_poses.py +44 -0
  8. ff9mapkit/_itemdb.py +65 -0
  9. ff9mapkit/_modeldb.py +725 -0
  10. ff9mapkit/_narrowmap_data.py +10 -0
  11. ff9mapkit/_npcparams.py +634 -0
  12. ff9mapkit/_regen_animdb.py +72 -0
  13. ff9mapkit/_regen_animdb_all.py +66 -0
  14. ff9mapkit/_regen_fieldtable.py +95 -0
  15. ff9mapkit/_regen_fieldtext.py +66 -0
  16. ff9mapkit/_regen_modeldb.py +67 -0
  17. ff9mapkit/_regen_npcparams.py +123 -0
  18. ff9mapkit/_regen_scenedb.py +57 -0
  19. ff9mapkit/_scenedb.py +869 -0
  20. ff9mapkit/abilities.py +225 -0
  21. ff9mapkit/animations.py +120 -0
  22. ff9mapkit/archetypes.py +218 -0
  23. ff9mapkit/areatitle.py +76 -0
  24. ff9mapkit/battle/__init__.py +21 -0
  25. ff9mapkit/battle/abilityfeatures.py +294 -0
  26. ff9mapkit/battle/actiondelta.py +441 -0
  27. ff9mapkit/battle/aiauthor.py +305 -0
  28. ff9mapkit/battle/ailint.py +140 -0
  29. ff9mapkit/battle/aipatch.py +175 -0
  30. ff9mapkit/battle/battleai.py +148 -0
  31. ff9mapkit/battle/battlecsv.py +390 -0
  32. ff9mapkit/battle/battlepatch.py +395 -0
  33. ff9mapkit/battle/build.py +558 -0
  34. ff9mapkit/battle/camera_codec.py +332 -0
  35. ff9mapkit/battle/camera_data.py +128 -0
  36. ff9mapkit/battle/characterdelta.py +789 -0
  37. ff9mapkit/battle/event_data.py +72 -0
  38. ff9mapkit/battle/extract.py +540 -0
  39. ff9mapkit/battle/fbx.py +223 -0
  40. ff9mapkit/battle/reskin.py +149 -0
  41. ff9mapkit/battle/scene_codec.py +314 -0
  42. ff9mapkit/battle/scene_data.py +369 -0
  43. ff9mapkit/battle/scenelint.py +125 -0
  44. ff9mapkit/battle/seqasm.py +131 -0
  45. ff9mapkit/battle/seqauthor.py +220 -0
  46. ff9mapkit/battle/seqcodec.py +300 -0
  47. ff9mapkit/battle/seqdis.py +106 -0
  48. ff9mapkit/battle/seqpatch.py +137 -0
  49. ff9mapkit/battle_bgm.py +133 -0
  50. ff9mapkit/binutils.py +60 -0
  51. ff9mapkit/build.py +5445 -0
  52. ff9mapkit/campaign.py +1276 -0
  53. ff9mapkit/catalog.py +316 -0
  54. ff9mapkit/chain.py +358 -0
  55. ff9mapkit/cli.py +3114 -0
  56. ff9mapkit/config.py +360 -0
  57. ff9mapkit/content/__init__.py +13 -0
  58. ff9mapkit/content/areatitle.py +36 -0
  59. ff9mapkit/content/ate.py +118 -0
  60. ff9mapkit/content/camera.py +123 -0
  61. ff9mapkit/content/chest.py +186 -0
  62. ff9mapkit/content/choice.py +163 -0
  63. ff9mapkit/content/conductor.py +217 -0
  64. ff9mapkit/content/cutscene.py +290 -0
  65. ff9mapkit/content/encounter.py +41 -0
  66. ff9mapkit/content/entry_settle.py +50 -0
  67. ff9mapkit/content/equipment.py +93 -0
  68. ff9mapkit/content/event.py +191 -0
  69. ff9mapkit/content/gateway.py +101 -0
  70. ff9mapkit/content/inventory.py +59 -0
  71. ff9mapkit/content/itemdata.py +644 -0
  72. ff9mapkit/content/itemtext.py +168 -0
  73. ff9mapkit/content/jump.py +114 -0
  74. ff9mapkit/content/ladder.py +633 -0
  75. ff9mapkit/content/movement.py +53 -0
  76. ff9mapkit/content/music.py +97 -0
  77. ff9mapkit/content/npc.py +348 -0
  78. ff9mapkit/content/object.py +340 -0
  79. ff9mapkit/content/onentry.py +135 -0
  80. ff9mapkit/content/party.py +111 -0
  81. ff9mapkit/content/pathfind.py +138 -0
  82. ff9mapkit/content/platform.py +314 -0
  83. ff9mapkit/content/player.py +168 -0
  84. ff9mapkit/content/prop.py +75 -0
  85. ff9mapkit/content/region.py +340 -0
  86. ff9mapkit/content/reinit.py +59 -0
  87. ff9mapkit/content/savepoint.py +90 -0
  88. ff9mapkit/content/shop.py +178 -0
  89. ff9mapkit/content/sps_trigger.py +66 -0
  90. ff9mapkit/content/startup.py +71 -0
  91. ff9mapkit/content/synthesis.py +106 -0
  92. ff9mapkit/content/text.py +183 -0
  93. ff9mapkit/content/textcarry.py +290 -0
  94. ff9mapkit/content/verbatim.py +86 -0
  95. ff9mapkit/content/walkmesh_hotfix.py +38 -0
  96. ff9mapkit/data/__init__.py +48 -0
  97. ff9mapkit/data/_regen_provenance.py +142 -0
  98. ff9mapkit/data/provenance/blank.es.patch +1 -0
  99. ff9mapkit/data/provenance/blank.fr.patch +1 -0
  100. ff9mapkit/data/provenance/blank.gr.patch +1 -0
  101. ff9mapkit/data/provenance/blank.it.patch +1 -0
  102. ff9mapkit/data/provenance/blank.jp.patch +1 -0
  103. ff9mapkit/data/provenance/blank.uk.patch +1 -0
  104. ff9mapkit/data/provenance/blank.us.patch +1 -0
  105. ff9mapkit/data/provenance/manifest.json +65 -0
  106. ff9mapkit/data/provenance/region_template.patch +1 -0
  107. ff9mapkit/data/reference_arcs.toml +89 -0
  108. ff9mapkit/data/region_catalog.toml +593 -0
  109. ff9mapkit/deploystack.py +358 -0
  110. ff9mapkit/dialogue.py +803 -0
  111. ff9mapkit/eb/__init__.py +12 -0
  112. ff9mapkit/eb/_exprtable.py +59 -0
  113. ff9mapkit/eb/_membertable.py +38 -0
  114. ff9mapkit/eb/_optables.py +537 -0
  115. ff9mapkit/eb/_regen_optables.py +76 -0
  116. ff9mapkit/eb/cmdasm.py +323 -0
  117. ff9mapkit/eb/disasm.py +332 -0
  118. ff9mapkit/eb/edit.py +439 -0
  119. ff9mapkit/eb/exprasm.py +158 -0
  120. ff9mapkit/eb/model.py +178 -0
  121. ff9mapkit/eb/opcodes.py +463 -0
  122. ff9mapkit/eblint.py +177 -0
  123. ff9mapkit/editor/__init__.py +20 -0
  124. ff9mapkit/editor/app.py +950 -0
  125. ff9mapkit/editor/battle_forms.py +240 -0
  126. ff9mapkit/editor/breadcrumb.py +89 -0
  127. ff9mapkit/editor/dialogs.py +116 -0
  128. ff9mapkit/editor/feedback.py +208 -0
  129. ff9mapkit/editor/forms.py +632 -0
  130. ff9mapkit/editor/graphview.py +350 -0
  131. ff9mapkit/editor/jobs.py +342 -0
  132. ff9mapkit/editor/model.py +243 -0
  133. ff9mapkit/editor/picker.py +120 -0
  134. ff9mapkit/editor/theme.py +212 -0
  135. ff9mapkit/eventscan.py +1441 -0
  136. ff9mapkit/extract.py +2279 -0
  137. ff9mapkit/flags.py +693 -0
  138. ff9mapkit/forkreport.py +1383 -0
  139. ff9mapkit/hub.py +477 -0
  140. ff9mapkit/idgated.py +101 -0
  141. ff9mapkit/infohub.py +580 -0
  142. ff9mapkit/items.py +63 -0
  143. ff9mapkit/itemstats.py +346 -0
  144. ff9mapkit/journey.py +1902 -0
  145. ff9mapkit/keyitems.py +93 -0
  146. ff9mapkit/logic_add.py +632 -0
  147. ff9mapkit/logic_edit.py +728 -0
  148. ff9mapkit/logic_map.py +526 -0
  149. ff9mapkit/pack.py +175 -0
  150. ff9mapkit/playerswap.py +231 -0
  151. ff9mapkit/prop_archetypes.py +228 -0
  152. ff9mapkit/provision.py +282 -0
  153. ff9mapkit/refarc.py +825 -0
  154. ff9mapkit/save.py +337 -0
  155. ff9mapkit/save_items.py +1673 -0
  156. ff9mapkit/scene/__init__.py +11 -0
  157. ff9mapkit/scene/arena.py +63 -0
  158. ff9mapkit/scene/bgart.py +140 -0
  159. ff9mapkit/scene/bgi.py +732 -0
  160. ff9mapkit/scene/bgs.py +174 -0
  161. ff9mapkit/scene/bgx.py +185 -0
  162. ff9mapkit/scene/cam.py +345 -0
  163. ff9mapkit/scene/guide.py +311 -0
  164. ff9mapkit/scene/paint.py +506 -0
  165. ff9mapkit/scene/placeholder.py +107 -0
  166. ff9mapkit/sjbinary.py +285 -0
  167. ff9mapkit/sps/__init__.py +17 -0
  168. ff9mapkit/sps/author.py +294 -0
  169. ff9mapkit/sps/catalog.py +88 -0
  170. ff9mapkit/sps/codec.py +264 -0
  171. ff9mapkit/sps/edit.py +184 -0
  172. ff9mapkit/sps/lint.py +58 -0
  173. ff9mapkit/sps/render.py +116 -0
  174. ff9mapkit/sps/templates.py +47 -0
  175. ff9mapkit/sps/texture.py +131 -0
  176. ff9mapkit/walkmesh_hotfixes.py +163 -0
  177. ff9mapkit/workspace/__init__.py +18 -0
  178. ff9mapkit/workspace/battledoc.py +985 -0
  179. ff9mapkit/workspace/builddoc.py +607 -0
  180. ff9mapkit/workspace/forms_qt.py +586 -0
  181. ff9mapkit/workspace/importdoc.py +665 -0
  182. ff9mapkit/workspace/mapview.py +131 -0
  183. ff9mapkit/workspace/palette.py +85 -0
  184. ff9mapkit/workspace/savedoc.py +664 -0
  185. ff9mapkit/workspace/shell.py +6907 -0
  186. ff9mapkit/workspace/style.py +105 -0
  187. ff9mapkit/workspace/tuningdialog.py +223 -0
  188. ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
  189. ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
  190. ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
  191. ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
  192. ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
  193. ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
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
@@ -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