ff9mapkit 1.0.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. ff9mapkit/__init__.py +18 -0
  2. ff9mapkit/__main__.py +36 -0
  3. ff9mapkit/_animdb.py +2994 -0
  4. ff9mapkit/_animdb_all.py +14125 -0
  5. ff9mapkit/_fieldtable.py +1516 -0
  6. ff9mapkit/_fieldtext.py +845 -0
  7. ff9mapkit/_held_poses.py +44 -0
  8. ff9mapkit/_itemdb.py +65 -0
  9. ff9mapkit/_modeldb.py +725 -0
  10. ff9mapkit/_narrowmap_data.py +10 -0
  11. ff9mapkit/_npcparams.py +634 -0
  12. ff9mapkit/_regen_animdb.py +72 -0
  13. ff9mapkit/_regen_animdb_all.py +66 -0
  14. ff9mapkit/_regen_fieldtable.py +95 -0
  15. ff9mapkit/_regen_fieldtext.py +66 -0
  16. ff9mapkit/_regen_modeldb.py +67 -0
  17. ff9mapkit/_regen_npcparams.py +123 -0
  18. ff9mapkit/_regen_scenedb.py +57 -0
  19. ff9mapkit/_scenedb.py +869 -0
  20. ff9mapkit/abilities.py +225 -0
  21. ff9mapkit/animations.py +120 -0
  22. ff9mapkit/archetypes.py +218 -0
  23. ff9mapkit/areatitle.py +76 -0
  24. ff9mapkit/battle/__init__.py +21 -0
  25. ff9mapkit/battle/abilityfeatures.py +294 -0
  26. ff9mapkit/battle/actiondelta.py +441 -0
  27. ff9mapkit/battle/aiauthor.py +305 -0
  28. ff9mapkit/battle/ailint.py +140 -0
  29. ff9mapkit/battle/aipatch.py +175 -0
  30. ff9mapkit/battle/battleai.py +148 -0
  31. ff9mapkit/battle/battlecsv.py +390 -0
  32. ff9mapkit/battle/battlepatch.py +395 -0
  33. ff9mapkit/battle/build.py +558 -0
  34. ff9mapkit/battle/camera_codec.py +332 -0
  35. ff9mapkit/battle/camera_data.py +128 -0
  36. ff9mapkit/battle/characterdelta.py +789 -0
  37. ff9mapkit/battle/event_data.py +72 -0
  38. ff9mapkit/battle/extract.py +540 -0
  39. ff9mapkit/battle/fbx.py +223 -0
  40. ff9mapkit/battle/reskin.py +149 -0
  41. ff9mapkit/battle/scene_codec.py +314 -0
  42. ff9mapkit/battle/scene_data.py +369 -0
  43. ff9mapkit/battle/scenelint.py +125 -0
  44. ff9mapkit/battle/seqasm.py +131 -0
  45. ff9mapkit/battle/seqauthor.py +220 -0
  46. ff9mapkit/battle/seqcodec.py +300 -0
  47. ff9mapkit/battle/seqdis.py +106 -0
  48. ff9mapkit/battle/seqpatch.py +137 -0
  49. ff9mapkit/battle_bgm.py +133 -0
  50. ff9mapkit/binutils.py +60 -0
  51. ff9mapkit/build.py +5445 -0
  52. ff9mapkit/campaign.py +1276 -0
  53. ff9mapkit/catalog.py +316 -0
  54. ff9mapkit/chain.py +358 -0
  55. ff9mapkit/cli.py +3114 -0
  56. ff9mapkit/config.py +360 -0
  57. ff9mapkit/content/__init__.py +13 -0
  58. ff9mapkit/content/areatitle.py +36 -0
  59. ff9mapkit/content/ate.py +118 -0
  60. ff9mapkit/content/camera.py +123 -0
  61. ff9mapkit/content/chest.py +186 -0
  62. ff9mapkit/content/choice.py +163 -0
  63. ff9mapkit/content/conductor.py +217 -0
  64. ff9mapkit/content/cutscene.py +290 -0
  65. ff9mapkit/content/encounter.py +41 -0
  66. ff9mapkit/content/entry_settle.py +50 -0
  67. ff9mapkit/content/equipment.py +93 -0
  68. ff9mapkit/content/event.py +191 -0
  69. ff9mapkit/content/gateway.py +101 -0
  70. ff9mapkit/content/inventory.py +59 -0
  71. ff9mapkit/content/itemdata.py +644 -0
  72. ff9mapkit/content/itemtext.py +168 -0
  73. ff9mapkit/content/jump.py +114 -0
  74. ff9mapkit/content/ladder.py +633 -0
  75. ff9mapkit/content/movement.py +53 -0
  76. ff9mapkit/content/music.py +97 -0
  77. ff9mapkit/content/npc.py +348 -0
  78. ff9mapkit/content/object.py +340 -0
  79. ff9mapkit/content/onentry.py +135 -0
  80. ff9mapkit/content/party.py +111 -0
  81. ff9mapkit/content/pathfind.py +138 -0
  82. ff9mapkit/content/platform.py +314 -0
  83. ff9mapkit/content/player.py +168 -0
  84. ff9mapkit/content/prop.py +75 -0
  85. ff9mapkit/content/region.py +340 -0
  86. ff9mapkit/content/reinit.py +59 -0
  87. ff9mapkit/content/savepoint.py +90 -0
  88. ff9mapkit/content/shop.py +178 -0
  89. ff9mapkit/content/sps_trigger.py +66 -0
  90. ff9mapkit/content/startup.py +71 -0
  91. ff9mapkit/content/synthesis.py +106 -0
  92. ff9mapkit/content/text.py +183 -0
  93. ff9mapkit/content/textcarry.py +290 -0
  94. ff9mapkit/content/verbatim.py +86 -0
  95. ff9mapkit/content/walkmesh_hotfix.py +38 -0
  96. ff9mapkit/data/__init__.py +48 -0
  97. ff9mapkit/data/_regen_provenance.py +142 -0
  98. ff9mapkit/data/provenance/blank.es.patch +1 -0
  99. ff9mapkit/data/provenance/blank.fr.patch +1 -0
  100. ff9mapkit/data/provenance/blank.gr.patch +1 -0
  101. ff9mapkit/data/provenance/blank.it.patch +1 -0
  102. ff9mapkit/data/provenance/blank.jp.patch +1 -0
  103. ff9mapkit/data/provenance/blank.uk.patch +1 -0
  104. ff9mapkit/data/provenance/blank.us.patch +1 -0
  105. ff9mapkit/data/provenance/manifest.json +65 -0
  106. ff9mapkit/data/provenance/region_template.patch +1 -0
  107. ff9mapkit/data/reference_arcs.toml +89 -0
  108. ff9mapkit/data/region_catalog.toml +593 -0
  109. ff9mapkit/deploystack.py +358 -0
  110. ff9mapkit/dialogue.py +803 -0
  111. ff9mapkit/eb/__init__.py +12 -0
  112. ff9mapkit/eb/_exprtable.py +59 -0
  113. ff9mapkit/eb/_membertable.py +38 -0
  114. ff9mapkit/eb/_optables.py +537 -0
  115. ff9mapkit/eb/_regen_optables.py +76 -0
  116. ff9mapkit/eb/cmdasm.py +323 -0
  117. ff9mapkit/eb/disasm.py +332 -0
  118. ff9mapkit/eb/edit.py +439 -0
  119. ff9mapkit/eb/exprasm.py +158 -0
  120. ff9mapkit/eb/model.py +178 -0
  121. ff9mapkit/eb/opcodes.py +463 -0
  122. ff9mapkit/eblint.py +177 -0
  123. ff9mapkit/editor/__init__.py +20 -0
  124. ff9mapkit/editor/app.py +950 -0
  125. ff9mapkit/editor/battle_forms.py +240 -0
  126. ff9mapkit/editor/breadcrumb.py +89 -0
  127. ff9mapkit/editor/dialogs.py +116 -0
  128. ff9mapkit/editor/feedback.py +208 -0
  129. ff9mapkit/editor/forms.py +632 -0
  130. ff9mapkit/editor/graphview.py +350 -0
  131. ff9mapkit/editor/jobs.py +342 -0
  132. ff9mapkit/editor/model.py +243 -0
  133. ff9mapkit/editor/picker.py +120 -0
  134. ff9mapkit/editor/theme.py +212 -0
  135. ff9mapkit/eventscan.py +1441 -0
  136. ff9mapkit/extract.py +2279 -0
  137. ff9mapkit/flags.py +693 -0
  138. ff9mapkit/forkreport.py +1383 -0
  139. ff9mapkit/hub.py +477 -0
  140. ff9mapkit/idgated.py +101 -0
  141. ff9mapkit/infohub.py +580 -0
  142. ff9mapkit/items.py +63 -0
  143. ff9mapkit/itemstats.py +346 -0
  144. ff9mapkit/journey.py +1902 -0
  145. ff9mapkit/keyitems.py +93 -0
  146. ff9mapkit/logic_add.py +632 -0
  147. ff9mapkit/logic_edit.py +728 -0
  148. ff9mapkit/logic_map.py +526 -0
  149. ff9mapkit/pack.py +175 -0
  150. ff9mapkit/playerswap.py +231 -0
  151. ff9mapkit/prop_archetypes.py +228 -0
  152. ff9mapkit/provision.py +282 -0
  153. ff9mapkit/refarc.py +825 -0
  154. ff9mapkit/save.py +337 -0
  155. ff9mapkit/save_items.py +1673 -0
  156. ff9mapkit/scene/__init__.py +11 -0
  157. ff9mapkit/scene/arena.py +63 -0
  158. ff9mapkit/scene/bgart.py +140 -0
  159. ff9mapkit/scene/bgi.py +732 -0
  160. ff9mapkit/scene/bgs.py +174 -0
  161. ff9mapkit/scene/bgx.py +185 -0
  162. ff9mapkit/scene/cam.py +345 -0
  163. ff9mapkit/scene/guide.py +311 -0
  164. ff9mapkit/scene/paint.py +506 -0
  165. ff9mapkit/scene/placeholder.py +107 -0
  166. ff9mapkit/sjbinary.py +285 -0
  167. ff9mapkit/sps/__init__.py +17 -0
  168. ff9mapkit/sps/author.py +294 -0
  169. ff9mapkit/sps/catalog.py +88 -0
  170. ff9mapkit/sps/codec.py +264 -0
  171. ff9mapkit/sps/edit.py +184 -0
  172. ff9mapkit/sps/lint.py +58 -0
  173. ff9mapkit/sps/render.py +116 -0
  174. ff9mapkit/sps/templates.py +47 -0
  175. ff9mapkit/sps/texture.py +131 -0
  176. ff9mapkit/walkmesh_hotfixes.py +163 -0
  177. ff9mapkit/workspace/__init__.py +18 -0
  178. ff9mapkit/workspace/battledoc.py +985 -0
  179. ff9mapkit/workspace/builddoc.py +607 -0
  180. ff9mapkit/workspace/forms_qt.py +586 -0
  181. ff9mapkit/workspace/importdoc.py +665 -0
  182. ff9mapkit/workspace/mapview.py +131 -0
  183. ff9mapkit/workspace/palette.py +85 -0
  184. ff9mapkit/workspace/savedoc.py +664 -0
  185. ff9mapkit/workspace/shell.py +6907 -0
  186. ff9mapkit/workspace/style.py +105 -0
  187. ff9mapkit/workspace/tuningdialog.py +223 -0
  188. ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
  189. ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
  190. ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
  191. ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
  192. ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
  193. ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,53 @@
