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