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,186 @@
|
|
|
1
|
+
"""Inject a real openable TREASURE CHEST -- one object whose pose is save-flag-gated (closed/open) and
|
|
2
|
+
whose press handler animates the lid, gives a fixed item or gil, shows a "Received X" box, and latches the
|
|
3
|
+
flag so it STAYS OPEN across saves.
|
|
4
|
+
|
|
5
|
+
Byte-grounded on real FF9 chests (field 200 entry 9; field 407 entries 12/22 -- model 75 = GEO_ACC_F0_TBX),
|
|
6
|
+
decoded opcode-for-opcode. Init (tag 0): CreateObject + TurnInstant + SetObjectLogicalSize(1,40,45) [the
|
|
7
|
+
collision box] + SetStandAnimation(7340) + SetObjectFlags(5) + SetHeadFocusMask(2,0) + a TWO-ARM pose branch
|
|
8
|
+
on a save-persistent GLOB_BOOL opened-flag -> SetStandAnimation(OPEN 7338) when SET / (CLOSED 7339) when
|
|
9
|
+
CLEAR + SetObjectFlags(49) [show + can't-walk-through(16) + don't-hide(32) = the chest's solid collision] +
|
|
10
|
+
EnableHeadFocus(0). The model is ALWAYS shown; only the pose differs, so a re-entered / reloaded field
|
|
11
|
+
re-poses the chest OPEN (the flag persists in gEventGlobal across saves) with zero per-visit bookkeeping.
|
|
12
|
+
|
|
13
|
+
The open handler runs in the chest's OWN object context (so RunAnimation animates the chest's own model -- a
|
|
14
|
+
separate region has no model and could not). SetObjectFlags bits (EventEngine.DoEventCode CFLAG 0x93:2040):
|
|
15
|
+
1 show, 2 collide-player, 4 collide-NPC, 8 disable-talk, 16 can't-walk-through, 32 don't-hide.
|
|
16
|
+
|
|
17
|
+
Fidelity note: real chests put the open in the object's tag-2 RANGE function and gate the ``Bubble`` "!" on
|
|
18
|
+
the opened-flag; the kit uses the object's tag-3 talk handler (press X while near) and instead sets the
|
|
19
|
+
**disable-talk** flag (bit 8) once opened, so a looted chest shows no "!" and ignores presses -- the same
|
|
20
|
+
"approach + press once, then inert" behaviour, a different dispatch tag.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import struct
|
|
25
|
+
|
|
26
|
+
from .. import items as _items
|
|
27
|
+
from ..eb import edit, opcodes
|
|
28
|
+
from . import event as _event
|
|
29
|
+
from . import npc as _npc
|
|
30
|
+
from . import region as _region
|
|
31
|
+
|
|
32
|
+
CHEST_MODEL = 75 # GEO_ACC_F0_TBX (the kit prop archetype "chest")
|
|
33
|
+
CLOSED_POSE = 7339 # SetStandAnimation rest pose when CLOSED
|
|
34
|
+
OPEN_POSE = 7338 # ... when OPEN (after looting)
|
|
35
|
+
NEUTRAL_POSE = 7340 # the transitional default the real Init sets before the pose branch
|
|
36
|
+
OPEN_ANIM = 7336 # the RunAnimation lid-open clip
|
|
37
|
+
CHEST_LOGICAL_SIZE = (1, 40, 45) # SetObjectLogicalSize -- the real chest's collision box
|
|
38
|
+
CHEST_FLAGS_INIT = 5 # SetObjectFlags initial: show(1) + collide-NPC(4) (matches the real Init)
|
|
39
|
+
CHEST_FLAGS_CLOSED = 49 # show(1) + can't-walk-through(16) + don't-hide(32): solid + talkable (-> "!")
|
|
40
|
+
CHEST_FLAGS_OPEN = 57 # CHEST_FLAGS_CLOSED + disable-talk(8): solid but NO "!" / inert once looted
|
|
41
|
+
CHEST_FLAG_CLASS = _region.GLOB_BOOL # 0xC4 -- save-persistent gEventGlobal bool
|
|
42
|
+
# The opened-flag is REQUIRED (no auto-allocation): inject_chest takes it as `flag_idx`, and build.validate
|
|
43
|
+
# enforces a DEFINED flag in the safe custom band [flags.FIRST_SAFE_FLAG, CHOICE_SCRATCH_FLOOR) -- so it can't
|
|
44
|
+
# shift on reorder (a positional bit would) or collide with FF9's own chest bitfield ([8376, 8511]). A named
|
|
45
|
+
# [[flag]] is the ergonomic, campaign-unique choice.
|
|
46
|
+
|
|
47
|
+
SET_MODEL = 0x2F
|
|
48
|
+
SET_OBJECT_LOGICAL_SIZE = 0x4B
|
|
49
|
+
SET_OBJECT_FLAGS = 0x93
|
|
50
|
+
SET_HEAD_FOCUS_MASK = 0x8B
|
|
51
|
+
ENABLE_HEAD_FOCUS = 0x47
|
|
52
|
+
SET_ANIMATION_FLAGS = 0x3F
|
|
53
|
+
RUN_SOUND_CODE3 = 0xC8 # RunSoundCode3(bank, sound_id, p1, p2, p3) -- the SFX op the real chest uses
|
|
54
|
+
LID_SFX = (637, 638) # the two lid-creak sound ids the real chest plays
|
|
55
|
+
ITEM_JINGLE = 108 # the item-get jingle the real chest plays when the Received box appears
|
|
56
|
+
SFX_BANK = 53248 # 0xD000 -- the sound bank the chest SFX live in
|
|
57
|
+
SFX_PARAMS = (0, 128, 125) # the pan/volume params (byte-faithful to fields 200/407)
|
|
58
|
+
|
|
59
|
+
# The 4 FF9 treasure-chest models (GEO_ACC_F0..F3_TBX) and their per-model animation ids -- decoded byte-for-byte
|
|
60
|
+
# from real fields (the Init's [neutral, open, closed] SetStandAnimation order + the tag-2 lid RunAnimation) and
|
|
61
|
+
# independently cross-verified across many fields, anchored to the in-game-proven F0 chest. The chest OBJECT is
|
|
62
|
+
# otherwise byte-identical across models (same collision LogSize 1,40,45, flags 5->49, opened-flag branch), so
|
|
63
|
+
# only the model id + these 4 animation ids vary. Two clip schemes: F0/F2 share the 73xx clips, F1/F3 share the
|
|
64
|
+
# low ids. ★ Extracting the FBX+textures from p0data (models/1/<id>/) confirms only TWO DISTINCT LOOKS: F1 (91) and
|
|
65
|
+
# F3 (702) are byte-identical (same mesh + both textures); F0 (75) and F2 (701) share the mesh + one texture and
|
|
66
|
+
# differ ONLY in F2's other texture being a ~magenta UNUSED dummy -> renders the same as F0. F2/F3 are per-zone
|
|
67
|
+
# duplicate ids kept here for fidelity; author with F0/F1 for the two real looks. tuple = (model_id, neutral, open, closed, lid).
|
|
68
|
+
CHEST_VARIANTS = {
|
|
69
|
+
"F0": (75, 7340, 7338, 7339, 7336), # GEO_ACC_F0_TBX -- the default wooden chest (in-game proven)
|
|
70
|
+
"F1": (91, 4, 1, 3, 22), # GEO_ACC_F1_TBX -- the 2nd real look
|
|
71
|
+
"F2": (701, 7340, 7338, 7339, 7336), # GEO_ACC_F2_TBX -- F0 with a magenta dummy texture (looks identical to F0)
|
|
72
|
+
"F3": (702, 4, 1, 3, 22), # GEO_ACC_F3_TBX -- byte-identical to F1
|
|
73
|
+
}
|
|
74
|
+
_VARIANT_BY_MODEL = {tup[0]: tup for tup in CHEST_VARIANTS.values()} # model id -> the same 5-tuple
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_chest_variant(model=None):
|
|
78
|
+
"""Resolve a ``[[chest]] model`` to ``(model_id, neutral_pose, open_pose, closed_pose, lid_anim)``.
|
|
79
|
+
``model`` is a variant NAME ("F0".."F3", case-insensitive), a raw model id (75/91/701/702), or ``None``
|
|
80
|
+
(= the default F0 wooden chest). Raises ``ValueError`` on an unknown model -- its open/closed/lid animation
|
|
81
|
+
ids aren't known, and a bare model swap (keeping F0's 73xx clips) would play the wrong / no lid + pose."""
|
|
82
|
+
if model is None or model == "":
|
|
83
|
+
return CHEST_VARIANTS["F0"]
|
|
84
|
+
if isinstance(model, str) and not model.strip().lstrip("-").isdigit():
|
|
85
|
+
key = model.strip().upper()
|
|
86
|
+
if key not in CHEST_VARIANTS:
|
|
87
|
+
raise ValueError(f"unknown chest model {model!r} -- use one of {sorted(CHEST_VARIANTS)} "
|
|
88
|
+
f"(the F0..F3 treasure-chest variants) or a raw model id {sorted(_VARIANT_BY_MODEL)}")
|
|
89
|
+
return CHEST_VARIANTS[key]
|
|
90
|
+
mid = int(model)
|
|
91
|
+
if mid not in _VARIANT_BY_MODEL:
|
|
92
|
+
raise ValueError(f"chest model id {mid} has no known open/closed/lid animations -- use a TBX chest "
|
|
93
|
+
f"variant {sorted(CHEST_VARIANTS)} (ids {sorted(_VARIANT_BY_MODEL)})")
|
|
94
|
+
return _VARIANT_BY_MODEL[mid]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def chest_lid_sfx() -> bytes:
|
|
98
|
+
"""The lid-creak open SFX, byte-faithful to the real chest (fields 200/407): SetAnimationFlags(1,0) +
|
|
99
|
+
two RunSoundCode3 (bank 53248, ids 637 then 638)."""
|
|
100
|
+
out = opcodes.encode(SET_ANIMATION_FLAGS, 1, 0)
|
|
101
|
+
for sid in LID_SFX:
|
|
102
|
+
out += opcodes.encode(RUN_SOUND_CODE3, SFX_BANK, sid, *SFX_PARAMS)
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def build_chest_init(*, x: int, z: int, flag_idx: int, model: int = CHEST_MODEL, animset: int | None = None,
|
|
107
|
+
face: int = 0, neutral_pose: int = NEUTRAL_POSE, open_pose: int = OPEN_POSE,
|
|
108
|
+
closed_pose: int = CLOSED_POSE, gate=None) -> bytes:
|
|
109
|
+
"""The chest Init (tag 0), opcode-faithful to the real chest, with the SAVABLE open-state: a two-arm
|
|
110
|
+
pose+flags branch on the opened flag -- the OPEN pose + the inert(disable-talk) flags when SET, the
|
|
111
|
+
CLOSED pose + the talkable flags when CLEAR. The collision (size + flags) is unconditional. ``gate`` =
|
|
112
|
+
``(flag_index, require_set)`` prepends a story-flag gate (same as an NPC's): the chest is ABSENT unless
|
|
113
|
+
that APPEARANCE flag is in state -- a quest-reward chest that only materializes after a beat (distinct
|
|
114
|
+
from ``flag_idx``, the OPENED bit)."""
|
|
115
|
+
animset_v, _hf, _ls = _npc._npc_object_params(model, animset)
|
|
116
|
+
parts = []
|
|
117
|
+
if gate is not None:
|
|
118
|
+
gf, gset = gate
|
|
119
|
+
parts.append(_region.flag_gate(_region.GLOB_BOOL, gf, require_set=gset))
|
|
120
|
+
parts += [
|
|
121
|
+
_npc._d9_const(0, x), _npc._d9_const(4, z), _npc._d9_const(6, face), _npc._d9_const(2, 0),
|
|
122
|
+
bytes([SET_MODEL, 0x00]) + struct.pack("<H", int(model) & 0xFFFF) + bytes([animset_v & 0xFF]),
|
|
123
|
+
_npc._CREATE_OBJECT, _npc._TURN_INSTANT,
|
|
124
|
+
opcodes.encode(SET_OBJECT_LOGICAL_SIZE, *CHEST_LOGICAL_SIZE), # the collision box
|
|
125
|
+
opcodes.set_stand_animation(neutral_pose),
|
|
126
|
+
opcodes.encode(SET_OBJECT_FLAGS, CHEST_FLAGS_INIT),
|
|
127
|
+
opcodes.encode(SET_HEAD_FOCUS_MASK, 2, 0),
|
|
128
|
+
_region.if_else(_region.cond_truthy(CHEST_FLAG_CLASS, flag_idx),
|
|
129
|
+
opcodes.set_stand_animation(open_pose) + opcodes.encode(SET_OBJECT_FLAGS, CHEST_FLAGS_OPEN),
|
|
130
|
+
opcodes.set_stand_animation(closed_pose) + opcodes.encode(SET_OBJECT_FLAGS, CHEST_FLAGS_CLOSED)),
|
|
131
|
+
opcodes.encode(ENABLE_HEAD_FOCUS, 0),
|
|
132
|
+
opcodes.RETURN,
|
|
133
|
+
]
|
|
134
|
+
return b"".join(parts)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def build_chest_open(flag_idx: int, *, give: bytes, received_text_id: int, payload_value: int,
|
|
138
|
+
open_anim: int = OPEN_ANIM, open_pose: int = OPEN_POSE) -> bytes:
|
|
139
|
+
"""The chest press handler (tag 3): no-op if already opened, else animate the lid open, give the
|
|
140
|
+
payload, show the Received box, latch the opened flag, and set the disable-talk flag (so it shows no
|
|
141
|
+
more "!" and ignores presses for the rest of the visit -- the Init handles it on the next load)."""
|
|
142
|
+
return b"".join([
|
|
143
|
+
_region.flag_gate(CHEST_FLAG_CLASS, flag_idx, require_set=False), # already opened -> return
|
|
144
|
+
chest_lid_sfx(), # the lid-creak SFX (637/638)
|
|
145
|
+
opcodes.run_animation(open_anim), opcodes.wait_animation(), # lid opens (on the chest's own model)
|
|
146
|
+
opcodes.set_stand_animation(open_pose), # hold the open pose for this visit
|
|
147
|
+
opcodes.encode(RUN_SOUND_CODE3, SFX_BANK, ITEM_JINGLE, *SFX_PARAMS), # the item-get jingle (with the box)
|
|
148
|
+
give, # AddItem / AddGil
|
|
149
|
+
opcodes.set_text_variable(0, payload_value), # bind the item/amount for the box
|
|
150
|
+
opcodes.window_sync(7, 0, received_text_id), # window TYPE 7 = the "Received X" box
|
|
151
|
+
_region.set_var(CHEST_FLAG_CLASS, flag_idx, 1), # latch the opened flag (save-backed)
|
|
152
|
+
opcodes.encode(SET_OBJECT_FLAGS, CHEST_FLAGS_OPEN), # disable talk -> no "!" the rest of the visit
|
|
153
|
+
opcodes.RETURN,
|
|
154
|
+
])
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def inject_chest(data, x, z, *, flag_idx: int, item=None, gil=None, count: int = 1,
|
|
158
|
+
received_text_id: int = 62, model="F0", face: int = 0, gate=None,
|
|
159
|
+
reserve_party_band: bool = False, spawn_wait_n: int = 2, spawn_wait_occurrence: int = 0):
|
|
160
|
+
"""Inject an openable, savable treasure chest at world (x, z) -- ONE object (tag 0 Init + tag 3 open).
|
|
161
|
+
Exactly one of ``item`` (id/name + ``count``) or ``gil`` (amount). ``flag_idx`` (a GLOB_BOOL save index)
|
|
162
|
+
is the opened bit -- it drives the Init open/closed pose+flags and the open handler's once-guard + latch.
|
|
163
|
+
``model`` picks the chest VARIANT (a name "F0".."F3" or a raw id 75/91/701/702 -- :func:`resolve_chest_variant`
|
|
164
|
+
supplies its own open/closed/neutral pose + lid clip). ``face`` rotates the model; ``gate`` =
|
|
165
|
+
``(flag_index, require_set)`` makes the chest's APPEARANCE story-gated. Returns new ``.eb`` bytes."""
|
|
166
|
+
if (item is None) == (gil is None):
|
|
167
|
+
raise ValueError("inject_chest needs exactly one of item= or gil=")
|
|
168
|
+
model_id, neutral_pose, open_pose, closed_pose, lid_anim = resolve_chest_variant(model)
|
|
169
|
+
if item is not None:
|
|
170
|
+
item_id = _items.resolve(item)
|
|
171
|
+
give, payload = _event.give_item(item_id, count), item_id
|
|
172
|
+
else:
|
|
173
|
+
give, payload = _event.give_gil(int(gil)), int(gil)
|
|
174
|
+
init = build_chest_init(x=int(x), z=int(z), flag_idx=flag_idx, model=model_id, face=int(face), gate=gate,
|
|
175
|
+
neutral_pose=neutral_pose, open_pose=open_pose, closed_pose=closed_pose)
|
|
176
|
+
openb = build_chest_open(flag_idx, give=give, received_text_id=received_text_id, payload_value=payload,
|
|
177
|
+
open_anim=lid_anim, open_pose=open_pose)
|
|
178
|
+
if len(openb) < 9: # IsActuallyTalkable polls tag3[ip+7/8]; keep it >= 9 bytes
|
|
179
|
+
openb += b"\x00" * (9 - len(openb))
|
|
180
|
+
table_len = 2 * 4
|
|
181
|
+
table = struct.pack("<HH", 0, table_len) + struct.pack("<HH", 3, table_len + len(init))
|
|
182
|
+
entry = bytes([_npc.NPC_ENTRY_TYPE, 2]) + table + init + openb # type-2 object: tag 0 + tag 3
|
|
183
|
+
from . import object as _object
|
|
184
|
+
out, slot = _object.seat_entry(data, entry, reserve_party_band=reserve_party_band)
|
|
185
|
+
return edit.activate(out, opcodes.init_object(slot, 0), spawn_wait_n=spawn_wait_n,
|
|
186
|
+
spawn_wait_occurrence=spawn_wait_occurrence)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Dialogue CHOICES -- show the player a menu of options and branch on the pick.
|
|
2
|
+
|
|
3
|
+
This is the interaction / puzzle primitive: a merchant, a lever with a "Yes/No", a quest-giver. It's
|
|
4
|
+
the conditional-region expression machinery (:mod:`ff9mapkit.content.region`) pointed at the engine's
|
|
5
|
+
choice result instead of a story flag.
|
|
6
|
+
|
|
7
|
+
Grounded BYTE-FOR-BYTE in real FF9 (the Black Mage shop, field 817):
|
|
8
|
+
|
|
9
|
+
WindowSync( 0, 128, 97 ) // the PROMPT + the option rows are ONE text entry:
|
|
10
|
+
// "...Can I help you?[CHOO][MOVE=18,0]Buy/Sell\n...Nothing"
|
|
11
|
+
switch ( GetDialogChoice ) from 0 { // branch on the picked row
|
|
12
|
+
case +0: ... case +1: ... }
|
|
13
|
+
|
|
14
|
+
Engine facts (Memoria source):
|
|
15
|
+
* The window is SYNCHRONOUS (``MES`` 0x1F "wait until it closes"), so the pick is finalised before
|
|
16
|
+
the next opcode runs; ``Dialog`` stores it in ``ETb.sChoose``.
|
|
17
|
+
* A script READS the pick via the expression sysvar token ``B_SYSVAR`` (0x7A) with code 9 ->
|
|
18
|
+
``GetSysvar(9)`` -> ``ETb.GetChoose()`` (0-based row index). See :func:`region.cond_sysvar_eq`.
|
|
19
|
+
* With no ``[PCHC]``/``[PCHM]`` pre-tags the choice count comes from the rows (all enabled), and
|
|
20
|
+
CANCEL (B) returns the LAST row -- so put the "decline" option last.
|
|
21
|
+
|
|
22
|
+
The prompt/option TEXT (the ``[CHOO][MOVE=18,0]`` rows) is assembled in :mod:`ff9mapkit.build`
|
|
23
|
+
(``collect_text``); here we build only the SCRIPT side: the window call + the per-option branch.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from ..eb import opcodes
|
|
29
|
+
from . import event as _event, region as _region
|
|
30
|
+
|
|
31
|
+
# zone-triggered choices auto-allocate a GLOB gate flag from here (clear of events 8000 + cutscene
|
|
32
|
+
# 8100). It must be GLOB (gEventGlobal is large); the per-field MAP array is only 80 bytes, so a high
|
|
33
|
+
# index there is out of bounds and crashes. once-per-visit is done by resetting this flag in the
|
|
34
|
+
# region's Init (re-runs each field load), not by a transient MAP flag.
|
|
35
|
+
CHOICE_FLAG_BASE = 8200
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def option_body(opt: dict, reply_txid: int | None = None) -> bytes:
|
|
39
|
+
"""Compose ONE option's actions (the body run if the player picks it). Reuses the event action
|
|
40
|
+
vocabulary so a choice option does exactly what an event does: an optional reply line, then
|
|
41
|
+
give/take item, gil, set a story flag, optionally advance the ScenarioCounter, and (LAST) WARP to
|
|
42
|
+
another field. Order: reply -> give_item -> remove_item -> gil -> set_flag -> set_scenario -> warp.
|
|
43
|
+
``warp`` is last because a Field op transitions away (anything after it is unreachable) -- this is the
|
|
44
|
+
World-Hub journey-pick primitive: a menu row that seeds the beat then warps into the chosen field."""
|
|
45
|
+
parts = []
|
|
46
|
+
if reply_txid is not None:
|
|
47
|
+
parts.append(_event.message(reply_txid))
|
|
48
|
+
if "give_item" in opt:
|
|
49
|
+
gi = opt["give_item"]
|
|
50
|
+
parts.append(_event.give_item(gi[0], int(gi[1]) if len(gi) > 1 else 1)) # gi[0] = id or name
|
|
51
|
+
if "remove_item" in opt:
|
|
52
|
+
ri = opt["remove_item"]
|
|
53
|
+
parts.append(_event.take_item(ri[0], int(ri[1]) if len(ri) > 1 else 1)) # symmetric: a trade option
|
|
54
|
+
if "gil" in opt:
|
|
55
|
+
parts.append(_event.give_gil(int(opt["gil"])))
|
|
56
|
+
if "set_flag" in opt:
|
|
57
|
+
sf = opt["set_flag"]
|
|
58
|
+
parts.append(_event.set_flag(int(sf[0]), int(sf[1]) if len(sf) > 1 else 1))
|
|
59
|
+
if "set_scenario" in opt:
|
|
60
|
+
parts.append(_event.set_scenario(int(opt["set_scenario"])))
|
|
61
|
+
if "warp" in opt:
|
|
62
|
+
# A choice that warps is ALWAYS a field transition, so it fades out first (fade=True) -- exactly
|
|
63
|
+
# like a gateway/ladder. Without the fade the destination loads in the clear and you see its
|
|
64
|
+
# camera-init frames (the World-Hub static-screen bug). entrance (optional) sets the arrival
|
|
65
|
+
# entrance var; it is not the camera fix (the fade is) -- see event.warp.
|
|
66
|
+
parts.append(_event.warp(int(opt["warp"]), entrance=opt.get("entrance"), fade=True)) # LAST: away
|
|
67
|
+
return b"".join(parts)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _gated(o: dict) -> bool:
|
|
71
|
+
"""An option whose visibility depends on a story flag at runtime (flag-gated hide)."""
|
|
72
|
+
return "requires_flag" in o or "requires_flag_clear" in o
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def dynamic_mask_setup(options, default: int) -> bytes:
|
|
76
|
+
"""Build the availability mask AT RUNTIME from per-option story flags, then point
|
|
77
|
+
``EnableDialogChoices`` at it -- the real-field pattern (Dali/Storage's moogle-mail menu:
|
|
78
|
+
``set_var`` the base, ``if(flag) or_var`` each conditional bit, then ``EnableDialogChoices(VAR | .., 0)``).
|
|
79
|
+
|
|
80
|
+
Always-visible rows form the base word; each flag-gated row ORs its bit in only when its condition
|
|
81
|
+
holds (``requires_flag`` -> visible when SET, ``requires_flag_clear`` -> visible when CLEAR). The
|
|
82
|
+
mask lives in a high scratch word (``region.MASK_SCRATCH_IDX``) and is read back as an UNSIGNED
|
|
83
|
+
UInt16 expression-arg (no sign trap). Statically ``disabled`` rows are simply never ORed in."""
|
|
84
|
+
base = sum(1 << i for i, o in enumerate(options) if not o.get("disabled") and not _gated(o))
|
|
85
|
+
parts = [_region.set_var(_region.GLOB_UINT16, _region.MASK_SCRATCH_IDX, base)]
|
|
86
|
+
for i, o in enumerate(options):
|
|
87
|
+
if o.get("disabled") or not _gated(o):
|
|
88
|
+
continue
|
|
89
|
+
if "requires_flag" in o:
|
|
90
|
+
cond = _region.cond_truthy(_region.GLOB_BOOL, int(o["requires_flag"]))
|
|
91
|
+
else:
|
|
92
|
+
cond = _region.cond_not(_region.GLOB_BOOL, int(o["requires_flag_clear"]))
|
|
93
|
+
parts.append(_region.if_block(cond, _region.or_var(_region.GLOB_UINT16, _region.MASK_SCRATCH_IDX, 1 << i)))
|
|
94
|
+
mask_expr = _region.var_expr(_region.GLOB_UINT16, _region.MASK_SCRATCH_IDX)
|
|
95
|
+
parts.append(opcodes.enable_dialog_choices_var(mask_expr, default))
|
|
96
|
+
return b"".join(parts)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def pre_choose(ch: dict) -> tuple[bytes, str]:
|
|
100
|
+
"""Pre-choose config for a choice: which row is highlighted by DEFAULT, which row CANCEL (B) picks,
|
|
101
|
+
and which options are HIDDEN (statically via ``disabled``, or flag-gated via ``requires_flag`` /
|
|
102
|
+
``requires_flag_clear``). Returns ``(setup_bytes, text_tag)`` -- ``setup`` runs before the window
|
|
103
|
+
(see :func:`region_body`), ``tag`` is prepended to the choice text. ``(b"", "")`` when nothing is
|
|
104
|
+
configured, so a plain choice stays byte-identical.
|
|
105
|
+
|
|
106
|
+
Mechanism (Memoria ``Dialog.SetupChoose`` + ``ETb.SetChooseParam``): an ``EnableDialogChoices``
|
|
107
|
+
opcode sets the availability mask (bit i = row i shown, LSB-first) + the default row; the
|
|
108
|
+
``[PCHM=count,cancel]`` text tag tells the dialog to APPLY the mask (hidden rows get no widget),
|
|
109
|
+
``[PCHC=count,cancel]`` sets count/cancel/default WITHOUT hiding. ``GetChoose()`` returns the
|
|
110
|
+
ABSOLUTE row index regardless of hides, so the per-option :func:`branch` is unaffected.
|
|
111
|
+
|
|
112
|
+
Three modes: flag-gated (any ``requires_flag``) -> a runtime mask (:func:`dynamic_mask_setup`);
|
|
113
|
+
static-hide (any ``disabled``) -> a literal partial mask; default/cancel only -> a literal all-on
|
|
114
|
+
mask ``(1<<n)-1`` (NOT 0xFFFF, which sign-extends to -1 and breaks ``SetChooseParam``'s
|
|
115
|
+
``while availMask>0`` loop -> default collapses to 0)."""
|
|
116
|
+
options = ch.get("options", [])
|
|
117
|
+
n = len(options)
|
|
118
|
+
default = int(ch.get("default", 0))
|
|
119
|
+
cancel = int(ch["cancel"]) if "cancel" in ch else (n - 1) # engine default cancel = last row
|
|
120
|
+
has_static = any(o.get("disabled") for o in options)
|
|
121
|
+
has_dynamic = any(_gated(o) for o in options)
|
|
122
|
+
if not (has_static or has_dynamic or "default" in ch or "cancel" in ch):
|
|
123
|
+
return b"", "" # nothing configured -> byte-identical
|
|
124
|
+
if has_dynamic:
|
|
125
|
+
return dynamic_mask_setup(options, default), f"[PCHM={n},{cancel}]"
|
|
126
|
+
if has_static:
|
|
127
|
+
mask = sum(1 << i for i in range(n) if not options[i].get("disabled"))
|
|
128
|
+
return opcodes.enable_dialog_choices(mask, default), f"[PCHM={n},{cancel}]"
|
|
129
|
+
return opcodes.enable_dialog_choices((1 << n) - 1, default), f"[PCHC={n},{cancel}]"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def branch(option_bodies) -> bytes:
|
|
133
|
+
"""``if (GetChoose()==0){b0} if (GetChoose()==1){b1} ...`` -- one independent if-block per option
|
|
134
|
+
(exactly how FF9 lays out choice handlers). Options with an empty body emit nothing."""
|
|
135
|
+
out = b""
|
|
136
|
+
for i, body in enumerate(option_bodies):
|
|
137
|
+
if body:
|
|
138
|
+
out += _region.if_block(_region.cond_sysvar_eq(_region.SYSVAR_CHOICE, i), body)
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def region_body(prompt_txid: int, option_bodies, *, window: int = 1, flags: int = 128,
|
|
143
|
+
setup: bytes = b"") -> bytes:
|
|
144
|
+
"""The choice block usable in ANY trigger context (an NPC talk OR a walk-in region): lock the
|
|
145
|
+
player, (optional pre-choose ``setup``), open the prompt+options window, branch on the pick, restore
|
|
146
|
+
control. **No RETURN** -- the caller adds it (NPC) or wraps it in a flag-gated region body (zone).
|
|
147
|
+
|
|
148
|
+
``setup`` is the optional ``EnableDialogChoices`` opcode from :func:`pre_choose` (default/cancel/
|
|
149
|
+
disabled config); it MUST run before the window opens. Why DisableMove/EnableMove: the engine does
|
|
150
|
+
NOT block field movement while a dialog is open, so without this the d-pad would move BOTH the menu
|
|
151
|
+
cursor AND the character. Real FF9 wraps a choice in DisableMove...EnableMove (e.g. the Black Mage
|
|
152
|
+
shop), and the menu still navigates because choice input comes from the dialog system, not field
|
|
153
|
+
control."""
|
|
154
|
+
return (opcodes.DISABLE_MOVE + setup + opcodes.window_sync(window, flags, prompt_txid)
|
|
155
|
+
+ branch(option_bodies) + opcodes.ENABLE_MOVE)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def speak_body(prompt_txid: int, option_bodies, *, window: int = 1, flags: int = 128,
|
|
159
|
+
setup: bytes = b"") -> bytes:
|
|
160
|
+
"""A complete ``_SpeakBTN`` (NPC talk) body for a choice: the choice block + RETURN. ``flags`` 128
|
|
161
|
+
is the standard field dialogue flag (same as plain NPC dialogue). ``setup`` = optional pre-choose
|
|
162
|
+
opcode (see :func:`pre_choose`)."""
|
|
163
|
+
return region_body(prompt_txid, option_bodies, window=window, flags=flags, setup=setup) + opcodes.RETURN
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Multi-actor cutscene CONDUCTOR -- FF9's central-director idiom (memory project-ff9-cutscene-multiactor).
|
|
2
|
+
|
|
3
|
+
A from-9-real-fields study (565/909/2554/2207/2169 ...) found FF9 coordinates 2+ scripted actors with a
|
|
4
|
+
single CONDUCTOR function -- NOT a per-actor flag handshake. ONE function (a code entry armed by
|
|
5
|
+
``InitCode`` in Main_Init) owns the control lock and sequences every actor, addressing each one BY UID via
|
|
6
|
+
the ``*Ex`` opcode family (``WindowSyncEx`` / ``TimedTurnEx`` / ``RunAnimationEx`` ...) so it never has to
|
|
7
|
+
context-switch into the actor. An actor's UID is its entry slot (``sid``); the player/control character is
|
|
8
|
+
uid 250 (the engine remaps 250 -> the active control uid). Timing between beats is a plain ``Wait``.
|
|
9
|
+
|
|
10
|
+
This module is the INCREMENT-1 director: SEQUENTIAL beats from a flat, actor-tagged ``steps`` list --
|
|
11
|
+
``say`` / ``turn`` / ``anim`` driven on a named actor by id, plus ``wait`` / ``set_flag`` at the conductor
|
|
12
|
+
level. Deliberately deferred (later increments, see the memory):
|
|
13
|
+
* animated WALK -- needs ``RunScript`` into a per-actor walk tag (base ``Walk`` only animates in the
|
|
14
|
+
actor's own tag-1 LOOP, state 1), so it's a bigger change than an inline ``*Ex`` op;
|
|
15
|
+
* PARALLEL beats ("these run together") -- FF9 forks with ``RunScriptAsync`` and joins on the script
|
|
16
|
+
*level* (``WaitSharedScript`` only joins the ONE shared script the SAME object spawned, not a global
|
|
17
|
+
barrier), which needs its own grounding pass.
|
|
18
|
+
|
|
19
|
+
Softlock care, mirroring the kit's existing cutscene rules on player-cloned actors:
|
|
20
|
+
* ``turn`` uses ``TurnInstantEx`` (instant, no wait) -- never ``WaitTurnEx`` (a player clone's turn anim
|
|
21
|
+
may not drive the wait to completion -> hang);
|
|
22
|
+
* ``anim`` uses ``RunAnimationEx`` + a fixed ``Wait`` hold -- never ``WaitAnimationEx`` (same hang risk).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import struct
|
|
28
|
+
|
|
29
|
+
from ..eb import EbScript, cmdasm, edit, opcodes
|
|
30
|
+
from . import object as _object # seat_entry (verbatim below-party-band seating)
|
|
31
|
+
from . import region as _region
|
|
32
|
+
from . import event as _event
|
|
33
|
+
from . import cutscene as _cutscene # shared: once_flag_for / DEFAULT_WARMUP / ANIM_HOLD / REORDER_WAIT / say
|
|
34
|
+
|
|
35
|
+
PLAYER_UID = _cutscene.PLAYER_UID # 250 -> the engine's control-character sentinel
|
|
36
|
+
|
|
37
|
+
# Spin-wait (frames) for the field/engine to grant control before locking. A field's entry transition
|
|
38
|
+
# (fade + scrolling-camera settle) RE-ENABLES control at a frame a fixed warmup can't predict -- in-game
|
|
39
|
+
# (2026-06-28) the player could walk + dismiss the first dialogue while the camera settled, then lost
|
|
40
|
+
# control. So: DisableMove, then SPIN until the engine RE-grants control, then DisableMove again -- the
|
|
41
|
+
# re-lock lands AFTER the grant and holds. Capped so a field that never re-grants can't hang.
|
|
42
|
+
CONTROL_POLL_CAP = 90 # frames (~3s) -- the entry settle is ~0.5-1.5s; this is a safe ceiling
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def wait_for_control_then_lock(cap: int = CONTROL_POLL_CAP) -> bytes:
|
|
46
|
+
"""Bytecode that spins until ``IsMovementEnabled`` (sysvar 2) becomes true -- the field/engine's entry
|
|
47
|
+
control-grant -- then ``DisableMove`` so the lock lands AFTER that grant. Used AFTER an initial
|
|
48
|
+
DisableMove, so it waits for the engine to RE-enable control (its entry-transition grant) and re-locks.
|
|
49
|
+
|
|
50
|
+
Unrolled (no loop counter -> no MAP-byte out-of-bounds risk): ``cap`` copies of
|
|
51
|
+
``push IsMovementEnabled; JMP_IF granted; Wait(1)`` then ``granted: DisableMove``. Each check exits early
|
|
52
|
+
via a forward ``JMP_IF`` the moment control is granted; if it's never granted the block falls through
|
|
53
|
+
after ``cap`` frames and locks anyway. Assembled with :func:`cmdasm.assemble_block` (resolves the jumps)."""
|
|
54
|
+
lines = []
|
|
55
|
+
for _ in range(int(cap)):
|
|
56
|
+
lines.append("SET({B_SYSVAR[2] B_EXPR_END})") # push IsMovementEnabled (engine usercontrol)
|
|
57
|
+
lines.append("JMP_IF(granted)") # granted -> stop spinning
|
|
58
|
+
lines.append("op_22(1)") # else wait one frame and re-check
|
|
59
|
+
lines += ["granted:", "DisableMove()"] # lock NOW (after the grant) -- the lock that sticks
|
|
60
|
+
return cmdasm.assemble_block("\n".join(lines))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _uid_for(name, uid_by_name):
|
|
64
|
+
"""Resolve an actor NAME to its runtime UID: ``"player"`` -> 250 (control char); else its entry slot."""
|
|
65
|
+
if name == "player":
|
|
66
|
+
return PLAYER_UID
|
|
67
|
+
return uid_by_name.get(name)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def actor_say(uid: int, text_id: int, *, flags: int = 128) -> bytes:
|
|
71
|
+
"""Step: the actor at ``uid`` speaks ``text_id`` -- ``WindowSyncEx(uid, 0, flags, txid)`` (the window
|
|
72
|
+
is attributed to that actor by id, so its tail points at them). Blocks until dismissed."""
|
|
73
|
+
return opcodes.window_sync_ex(uid, 0, flags, int(text_id))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def actor_turn(uid: int, angle: int) -> bytes:
|
|
77
|
+
"""Step: face ``angle`` INSTANTLY (0=S, 64=W, 128=N, 192=E) -- ``TurnInstantEx(uid, angle)``. Instant
|
|
78
|
+
(no ``WaitTurnEx``) so it never hangs on a player-cloned actor whose turn anim doesn't complete."""
|
|
79
|
+
return opcodes.turn_instant_ex(uid, int(angle))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def actor_anim(uid: int, anim: int, hold: int = _cutscene.ANIM_HOLD) -> bytes:
|
|
83
|
+
"""Step: play ``anim`` on the actor then hold ``hold`` frames -- ``RunAnimationEx(uid, anim)`` + a fixed
|
|
84
|
+
``Wait`` (NOT ``WaitAnimationEx``, which hangs if the clip doesn't drive the wait to completion)."""
|
|
85
|
+
return opcodes.run_animation_ex(uid, int(anim)) + opcodes.wait(int(hold))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
WALK_LEVEL = 2 # RunScript script-level for a walk call (matches real fields 565/909/2554)
|
|
89
|
+
WALK_TAG_BASE = 20 # first walk-choreography tag added to an actor's entry (clear of inject_npc's 0/1/3)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def compile_steps(steps, uid_by_name, txids, *, say_flags: int = 128, relock: bool = False,
|
|
93
|
+
walk_calls=None) -> bytes:
|
|
94
|
+
"""Compile actor-tagged conductor steps to bytes. Each step is a dict with at most one action:
|
|
95
|
+
``say`` / ``turn`` / ``anim`` / ``walk`` (need an ``actor = "<name>"``; ``say`` without an actor = a
|
|
96
|
+
narration line), or ``wait`` / ``set_flag`` (conductor-level, no actor). ``say`` steps consume ``txids``
|
|
97
|
+
in order. The actor name resolves to a UID via ``uid_by_name`` (or ``"player"`` -> 250).
|
|
98
|
+
|
|
99
|
+
A ``walk`` step compiles to ``RunScriptSync(2, uid, tag)`` -- the conductor can't walk an actor inline
|
|
100
|
+
(base ``Walk`` acts on the EXECUTING object and there is no targeted WalkEx), so the caller pre-generates
|
|
101
|
+
a walk-choreography tag on the actor's OWN entry (``edit.add_function``) and passes ``walk_calls`` (a
|
|
102
|
+
dict ``step_index -> (uid, tag)``); RunScriptSync runs it in the actor's context (so it animates) and
|
|
103
|
+
BLOCKS until it returns (sequential). ``walk_calls`` is required iff any step has ``walk``.
|
|
104
|
+
|
|
105
|
+
``relock`` prefixes every beat with ``DisableMove`` -- see :func:`build_body` for why the entry control
|
|
106
|
+
grant needs the spin-lock + per-beat re-lock."""
|
|
107
|
+
walk_calls = walk_calls or {}
|
|
108
|
+
out, ti = [], 0
|
|
109
|
+
for i, s in enumerate(steps):
|
|
110
|
+
if relock:
|
|
111
|
+
out.append(opcodes.DISABLE_MOVE) # re-lock: undo any entry-transition control re-grant
|
|
112
|
+
name = s.get("actor")
|
|
113
|
+
uid = _uid_for(name, uid_by_name) if name else None
|
|
114
|
+
if "say" in s:
|
|
115
|
+
out.append(actor_say(uid, txids[ti], flags=say_flags) if uid is not None
|
|
116
|
+
else _cutscene.say(txids[ti], flags=say_flags))
|
|
117
|
+
ti += 1
|
|
118
|
+
elif "wait" in s:
|
|
119
|
+
out.append(opcodes.wait(int(s["wait"])))
|
|
120
|
+
elif "set_flag" in s:
|
|
121
|
+
sf = s["set_flag"]
|
|
122
|
+
out.append(_cutscene.set_flag(int(sf[0]), int(sf[1]) if len(sf) > 1 else 1))
|
|
123
|
+
elif "turn" in s:
|
|
124
|
+
if uid is None:
|
|
125
|
+
raise ValueError(f"conductor step {s!r}: turn needs actor = \"<name>\"")
|
|
126
|
+
out.append(actor_turn(uid, s["turn"]))
|
|
127
|
+
elif "anim" in s:
|
|
128
|
+
if uid is None:
|
|
129
|
+
raise ValueError(f"conductor step {s!r}: anim needs actor = \"<name>\"")
|
|
130
|
+
out.append(actor_anim(uid, s["anim"]))
|
|
131
|
+
elif "walk" in s:
|
|
132
|
+
if i not in walk_calls:
|
|
133
|
+
raise ValueError(f"conductor step {s!r}: walk needs a pre-generated walk tag (walk_calls)")
|
|
134
|
+
w_uid, w_tag = walk_calls[i]
|
|
135
|
+
out.append(opcodes.run_script_sync(WALK_LEVEL, w_uid, w_tag)) # run the actor's walk tag, block
|
|
136
|
+
else:
|
|
137
|
+
raise ValueError(f"unknown conductor step: {s!r}")
|
|
138
|
+
return b"".join(out)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def walk_tag_body(x: int, z: int, speed: int | None = None) -> bytes:
|
|
142
|
+
"""The body of a per-actor WALK tag, run via RunScript in the actor's own context (gExec == the actor,
|
|
143
|
+
so base ``Walk`` moves IT and animates). Reuses the kit's actor-walk recipe (SetWalkTurnSpeed(255) +
|
|
144
|
+
StopAnimation + InitWalk + Walk -- no WaitTurn/WaitAnimation, which hang on a player clone) + a RETURN
|
|
145
|
+
so the blocking ``RunScriptSync`` caller unblocks on arrival.
|
|
146
|
+
|
|
147
|
+
A base ``Walk`` SELF-BLOCKS until arrival, so if another actor's collision box sits in the path it never
|
|
148
|
+
arrives => softlock (in-game 2026-06-28: a walk into a neighbor locked). The cure is faithful STAGING --
|
|
149
|
+
author clear paths between an actor's start and its target (real FF9 does the same; the kit can't reroute
|
|
150
|
+
around live actors). A ``SetPathing(0)`` collision-off wrap was tried and dropped: off the walkmesh the
|
|
151
|
+
Walk can fail to register arrival (a different hang) and the actor can drift off-mesh -- clean spacing is
|
|
152
|
+
both safer and how the real game stages cutscene walks."""
|
|
153
|
+
return _cutscene.actor_walk(int(x), int(z), speed) + opcodes.RETURN
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def build_body(steps, uid_by_name, txids, once_flag: int | None, *, flag_class=_region.GLOB_BOOL,
|
|
157
|
+
warmup: int = _cutscene.DEFAULT_WARMUP, owns_control: bool = True,
|
|
158
|
+
exit_warp: int | None = None, say_flags: int = 128,
|
|
159
|
+
reorder: int = _cutscene.REORDER_WAIT, walk_calls=None) -> bytes:
|
|
160
|
+
"""The conductor function body, run from a standalone ``InitCode``-armed code entry.
|
|
161
|
+
|
|
162
|
+
Shape: ``[Wait(reorder)] [DisableMove] [Wait(warmup)] <beats> [EnableMove]`` gated
|
|
163
|
+
``if (!once_flag) { ...; once_flag = 1 }`` when ``once_flag`` is set. The leading ``reorder`` Wait lets
|
|
164
|
+
Main_Init reach its own ``EnableMove`` first so the conductor's ``DisableMove`` is the last control-setter
|
|
165
|
+
(the lock sticks); the ``warmup`` Wait (after the lock, so the player can't wander) lets the field's entry
|
|
166
|
+
fade settle AND lets the actor objects finish spawning before the conductor addresses them by uid.
|
|
167
|
+
|
|
168
|
+
``exit_warp`` (a field id) ends the scene with a fade-to-black ``Field(exit_warp)`` instead of restoring
|
|
169
|
+
control (the warp sits OUTSIDE the once-gate so it always fires); the fade avoids the destination loading
|
|
170
|
+
in the clear. With ``exit_warp`` set, no ``EnableMove`` is emitted (the destination restores control).
|
|
171
|
+
|
|
172
|
+
Control lock (the load-bearing part -- two in-game iterations to get right, 2026-06-28): a fixed warmup
|
|
173
|
+
can't beat the field's entry control-grant, which re-enables control as the fade + scrolling-camera settle
|
|
174
|
+
finish (the player could walk + dismiss the first window mid-settle). So under ``owns_control`` the conductor
|
|
175
|
+
(1) ``DisableMove`` immediately, (2) ``wait_for_control_then_lock`` -- SPINS until the engine RE-grants
|
|
176
|
+
control, then ``DisableMove`` again so the lock lands AFTER the grant -- and (3) ``compile_steps(relock=True)``
|
|
177
|
+
re-locks before every beat as a backstop. The spin doubles as the actor-spawn settle (it runs ~until the
|
|
178
|
+
grant, by which point the InitObject'd actors exist for the by-id ``*Ex`` ops)."""
|
|
179
|
+
inner = opcodes.wait(int(reorder)) if reorder and reorder > 0 else b""
|
|
180
|
+
if owns_control:
|
|
181
|
+
inner += opcodes.DISABLE_MOVE # disable, so the spin waits for the engine's RE-grant
|
|
182
|
+
inner += wait_for_control_then_lock() # ... spin to that grant, then re-lock (the lock that holds)
|
|
183
|
+
elif warmup > 0:
|
|
184
|
+
inner += opcodes.wait(int(warmup)) # no lock: still settle so the actors exist before the beats
|
|
185
|
+
inner += compile_steps(steps, uid_by_name, txids, say_flags=say_flags, relock=owns_control,
|
|
186
|
+
walk_calls=walk_calls)
|
|
187
|
+
if owns_control and exit_warp is None:
|
|
188
|
+
inner += opcodes.ENABLE_MOVE
|
|
189
|
+
if once_flag is not None:
|
|
190
|
+
inner += _region.set_var(flag_class, once_flag, 1)
|
|
191
|
+
body = _region.if_block(_region.cond_not(flag_class, once_flag), inner)
|
|
192
|
+
else:
|
|
193
|
+
body = inner
|
|
194
|
+
if exit_warp is not None:
|
|
195
|
+
body += _event.warp(int(exit_warp), fade=True)
|
|
196
|
+
return body + opcodes.RETURN
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def inject_conductor(data, steps, uid_by_name, txids, *, once_flag: int | None = None,
|
|
200
|
+
flag_class=_region.GLOB_BOOL, warmup: int = _cutscene.DEFAULT_WARMUP,
|
|
201
|
+
owns_control: bool = True, exit_warp: int | None = None, say_flags: int = 128,
|
|
202
|
+
walk_calls=None, reserve_party_band: bool = False,
|
|
203
|
+
spawn_wait_n: int = 2, spawn_wait_occurrence: int = 0) -> bytes:
|
|
204
|
+
"""Seat the conductor as a single-function code entry and arm it via ``InitCode`` in Main_Init (over a
|
|
205
|
+
Wait filler), exactly like a narration cutscene. Returns new .eb bytes. ``walk_calls`` (a dict
|
|
206
|
+
``step_index -> (uid, tag)``) maps each ``walk`` step to its pre-generated per-actor walk tag.
|
|
207
|
+
|
|
208
|
+
``reserve_party_band``: on a VERBATIM fork the donor's last 9 slots are the playable characters, so the
|
|
209
|
+
conductor INSERTS just below them (``object.seat_entry``) -- keeping the band as the top slots and not
|
|
210
|
+
perturbing the actors it addresses (which seat below the band before it, so their uids stay valid)."""
|
|
211
|
+
body = build_body(steps, uid_by_name, txids, once_flag, flag_class=flag_class, warmup=warmup,
|
|
212
|
+
owns_control=owns_control, exit_warp=exit_warp, say_flags=say_flags,
|
|
213
|
+
walk_calls=walk_calls)
|
|
214
|
+
entry = bytes([0x00, 0x01]) + struct.pack("<HH", 0, 4) + body
|
|
215
|
+
out, slot = _object.seat_entry(data, entry, reserve_party_band=reserve_party_band)
|
|
216
|
+
return edit.activate(out, opcodes.init_code(slot, 0), spawn_wait_n=spawn_wait_n,
|
|
217
|
+
spawn_wait_occurrence=spawn_wait_occurrence)
|