1
+ """Set the player movement control direction (TWIST / SetControlDirection, opcode 0x67).
2
+
3
+ FF9 rotates raw WASD/stick input (x = right, z = forward) about world-Y by an angle BEFORE
4
+ applying it, so "up" on the controller matches "up the screen" for the field's camera. The engine
5
+ stores the angle as ``angle = (value + 1) / 256 * 360`` degrees (FieldState.SetTwistAD), with the
6
+ raw value a signed byte. A front-facing (yaw-0) camera uses value ``-1`` (= 0 deg) — the kit's blank
7
+ default. For a camera ORBITED by ``yaw`` degrees about the scene centre, the player's forward must
8
+ rotate by that same yaw so W still goes up the screen (verified in-game): ``value = round(yaw/360 *
9
+ 256) - 1``. Real FF9 fields do exactly this — e.g. the ~90 deg-yawed Treno shop camera ships a
10
+ matching TWIST.
11
+
12
+ This is the missing half of authoring a yawed custom field: :mod:`ff9mapkit.scene.guide`/``cam``
13
+ place the camera + walkmesh correctly at any yaw, and this makes the controls match.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from ..eb import EbScript, edit, opcodes
19
+
20
+ TWIST_OP = 0x67
21
+
22
+
23
+ def control_value_for_angle(angle_deg: float) -> int:
24
+ """Signed-byte SetControlDirection value whose decoded angle ~= ``angle_deg`` (mod 360).
25
+
26
+ Inverse of ``(value+1)/256*360``. The angle is normalised to (-180, 180] first, then clamped to
27
+ the signed-byte range so any yaw maps to a valid operand."""
28
+ a = ((float(angle_deg) + 180.0) % 360.0) - 180.0 # normalise to (-180, 180]
29
+ v = int(round(a / 360.0 * 256.0)) - 1
30
+ if v < -128:
31
+ v += 256
32
+ elif v > 127:
33
+ v -= 256
34
+ return v
35
+
36
+
37
+ def set_control_direction(eb_bytes, value: int, *, entry_index: int = 0,
38
+ func_tag: int | None = 0) -> bytes:
39
+ """Overwrite the existing TWIST args (both analog + digital) with ``value``, in place.
40
+
41
+ The blank field carries exactly one ``SetControlDirection`` in Main_Init (the kit default
42
+ ``-1, -1`` = 0 deg). This is a same-length patch (``67 00 vv vv``), so there is no bytecode
43
+ shift and no jump relocation — safe to run first, before any appends.
44
+ """
45
+ eb = EbScript.from_bytes(eb_bytes)
46
+ hits = edit.find_instrs(eb, TWIST_OP, entry_index=entry_index, func_tag=func_tag)
47
+ if not hits:
48
+ raise ValueError("no SetControlDirection (TWIST 0x67) in Main_Init to set")
49
+ if len(hits) > 1:
50
+ raise ValueError(f"expected exactly one TWIST in Main_Init, found {len(hits)}")
51
+ off = hits[0].off
52
+ new = opcodes.set_control_direction(int(value), int(value))
53
+ return edit.patch_bytes(eb_bytes, off, new, expect=eb_bytes[off:off + len(new)])
@@ -0,0 +1,97 @@
1
+ """Add field background music (BGM).
2
+
3
+ Field music plays via ``RunSoundCode(0, songId)`` (``ff9fldsnd_song_play``); e.g. song 9 =
4
+ "Vivi's Theme (Disc 1)". Two play points:
5
+ * :func:`add_field_music` appends a tiny init entry ``{RunSoundCode(0, song); return}`` and
6
+ activates it from Main_Init (plays on room entry) — same shift-free mechanism as encounters.
7
+ * :func:`add_music_to_reinit` inserts the same call into the entry-0 tag-10 handler so the
8
+ track resumes after a battle (otherwise the field is silent on battle-return).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import struct
14
+
15
+ from ..eb import EbScript, disasm, edit, opcodes
16
+
17
+ REINIT_TAG = 10
18
+
19
+ # RunSoundCode (0xC5) sound-codes that select the AUDIBLE field BGM (FF9Snd.cs FF9AllSoundDispatch).
20
+ # A field rescore MUST rewrite BOTH: PLAY starts the song, but LOAD is what makes the song's data
21
+ # resident -- patch PLAY alone and the OLD song stays loaded and keeps playing even when PLAY asks for
22
+ # the new one (the Ice Cavern fork bug: Main_Init does PLAY(60) AND LOAD(60), only PLAY was swapped).
23
+ SONG_PLAY = 0 # FF9SOUND_SONG_PLAY
24
+ SONG_LOAD = 1792 # FF9SOUND_SONG_LOAD (0x0700)
25
+ _BGM_CODES = (SONG_PLAY, SONG_LOAD)
26
+
27
+
28
+ def _music_entry(song: int) -> bytes:
29
+ code = opcodes.run_sound_code(0, song) + opcodes.RETURN
30
+ return bytes([0x00, 0x01]) + struct.pack("<HH", 0, 4) + code
31
+
32
+
33
+ def add_field_music(eb_bytes, song: int, *, slot: int | None = None, spawn_wait_n: int = 2,
34
+ spawn_wait_occurrence: int = 0) -> bytes:
35
+ """Play ``song`` on room entry (appended init entry + InitCode over a Wait filler)."""
36
+ eb = EbScript.from_bytes(eb_bytes)
37
+ if slot is None:
38
+ slot = eb.first_free_slot()
39
+ out = edit.append_entry(eb_bytes, slot, _music_entry(song))
40
+ out = edit.activate(out, opcodes.init_code(slot, 0), spawn_wait_n=spawn_wait_n,
41
+ spawn_wait_occurrence=spawn_wait_occurrence)
42
+ return out
43
+
44
+
45
+ def add_music_to_reinit(eb_bytes, song: int) -> bytes:
46
+ """Insert RunSoundCode(0, song) at the start of the entry-0 tag-10 handler (after-battle resume)."""
47
+ eb = EbScript.from_bytes(eb_bytes)
48
+ f = eb.entry(0).func_by_tag(REINIT_TAG)
49
+ if f is None:
50
+ raise ValueError("entry 0 has no tag-10 handler (run content.reinit.add_reinit first)")
51
+ return edit.insert_bytes(eb_bytes, f.abs_start, opcodes.run_sound_code(0, song))
52
+
53
+
54
+ def replace_field_music(eb_bytes, new_song: int, *, old_song: int | None = None):
55
+ """REPLACE the field's BGM in place: overwrite the song operand of every immediate field-BGM ``RunSoundCode``
56
+ -- both the PLAY (``code 0``) AND the LOAD (``code 1792``) of the donor's BGM song id -- with ``new_song``
57
+ (a length-preserving 2-byte swap). For a VERBATIM fork this rewrites the donor's OWN field-BGM calls (the
58
+ Main_Init load+play plus any after-battle/tag-10 resume), so the new track REPLACES rather than stacks
59
+ (unlike :func:`add_field_music`, which appends a play). Rescoring the LOAD is essential: PLAY alone leaves the
60
+ OLD song resident, so the engine keeps playing it. ``old_song`` defaults to :func:`ff9mapkit.eventscan.scan_music`
61
+ (the donor's field BGM, found via its PLAY). A call referencing a DIFFERENT song id (a cutscene track / an
62
+ SFX) is untouched -- only the field BGM song is rescored.
63
+
64
+ Returns ``(out_bytes, n_patched, old_song)``. ``n_patched == 0`` with ``old_song is None`` means the donor
65
+ has no immediate field BGM to replace (silent, or its BGM is set by a computed value); ``old_song == new_song``
66
+ is a no-op (``n_patched == 0``, ``old_song`` echoed). The caller owns the empty case (error / append a track)."""
67
+ from .. import eventscan as _es
68
+ from .object import _arg_byte_offset # decoder-derived operand byte offset (handles argflag)
69
+ if old_song is None:
70
+ old_song = _es.scan_music(eb_bytes)
71
+ if old_song is None: # silent donor / expression-computed -> nothing to swap
72
+ return bytes(eb_bytes), 0, None
73
+ old_song, new_song = int(old_song), int(new_song)
74
+ if old_song == new_song: # already the desired track -> idempotent no-op
75
+ return bytes(eb_bytes), 0, old_song
76
+ OP, SONG = _es.RUN_SOUND_CODE, 1 # operand 1 = song id (operand 0 = sound-code: PLAY 0 / LOAD 1792)
77
+ width = disasm.argsize(OP, SONG) # 2 bytes (song id)
78
+ eb = EbScript.from_bytes(eb_bytes)
79
+ buf = bytearray(eb_bytes)
80
+ seen: set = set() # entry 0's tag-0/tag-10 can enumerate one shared func
81
+ n = 0 # region twice -> dedupe by offset (two real plays differ)
82
+ for e in eb.entries:
83
+ if e.empty:
84
+ continue
85
+ for f in e.funcs:
86
+ for ins in eb.instrs(f):
87
+ if ins.op != OP or ins.imm(0) not in _BGM_CODES or ins.imm(1) != old_song:
88
+ continue # not a PLAY/LOAD of the donor's field-BGM song id
89
+ if ins.off in seen:
90
+ continue
91
+ bo = _arg_byte_offset(ins, SONG) # None iff a preceding operand is an expression
92
+ if bo is None:
93
+ continue
94
+ seen.add(ins.off)
95
+ buf[ins.off + bo:ins.off + bo + width] = new_song.to_bytes(width, "little")
96
+ n += 1
97
+ return bytes(buf), n, old_song
@@ -0,0 +1,348 @@
1
+ """Inject an NPC into a field script, and move the player's spawn.
2
+
3
+ An NPC is built by cloning the field's player object (the entry that calls
4
+ ``DefinePlayerCharacter``), neutralising it (NOP that opcode so it's an NPC, not a 2nd
5
+ player), repositioning it, optionally swapping its model + animations, and adding a
6
+ ``_SpeakBTN`` (func tag 3) that opens a dialogue window. The clone is appended into a free
7
+ entry slot and spawned by overwriting a Main_Init ``Wait(2)`` filler with ``InitObject`` —
8
+ shift-free, so nothing else in the script moves.
9
+
10
+ Offsets are located **symbolically** (via the disassembler / byte patterns), not hardcoded,
11
+ so this works on any field whose player object follows the standard template — while
12
+ reproducing the proven hand-built results byte-for-byte.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import struct
18
+
19
+ from ..binutils import pi16, pu16
20
+ from .._npcparams import NPC_PARAMS # baked per-model NPC object params (animset/head-focus/size/clips)
21
+ from ..eb import EbScript, edit, opcodes
22
+ from ..eb.disasm import iter_code
23
+ from . import region as _region
24
+
25
+ # Character presets: (model, animset, {stand, walk, run, left, right} animation ids)
26
+ PRESETS = {
27
+ "vivi": (8, 61, {"stand": 148, "walk": 571, "run": 419, "left": 917, "right": 918}),
28
+ "zidane": (None, None, None), # keep the cloned player's model/anims as-is
29
+ }
30
+ ANIM_ORDER = ("stand", "walk", "run", "left", "right")
31
+
32
+ DEFINE_PLAYER = 0x2C
33
+ SET_MODEL = 0x2F
34
+ SET_STAND_ANIM = 0x33
35
+ SET_HEAD_FOCUS_MASK = 0x8B
36
+
37
+ # The moogle field rigs (GEO_NPC_F0..F5_MOG). Re-skinning the cloned PLAYER template onto a moogle drags
38
+ # the template's head-focus mask (97,61 -- a human-rig value), which makes a moogle whip its WHOLE BODY to
39
+ # track the player ("head-follow goes crazy"). Real moogle NPCs use a head-ONLY mask (4,1) -- 50/50 in a
40
+ # 676-field census, incl. the real Stiltzkin (field 3100, Mognet Central). Normalize moogle re-skins to
41
+ # this source value (a same-length, byte-identical-to-source arg patch of the existing SetHeadFocusMask).
42
+ MOOGLE_MODELS = frozenset({220, 129, 196, 212, 198, 199})
43
+ MOOGLE_HEAD_FOCUS = (4, 1) # moogle NPCs: head-only focus (50/50 census + the real Stiltzkin)
44
+
45
+ # ---- faithful NPC synthesis: emit a real standing-NPC object entry FROM SCRATCH (no player clone) --------
46
+ # The canonical real-NPC Init shape, byte-verified against Mognet Central (field 3100) standing moogles
47
+ # (Mosh / Mogliana) AND the real Stiltzkin: the four position consts -> SetModel -> CreateObject ->
48
+ # TurnInstant -> the five movement-anim setters -> SetObjectLogicalSize -> SetAnimationStandSpeed ->
49
+ # SetHeadFocusMask -> RETURN. NONE of the player rig's control cruft (DefinePlayerCharacter / EnableMove /
50
+ # EnableMenu / the animation-pack RunModelCode·RunSoundCode / SetTriangleFlagMask) leaks in. The Loop is the
51
+ # real 2-op standby (yield + jump-back). Per-model values beyond the confirmed moogle set fall back to safe
52
+ # defaults here; a baked model->object-params catalog (animset/head-focus/logical-size per model) is the
53
+ # planned fast-follow that makes EVERY model byte-faithful, not just moogles.
54
+ NPC_ENTRY_TYPE = 2 # real NPC object entries are type 2 (so is the blank player entry)
55
+ DEFAULT_LOGICAL_SIZE = (14, 14, 22) # collision box; the common real value (moogles + most humans)
56
+ MOOGLE_ANIMSET = 50 # the moogle SetModel animset (real Mosh/Mogliana/Stiltzkin)
57
+ DEFAULT_ANIMSET = 50 # phased fallback for an unknown model (refined by the per-model catalog)
58
+ DEFAULT_HEAD_FOCUS = (0, 65) # non-moogle default: no automatic head-track (static facing)
59
+ STAND_SPEED = bytes([0x86, 0x00, 0x0E, 0x10, 0x12, 0x14]) # SetAnimationStandSpeed(14,16,18,20) -- invariant
60
+ NPC_STANDBY_LOOP = bytes([0x22, 0x00, 0x01, 0x01, 0xFA, 0xFF]) # yield(1) + JMP -6: the real standby loop
61
+ _CREATE_OBJECT = bytes([0x1D, 0x03, 0xD9, 0x00, 0x7F, 0xD9, 0x04, 0x7F]) # CreateObject(D9(0), D9(4))
62
+ _TURN_INSTANT = bytes([0x36, 0x01, 0xD9, 0x06, 0x7F]) # TurnInstant(D9(6))
63
+ _ANIM_OPS = (0x33, 0x34, 0x35, 0x7A, 0x7B) # stand, walk, run, left, right (ANIM_ORDER)
64
+
65
+
66
+ def _d9_const(idx: int, val: int) -> bytes:
67
+ """``SetVar D9(idx) = val`` in the engine's own expression form: 05 D9 idx 7D <i16 LE> 2C 7F."""
68
+ return bytes([0x05, 0xD9, idx, 0x7D]) + struct.pack("<h", int(val)) + bytes([0x2C, 0x7F])
69
+
70
+
71
+ def _anim_op(op: int, anim_id: int) -> bytes:
72
+ """A movement-animation setter: <op> 00 <anim id, u16 LE>."""
73
+ return bytes([op, 0x00]) + struct.pack("<H", int(anim_id) & 0xFFFF)
74
+
75
+
76
+ def _complete_anims(model, anims) -> dict:
77
+ """A full ``{stand, walk, run, left, right}`` clip set -- the from-scratch Init has no cloned clip to keep,
78
+ so every slot must be filled. Uses the given ``anims``, else the Info Hub model->gesture join; a missing
79
+ slot falls back to ``stand``. Raises if the model resolves no animations at all (specify ``anims=``)."""
80
+ a = dict(anims or {})
81
+ if not a:
82
+ from .. import catalog as _catalog
83
+ a = dict(_catalog.npc_anims(int(model)) or {})
84
+ if "stand" not in a or a.get("stand") is None:
85
+ raise ValueError(f"NPC model {model}: no animations resolved -- pass anims={{stand,walk,run,left,right}}")
86
+ return {name: a.get(name) if a.get(name) is not None else a["stand"] for name in ANIM_ORDER}
87
+
88
+
89
+ def _npc_object_params(model, animset):
90
+ """``(animset, head_focus, logical_size)`` for an NPC model. The baked per-model catalog
91
+ (:data:`ff9mapkit._npcparams.NPC_PARAMS`, the real values 156 NPC/creature rigs use) is authoritative;
92
+ an explicit ``animset`` still wins. Off-catalog models fall back to the confirmed moogle set / safe
93
+ defaults."""
94
+ p = NPC_PARAMS.get(int(model))
95
+ if p:
96
+ av = int(animset) if animset is not None else p["animset"]
97
+ return av, p["head_focus"], p["logical_size"]
98
+ is_moog = int(model) in MOOGLE_MODELS
99
+ av = int(animset) if animset is not None else (MOOGLE_ANIMSET if is_moog else DEFAULT_ANIMSET)
100
+ hf = MOOGLE_HEAD_FOCUS if is_moog else DEFAULT_HEAD_FOCUS
101
+ return av, hf, DEFAULT_LOGICAL_SIZE
102
+
103
+
104
+ def build_npc_init(*, model, animset, anims, x: int, z: int, facing: int = 0, y: int = 0,
105
+ head_focus=DEFAULT_HEAD_FOCUS, logical_size=DEFAULT_LOGICAL_SIZE,
106
+ init_tail: bytes = b"", gate=None) -> bytes:
107
+ """Emit a faithful standing-NPC Init (func tag 0) from scratch -- the real-NPC opcode shape, NO player
108
+ clone. ``anims`` must hold all five movement clips (see :func:`_complete_anims`). ``gate`` =
109
+ ``(flag_index, require_set)`` prepends a story-flag gate so the object is absent unless the flag is in
110
+ state. ``init_tail`` (the prop recipe: EnableHeadFocus(0) / AttachObject ...) is spliced just before the
111
+ RETURN, applying to the freshly created object."""
112
+ parts = []
113
+ if gate is not None:
114
+ gf, gset = gate
115
+ parts.append(_region.flag_gate(_region.GLOB_BOOL, gf, require_set=gset))
116
+ parts.append(_d9_const(0, x))
117
+ parts.append(_d9_const(4, z))
118
+ parts.append(_d9_const(6, facing))
119
+ parts.append(_d9_const(2, y))
120
+ parts.append(bytes([0x2F, 0x00]) + struct.pack("<H", int(model) & 0xFFFF) + bytes([int(animset) & 0xFF]))
121
+ parts.append(_CREATE_OBJECT)
122
+ parts.append(_TURN_INSTANT)
123
+ for op, name in zip(_ANIM_OPS, ANIM_ORDER):
124
+ parts.append(_anim_op(op, anims[name]))
125
+ parts.append(bytes([0x4B, 0x00, logical_size[0] & 0xFF, logical_size[1] & 0xFF, logical_size[2] & 0xFF]))
126
+ parts.append(STAND_SPEED)
127
+ parts.append(bytes([0x8B, 0x00, head_focus[0] & 0xFF, head_focus[1] & 0xFF]))
128
+ parts.append(bytes(init_tail))
129
+ parts.append(opcodes.RETURN) # 0x04 -- the real NPC Init terminator
130
+ return b"".join(parts)
131
+
132
+
133
+ def _find_player_entry(eb: EbScript) -> int:
134
+ for e in eb.entries:
135
+ if e.empty:
136
+ continue
137
+ f0 = e.func_by_tag(0)
138
+ if f0 and any(ins.op == DEFINE_PLAYER for ins in eb.instrs(f0)):
139
+ return e.index
140
+ raise ValueError("no player object (DefinePlayerCharacter) found in any entry")
141
+
142
+
143
+ def _func0_locations(eb: EbScript, entry):
144
+ """Return offsets (relative to func0 body start) of the opcodes we patch."""
145
+ f0 = entry.func_by_tag(0)
146
+ base = f0.abs_start
147
+ loc = {"dpc": None, "model": None, "animset": None, "stand": None, "headfocus": None}
148
+ for ins in iter_code(eb.data, f0.abs_start, f0.abs_end):
149
+ if ins.op == DEFINE_PLAYER and loc["dpc"] is None:
150
+ loc["dpc"] = ins.off - base
151
+ elif ins.op == SET_MODEL and loc["model"] is None:
152
+ # SetModel: op, argFlag, model(2), animset(1) -> model@+2, animset@+4
153
+ loc["model"] = ins.off - base + 2
154
+ loc["animset"] = ins.off - base + 4
155
+ elif ins.op == SET_STAND_ANIM and loc["stand"] is None:
156
+ loc["stand"] = ins.off - base + 2 # first anim-setter arg; 4 more follow every 4 bytes
157
+ elif ins.op == SET_HEAD_FOCUS_MASK and loc["headfocus"] is None:
158
+ loc["headfocus"] = ins.off - base + 2 # SetHeadFocusMask: op, flag, arg0, arg1 -> args@+2
159
+ return f0, base, loc
160
+
161
+
162
+ def _find_var_const(body: bytes, var_index: int) -> int:
163
+ """Offset (within body) of the 2-byte const a ``SetVar D9(var_index) = const`` assigns.
164
+
165
+ Pattern: 05 D9 <var_index> 7D <lo> <hi> 2C 7F -> the const is the 2 bytes after 0x7D.
166
+ """
167
+ pat = bytes([0x05, 0xD9, var_index, 0x7D])
168
+ i = body.find(pat)
169
+ if i < 0:
170
+ raise ValueError(f"no SetVar D9({var_index}) const found")
171
+ return i + len(pat)
172
+
173
+
174
+ def _player_rig(data) -> tuple:
175
+ """The field player's ``(model, animset, anims)`` -- the default rig for an NPC injected with NO model
176
+ (preserves the pre-template default: a bare ``[[npc]]`` mirrored the field's current player avatar).
177
+ Sourced as a clean model id + clip set read straight from the player Init, NOT a rig clone."""
178
+ eb = EbScript.from_bytes(data)
179
+ entry = eb.entry(_find_player_entry(eb))
180
+ f0, base, loc = _func0_locations(eb, entry)
181
+ if loc["model"] is None:
182
+ raise ValueError("field player has no SetModel -- specify a model for the NPC")
183
+ b = f0.abs_start
184
+ model = int.from_bytes(data[b + loc["model"]:b + loc["model"] + 2], "little")
185
+ animset = data[b + loc["animset"]] if loc["animset"] is not None else None
186
+ anims = None
187
+ if loc["stand"] is not None:
188
+ anims = {name: int.from_bytes(data[b + loc["stand"] + 4 * k:b + loc["stand"] + 4 * k + 2], "little")
189
+ for k, name in enumerate(ANIM_ORDER)}
190
+ return model, animset, anims
191
+
192
+
193
+ def inject_npc(data, x: int, z: int, *, preset: str | None = None, model=None, animset=None,
194
+ anims=None, talk_text_id: int = 62, slot: int | None = None,
195
+ spawn_wait_n: int = 2, spawn_wait_occurrence: int = 0,
196
+ gate_flag: int | None = None, gate_require_set: bool = True,
197
+ intro: bytes | None = None, speak_body: bytes | None = None,
198
+ init_tail: bytes | None = None, bare: bool = False,
199
+ reserve_party_band: bool = False) -> bytes:
200
+ """Inject an NPC at world (x, z). Returns new .eb bytes.
201
+
202
+ ``reserve_party_band`` (the VERBATIM-fork path): a real field packs its NPC slots and reserves the
203
+ LAST 9 entry slots for the playable characters (addressed positionally by the engine), so the kit's
204
+ ``first_free_slot`` would seat the NPC inside that band = the engine reads it as a character. With this
205
+ set, the NPC is inserted BELOW the band instead (the 9 character slots shift up one, every reference to
206
+ them remapped +1 -- :func:`ff9mapkit.content.object.insert_entry_before_band`). Leave False on the
207
+ synthesize path (a blank field has free NPC slots; behaviour is byte-identical to before).
208
+
209
+ ``gate_flag`` (a GlobBool index) makes the NPC conditional: its Init returns early -- so it never
210
+ creates its model and is absent/non-interactable -- unless the flag is in the required state
211
+ (``gate_require_set`` True = appears when the flag is SET, False = when CLEAR). This is the
212
+ standard FF9 way to show/hide an NPC by story state.
213
+
214
+ ``intro`` (bytes) is an ACTOR cutscene's gated choreography block (from
215
+ :func:`ff9mapkit.content.cutscene.build_choreography`), spliced into this NPC's Init just before
216
+ its RETURN so it runs in the NPC's own object context (``gExec`` == this NPC) after CreateObject.
217
+
218
+ ``speak_body`` (bytes) replaces the default ``_SpeakBTN`` (tag 3) -- pass a dialogue-choice body
219
+ (:func:`ff9mapkit.content.choice.speak_body`) for a talk-to-branch NPC. Must end with a RETURN."""
220
+ if preset is not None:
221
+ model, animset, anims = PRESETS[preset]
222
+ if model is None:
223
+ # no model/preset/archetype -> mirror the field's current player avatar (the pre-template default:
224
+ # a bare [[npc]] / preset "zidane" looked like the player). Clean model+clips, not a rig clone.
225
+ pmodel, pset, panims = _player_rig(data)
226
+ model = pmodel
227
+ animset = animset if animset is not None else pset
228
+ anims = anims or panims
229
+ anims = _complete_anims(model, anims)
230
+ animset_v, head_focus, logical_size = _npc_object_params(model, animset)
231
+
232
+ # Init (tag 0): the real-NPC object shape, emitted FROM SCRATCH -- no player clone, no control cruft.
233
+ # The flag `gate` (object absent unless in state) and the prop `init_tail` (EnableHeadFocus(0) /
234
+ # AttachObject) are folded into the Init by build_npc_init.
235
+ body0 = build_npc_init(model=model, animset=animset_v, anims=anims, x=x, z=z,
236
+ head_focus=head_focus, logical_size=logical_size,
237
+ init_tail=bytes(init_tail or b""),
238
+ gate=(gate_flag, gate_require_set) if gate_flag is not None else None)
239
+ # Loop (tag 1): the real 2-op standby. An ACTOR cutscene's `intro` choreography PREPENDS here (NOT the
240
+ # Init): the engine only advances animation frames at loop state 1, so a cutscene baked into the Init
241
+ # (state 2) would glide FROZEN. It self-gates to run once per visit.
242
+ body1 = (bytes(intro) + NPC_STANDBY_LOOP) if intro else NPC_STANDBY_LOOP
243
+
244
+ eb = EbScript.from_bytes(data)
245
+ # assemble the entry. A BARE object is Init-only (1 func, tag 0) -- the shipping set-dressing shape; it
246
+ # has NO tag-3 talk func, so the engine's IsActuallyTalkable short-circuits on GetIP(...,3)==nil instead
247
+ # of indexing past a too-short func (no per-frame IndexOutOfRange when the player stands near a prop). A
248
+ # normal NPC keeps Init + Loop + _SpeakBTN (tag 3) so it can be talked to.
249
+ if bare:
250
+ table = struct.pack("<HH", 0, 1 * 4)
251
+ entry_bytes = bytes([NPC_ENTRY_TYPE, 1]) + table + body0
252
+ else:
253
+ f2 = speak_body if speak_body is not None else (opcodes.window_sync(1, 128, talk_text_id) + opcodes.RETURN)
254
+ # IsActuallyTalkable (the per-frame talk-icon poll) blindly reads tag3[ip+7]/[ip+8]; a talk func
255
+ # shorter than 9 bytes indexes PAST the entry buffer -> an IndexOutOfRange every frame the player is
256
+ # near. Real talk funcs are 100+ bytes; pad ours to >= 9 (dead bytes after RETURN -> behaviour same).
257
+ if len(f2) < 9:
258
+ f2 = bytes(f2) + b"\x00" * (9 - len(f2))
259
+ table_len = 3 * 4
260
+ nf0, nf1, nf2 = table_len, table_len + len(body0), table_len + len(body0) + len(body1)
261
+ table = struct.pack("<HH", 0, nf0) + struct.pack("<HH", 1, nf1) + struct.pack("<HH", 3, nf2)
262
+ entry_bytes = bytes([NPC_ENTRY_TYPE, 3]) + table + body0 + bytes(body1) + f2
263
+
264
+ # 7) seat + spawn (shift-free): overwrite a Main_Init Wait(n) with InitObject(slot,0). On a verbatim
265
+ # fork (reserve_party_band) seat_entry inserts the NPC BELOW the reserved party-character band instead of
266
+ # into a blank free slot (which on a real field is an unused CHARACTER slot the engine would mis-read).
267
+ from . import object as _object
268
+ out, slot = _object.seat_entry(data, entry_bytes, reserve_party_band=reserve_party_band, slot=slot)
269
+ out = edit.activate(out, opcodes.init_object(slot, 0), spawn_wait_n=spawn_wait_n,
270
+ spawn_wait_occurrence=spawn_wait_occurrence)
271
+ return out
272
+
273
+
274
+ def set_player_spawn(data, x: int, z: int, *, entry_index: int | None = None) -> bytes:
275
+ """Move the player's spawn position (the SetVar D9(0)/D9(4) consts in its Init func)."""
276
+ eb = EbScript.from_bytes(data)
277
+ pe = entry_index if entry_index is not None else _find_player_entry(eb)
278
+ f0 = eb.entry(pe).func_by_tag(0)
279
+ body = bytearray(data[f0.abs_start:f0.abs_end])
280
+ xo, zo = _find_var_const(body, 0), _find_var_const(body, 4)
281
+ abs_x = f0.abs_start + xo
282
+ abs_z = f0.abs_start + zo
283
+ return edit.patch_bytes(edit.patch_bytes(data, abs_x, pi16(x)), abs_z, pi16(z))
284
+
285
+
286
+ def set_player_model(data, model_id: int, anims: dict | None = None, *,
287
+ animset: int | None = None, entry_index: int | None = None) -> bytes:
288
+ """Re-skin the PLAYER's field avatar to ``model_id`` + its movement ``anims`` -- the `[player] model=`
289
+ option (walk an authored field as a Moogle / any model, the World-Hub PC). Patches the player entry's
290
+ Init ``SetModel`` + the 5 movement-animation setters in place (same byte-exact width as a swap), while
291
+ KEEPING ``DefinePlayerCharacter`` (it is still the player, just a different rig). This is the field-side
292
+ twin of ``playerswap.swap_player`` for a SYNTHESIZED field (vs a fork): the player here is the blank
293
+ field's Zidane, found unambiguously by :func:`_find_player_entry`.
294
+
295
+ ``anims`` = ``{stand, walk, run, left, right}`` (from :func:`ff9mapkit.catalog.npc_anims`); a missing key
296
+ keeps the cloned clip. Only MOVEMENT clips are swapped -- a scripted-gesture cutscene would still play
297
+ the donor rig's gesture clips (same caveat as ``--swap-player``); a hub field is free-roam, so clean.
298
+ Raises if the player Init has no ``SetModel`` to re-skin."""
299
+ eb = EbScript.from_bytes(data)
300
+ pe = entry_index if entry_index is not None else _find_player_entry(eb)
301
+ entry = eb.entry(pe)
302
+ f0, _base0, loc = _func0_locations(eb, entry)
303
+ if loc["model"] is None:
304
+ raise ValueError("player Init has no SetModel -- cannot set [player] model")
305
+ body0 = bytearray(data[f0.abs_start:f0.abs_end])
306
+ body0[loc["model"]:loc["model"] + 2] = pu16(int(model_id))
307
+ if animset is not None and loc["animset"] is not None:
308
+ body0[loc["animset"]] = int(animset) & 0xFF
309
+ if anims and loc["stand"] is not None:
310
+ for k, name in enumerate(ANIM_ORDER):
311
+ if name in anims and anims[name] is not None:
312
+ o = loc["stand"] + 4 * k
313
+ body0[o:o + 2] = pu16(int(anims[name]))
314
+ # moogle PC head-focus: a moogle avatar inherits the template's human head mask (97,61) -> a spin-to-
315
+ # face when idle near an NPC. Match the real moogle value (4,1); only for moogle rigs (a human [player]
316
+ # model= keeps its own mask). Same length -> stays in the in-place patch.
317
+ if int(model_id) in MOOGLE_MODELS and loc["headfocus"] is not None:
318
+ body0[loc["headfocus"]:loc["headfocus"] + 2] = bytes(MOOGLE_HEAD_FOCUS)
319
+ return edit.patch_bytes(data, f0.abs_start, bytes(body0)) # same length -> in-place
320
+
321
+
322
+ # RunSoundCode(4616, 912): a sound/voice-bank PRELOAD the blank player template carries from its SOURCE
323
+ # field. In a custom field that bank is never loaded, so the engine throws "Music Id 912 not found" each
324
+ # time the loop sound retries -> a per-frame KeyNotFoundException = lag (seen on the hut, field 4000). The
325
+ # model-pack RunModelCode ops alongside it do NOT throw (different subsystem), so they stay (the player
326
+ # still animates) -- we neutralise only the offending sound op.
327
+ PLAYER_STALE_SOUND = (0xC5, (4616, 912)) # (op, args) -- RunSoundCode(4616, 912)
328
+
329
+
330
+ def neutralize_player_audio_cruft(data) -> bytes:
331
+ """NOP the stale ``RunSoundCode(4616, 912)`` preload ops in the PLAYER entry's Init (in-place, same
332
+ length). Removes the per-frame 'Music Id 912' exception spam every synthesized field's player inherits
333
+ from the blank template, without touching animation (the model-pack loads are kept). No-op if absent."""
334
+ op, want = PLAYER_STALE_SOUND
335
+ eb = EbScript.from_bytes(data)
336
+ try:
337
+ f0 = eb.entry(_find_player_entry(eb)).func_by_tag(0)
338
+ except ValueError:
339
+ return data if isinstance(data, bytes) else bytes(data)
340
+ if f0 is None:
341
+ return data if isinstance(data, bytes) else bytes(data)
342
+ out = bytearray(data)
343
+ instrs = list(eb.instrs(f0))
344
+ for k, ins in enumerate(instrs):
345
+ if ins.op == op and tuple(ins.args or ()) == want:
346
+ end = instrs[k + 1].off if k + 1 < len(instrs) else f0.abs_end
347
+ out[ins.off:end] = b"\x00" * (end - ins.off) # NOP the whole op (op 0x00 = safe skip)
348
+ return bytes(out)