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,75 @@
|
|
|
1
|
+
"""Place a static set-dressing PROP -- the real FF9 prop recipe (no emulation; replicated from bytes).
|
|
2
|
+
|
|
3
|
+
A prop is the NPC object MINUS the character behaviours: it holds a single static pose and does NOT turn
|
|
4
|
+
to face the player. Verified byte-for-byte against shipping fields -- the save-moogle (field 300, entry 5)
|
|
5
|
+
and the chest (field 115, entry 9) -- whose Init does:
|
|
6
|
+
|
|
7
|
+
SetModel + CreateObject + SetStandAnimation(<pose>) + SetObjectFlags(..) + EnableHeadFocus(0)
|
|
8
|
+
|
|
9
|
+
`EnableHeadFocus(0)` (engine source: "Enable or disable the character turning his head toward an active
|
|
10
|
+
object") is exactly the switch that kills the turn-to-player behaviour an NPC has. So a prop is just the
|
|
11
|
+
proven NPC injection (:func:`content.npc.inject_npc`) with the static pose in all gesture slots plus that
|
|
12
|
+
tail appended to Init -- we add nothing the engine doesn't already do for its own props.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from ..eb import EbScript, opcodes
|
|
17
|
+
from .npc import ANIM_ORDER, inject_npc
|
|
18
|
+
|
|
19
|
+
ENABLE_HEAD_FOCUS = 0x47 # "Enable or disable the character turning his head toward an active object"
|
|
20
|
+
TURN_INSTANT = 0x36
|
|
21
|
+
ATTACH_OBJECT = 0x4C # "Attach an object to another one" -- AttachObject(attachedUid, carryingUid, bone)
|
|
22
|
+
SET_OBJECT_FLAGS = 0x93 # bits: 1 show model, 2 collide player, 4 collide NPC, 8 disable talk
|
|
23
|
+
HELD_FLAGS = 7 # show + collide + collideNPC -- the flags the shipping held cup sets
|
|
24
|
+
# NB: do NOT blanket-apply SetObjectFlags here. Per the engine (EventEngine.DoEventCode, CFLAG 0x93) the
|
|
25
|
+
# flag bits are {1: show model, 2: collide player, 4: collide NPC, 8: disable talk, ...} and it REPLACES
|
|
26
|
+
# the object's low 6 bits. The shipping props' SetObjectFlags(14) (= 2+4+8) omits bit 1 -> "show model"
|
|
27
|
+
# off -> the prop vanishes (in-game-verified: adding it hid all four props). Our prop is a cloned player
|
|
28
|
+
# object that is already shown + collidable, so we only need to kill head-tracking. A future
|
|
29
|
+
# interactivity option can add a SHOW-preserving flag (e.g. 1|8 to also disable the talk prompt).
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def prop_init_tail(face: int | None = None) -> bytes:
|
|
33
|
+
"""The bytes a prop's Init runs after CreateObject: disable head-tracking (+ an optional instant
|
|
34
|
+
facing). Mirrors the shipping save-moogle / chest objects minus the model-hiding SetObjectFlags."""
|
|
35
|
+
tail = opcodes.encode(ENABLE_HEAD_FOCUS, 0) # EnableHeadFocus(0): no turn-to-face
|
|
36
|
+
if face:
|
|
37
|
+
tail += opcodes.encode(TURN_INSTANT, int(face) & 0xFF) # TurnInstant(face)
|
|
38
|
+
return tail
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def inject_prop(data, x: int, z: int, *, model: int, pose: int, face: int | None = None,
|
|
42
|
+
dialogue_text_id: int | None = None, slot: int | None = None,
|
|
43
|
+
attach_to: int | None = None, bone: int = 11,
|
|
44
|
+
spawn_wait_n: int = 2, spawn_wait_occurrence: int = 0,
|
|
45
|
+
gate_flag: int | None = None, gate_require_set: bool = True,
|
|
46
|
+
reserve_party_band: bool = False) -> bytes:
|
|
47
|
+
"""Place a prop ``model`` at world (x, z), held at ``pose`` (an animation id), head-tracking OFF.
|
|
48
|
+
|
|
49
|
+
``attach_to`` (a carrying object's uid = its entry slot) binds this prop to that object's ``bone``
|
|
50
|
+
so it follows it -- the real held-item recipe ``AttachObject(self_uid, carrier_uid, bone)`` (bone
|
|
51
|
+
defaults to 11, the right hand the shipping cup uses). The prop's own uid IS its entry slot, so the
|
|
52
|
+
slot is resolved up front. ``dialogue_text_id`` makes it readable; ``face``/``gate_flag`` as usual.
|
|
53
|
+
``reserve_party_band`` (the VERBATIM-fork path) seats the prop BELOW the party-character band (only for
|
|
54
|
+
a STATIC prop; an ``attach_to`` held item resolves its slot up front and is unsupported there).
|
|
55
|
+
Returns new ``.eb`` bytes."""
|
|
56
|
+
anims = {k: pose for k in ANIM_ORDER} # all five gesture slots = the (held) pose
|
|
57
|
+
if attach_to is not None: # ATTACHED: bind to the carrier's bone
|
|
58
|
+
if reserve_party_band:
|
|
59
|
+
raise ValueError("inject_prop: attach_to (held item) is not supported with reserve_party_band "
|
|
60
|
+
"(its uid is its slot, resolved before the band-aware insert)")
|
|
61
|
+
if slot is None:
|
|
62
|
+
slot = EbScript.from_bytes(data).first_free_slot() # the prop's uid == its slot (= attachedUid)
|
|
63
|
+
tail = opcodes.encode(ATTACH_OBJECT, slot, int(attach_to), int(bone))
|
|
64
|
+
tail += opcodes.encode(SET_OBJECT_FLAGS, HELD_FLAGS) # show + collide (like the shipping cup)
|
|
65
|
+
else: # STATIC: just kill head-tracking
|
|
66
|
+
tail = prop_init_tail(face)
|
|
67
|
+
# a non-interactive prop is BARE (Init-only, no tag-3 talk func -> the engine's IsActuallyTalkable
|
|
68
|
+
# short-circuits instead of indexing past it = no per-frame IndexOutOfRange). A prop with dialogue
|
|
69
|
+
# keeps a real tag-3 WindowSync so it stays readable.
|
|
70
|
+
return inject_npc(data, x, z, model=model, anims=anims,
|
|
71
|
+
talk_text_id=(dialogue_text_id if dialogue_text_id is not None else 62),
|
|
72
|
+
init_tail=tail, slot=slot, bare=(dialogue_text_id is None),
|
|
73
|
+
spawn_wait_n=spawn_wait_n, spawn_wait_occurrence=spawn_wait_occurrence,
|
|
74
|
+
gate_flag=gate_flag, gate_require_set=gate_require_set,
|
|
75
|
+
reserve_party_band=reserve_party_band)
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""General conditional region triggers + the field-script flag/expression primitives.
|
|
2
|
+
|
|
3
|
+
This is the kit's first *authored-logic* injector. Every other content module stamps a canned
|
|
4
|
+
block (an exit, an NPC, an encounter); this one builds a region whose ``_Range`` trigger runs a
|
|
5
|
+
flag-gated body you compose -- the reusable primitive behind multi-camera switch zones (see
|
|
6
|
+
:mod:`ff9mapkit.content.camera`) and, by the same shape, chests / story flags / one-shot events
|
|
7
|
+
(``if (!done) { give...; set done = 1 }``).
|
|
8
|
+
|
|
9
|
+
Everything here is grounded BYTE-FOR-BYTE in real FF9 field bytecode -- decoded from the camera
|
|
10
|
+
switch regions of Gargan Roo/Passage (``evt_gargan_gr_lef_0``) + the field-109 exit region. The
|
|
11
|
+
field event "expression" sub-language (opcode ``0x05``) is a little RPN stack terminated by ``0x7F``:
|
|
12
|
+
|
|
13
|
+
push a variable : <class> <idx> class 0xD5 = GlobUInt8, 0xC5 = GlobBool
|
|
14
|
+
push a constant : 0x7D <i16>
|
|
15
|
+
operators : 0x0E = logical NOT, 0x20 = '==', 0x2C = '=' (assign)
|
|
16
|
+
end : 0x7F
|
|
17
|
+
|
|
18
|
+
So ``set V = k`` -> ``05 <cls> <idx> 7D <k:i16> 2C 7F``
|
|
19
|
+
``if (V)`` -> ``05 <cls> <idx> 7F`` (truthy)
|
|
20
|
+
``if (!V)`` -> ``05 <cls> <idx> 0E 7F``
|
|
21
|
+
``if (V == k)``-> ``05 <cls> <idx> 7D <k:i16> 20 7F``
|
|
22
|
+
|
|
23
|
+
Control flow uses two relative jumps whose operand is the byte length of the block they skip:
|
|
24
|
+
``0x02`` = jump-if-FALSE (the ``if`` skip), ``0x03`` = jump-if-TRUE (used by ``ifnot``). A region
|
|
25
|
+
entry is engine type ``1`` with an Init func (tag 0 = ``SetRegion`` polygon) and a Range func
|
|
26
|
+
(tag 2 = the trigger body, which only runs while ``usercontrol == 1``).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import struct
|
|
32
|
+
|
|
33
|
+
from ..eb import EbScript, edit, opcodes
|
|
34
|
+
|
|
35
|
+
# --- expression var classes (the engine's variable scopes) ---
|
|
36
|
+
# A var token byte = 0xC0 | (VariableType << 2) | VariableSource (EBin.getVarOperation). CRITICAL: the
|
|
37
|
+
# SOURCE decides PERSISTENCE -- Global (src 0) reads/writes the SAVE-BACKED gEventGlobal (survives
|
|
38
|
+
# field reloads); Map (src 1) is a PER-FIELD array WIPED on every field load. (HW's naming is
|
|
39
|
+
# inverted: HW "GenBool" = engine Global = persistent; HW "GlobBool" = engine Map = transient.)
|
|
40
|
+
GLOB_BOOL = 0xC4 # Global + Bit -> SAVE-PERSISTENT bool (story flags, chest "once", etc.)
|
|
41
|
+
MAP_BOOL = 0xC5 # Map + Bit -> transient per-field bool (resets on reload; rarely what you want)
|
|
42
|
+
GLOB_UINT8 = 0xD5 # Map + Byte -> transient byte (the camera-switch flag; reset per load by design)
|
|
43
|
+
GLOB_BYTE = 0xD4 # Global + Byte -> SAVE-BACKED single byte: writes ONLY gEventGlobal[idx] (one byte),
|
|
44
|
+
# unlike GLOB_UINT16 which spans [idx, idx+1] and zeroes the neighbour. The engine
|
|
45
|
+
# reads e.g. the Pandemonium lift-config bytes 361/362 this way (token 0xF4 =
|
|
46
|
+
# 0xD4 | the long-index bit). Use this to seed ONE byte without clobbering the next.
|
|
47
|
+
GLOB_UINT16 = 0xDC # Global + UInt16 -> save-backed 16-bit word. Read via the EXPRESSION path it is
|
|
48
|
+
# UNSIGNED (0..65535, no sign-extension -- EBin.GetVariableValueInternal), so it
|
|
49
|
+
# holds a choice availability mask without the 0xFFFF->-1 sign trap of a literal.
|
|
50
|
+
MAP_INT16 = 0xD9 # Map + Int16 -> transient SIGNED 16-bit (wiped per field load). The navigable
|
|
51
|
+
# ladder's per-frame climb-target scratch (field 706 uses MAP.I16[2]); re-derived
|
|
52
|
+
# from the player's height every frame so its transient value never matters.
|
|
53
|
+
GLOB_INT16 = 0xD8 # Global + Int16. Idx 2 (D8:2) is the engine's ARRIVAL-ENTRANCE var: set it right
|
|
54
|
+
# before Field()/WorldMap() and the destination field's player-init switches on it.
|
|
55
|
+
VAR_CLASSES = {"glob_bool": GLOB_BOOL, "map_bool": MAP_BOOL, "glob_uint8": GLOB_UINT8, "glob_byte": GLOB_BYTE}
|
|
56
|
+
|
|
57
|
+
# A scratch word high in gEventGlobal (byte offset; vars index BYTES, bits index BITS -- so byte 2040
|
|
58
|
+
# is bits 16320+, clear of base-game vars [low offsets] AND the kit's 8000+ bit-flags [bytes ~1000]).
|
|
59
|
+
# Rebuilt every time a choice opens (set_var -> or_var), so its transient value never matters across
|
|
60
|
+
# opens; F10's gEventGlobal reset is harmless to it.
|
|
61
|
+
MASK_SCRATCH_IDX = 2040
|
|
62
|
+
|
|
63
|
+
# --- expression opcodes / tokens ---
|
|
64
|
+
EXPR_OP = 0x05 # expression statement (its single operand is a token stream)
|
|
65
|
+
T_CONST = 0x7D # 0x7D <i16>
|
|
66
|
+
T_NOT = 0x0E
|
|
67
|
+
T_EQ = 0x20
|
|
68
|
+
T_ASSIGN = 0x2C # B_LET ('=')
|
|
69
|
+
T_OR_ASSIGN = 0x3F # B_OR_LET ('|='); real-field verified (Dali/Storage 407: `VAR |= 2` = 05 .. 3F 7F)
|
|
70
|
+
T_LT = 0x18 # B_LT ('<')
|
|
71
|
+
T_ITEMCOUNT = 0x64 # GetItemCount: unary fn token -- pops an item-id const, pushes the held count
|
|
72
|
+
# (real-field verified, Dali/Storage 407 chest guard `GetItemCount(236) < 99`)
|
|
73
|
+
T_SYSVAR = 0x7A # push GetSysvar(<code>) -- EBin.B_SYSVAR (122); reads the next byte as the code
|
|
74
|
+
T_END = 0x7F
|
|
75
|
+
|
|
76
|
+
# Arithmetic / comparison / input operator tokens -- the engine's binary-op opcodes, verified
|
|
77
|
+
# byte-for-byte against field 706's navigable vine climb (see content.ladder.navigable_climb_body).
|
|
78
|
+
T_MULT = 0x11 # B_MULT '*'
|
|
79
|
+
T_DIV = 0x12 # B_DIV '/'
|
|
80
|
+
T_MOD = 0x13 # B_MOD '%' (the ladder anim window: (animFrame+1) % frames)
|
|
81
|
+
T_PLUS = 0x14 # B_PLUS '+'
|
|
82
|
+
T_MINUS = 0x15 # B_MINUS '-'
|
|
83
|
+
T_GT = 0x19 # B_GT '>'
|
|
84
|
+
T_LE = 0x1A # B_LE '<='
|
|
85
|
+
T_GE = 0x1B # B_GE '>='
|
|
86
|
+
T_ANDAND = 0x27 # B_ANDAND '&&'
|
|
87
|
+
T_OROR = 0x28 # B_OROR '||'
|
|
88
|
+
T_KEY = 0x59 # B_KEY: pop a button-mask const, push (mask & held-inputs ? 1 : 0) -- HELD input
|
|
89
|
+
T_KEYON = 0x4F # B_KEYON: pop a button-mask const, push (mask & pressed-THIS-FRAME ? 1 : 0) -- the
|
|
90
|
+
# press EDGE. The ATE "press SELECT" trigger (field 552 [11667]: `1 B_KEYON` with
|
|
91
|
+
# SELECT=EventInput.Select=1u). Edge (not held) so one tap = one open.
|
|
92
|
+
T_OBJVAR = 0x78 # B_OBJSPECA: read an object var -> 78 <uid> <field> (uid 255 = self)
|
|
93
|
+
|
|
94
|
+
# Field-event input button masks (EventInput.cs): the const a B_KEY/B_KEYON token tests against.
|
|
95
|
+
KEY_SELECT = 1 # EventInput.Select (1u) -- the ATE menu trigger
|
|
96
|
+
KEY_START = 8 # EventInput.Start (8u)
|
|
97
|
+
|
|
98
|
+
# A couple of useful system-variable codes (EventEngine.GetSysvar switch): 2 = usercontrol
|
|
99
|
+
# (IsMovementEnabled), 9 = ETb.GetChoose() = the index the player picked in the last choice window.
|
|
100
|
+
SYSVAR_USERCONTROL = 2
|
|
101
|
+
SYSVAR_CHOICE = 9
|
|
102
|
+
JMP_FALSE = 0x02 # jump-if-false 02 <skip:i16>
|
|
103
|
+
JMP_TRUE = 0x03 # jump-if-true 03 <skip:i16>
|
|
104
|
+
SETREGION_OP = 0x29
|
|
105
|
+
REGION_ENTRY_TYPE = 1
|
|
106
|
+
RANGE_TAG = 2 # the player-in-region (tread) trigger func -- runs EVERY frame in the quad
|
|
107
|
+
INTERACT_TAG = 3 # the press-action-while-in-quad func -- fires on the action button (a lever/sign)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _cls(var_class) -> int:
|
|
111
|
+
if isinstance(var_class, str):
|
|
112
|
+
return VAR_CLASSES[var_class]
|
|
113
|
+
return int(var_class) & 0xFF
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _i16(v: int) -> bytes:
|
|
117
|
+
return struct.pack("<h", int(v))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _push_var(var_class, idx: int) -> bytes:
|
|
121
|
+
"""Encode a variable reference token. Index <= 0xFF -> ``<cls> <idx>``; a larger index sets the
|
|
122
|
+
long-index bit (0x20) on the class byte and uses a 2-byte little-endian index, exactly as the
|
|
123
|
+
engine encodes it (EBin.getVarOperation: ``index << 8``, ``| 0x20`` when index > 0xFF). This lets
|
|
124
|
+
flags live high in gEventGlobal (clear of base-game flags) and still decode correctly."""
|
|
125
|
+
c = _cls(var_class)
|
|
126
|
+
if 0 <= idx <= 0xFF:
|
|
127
|
+
return bytes([c, idx])
|
|
128
|
+
return bytes([c | 0x20]) + struct.pack("<H", idx & 0xFFFF)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --- expression statements (opcode 0x05 + token stream) ---
|
|
132
|
+
def set_var(var_class, idx: int, value: int) -> bytes:
|
|
133
|
+
"""``set VAR = value`` -> ``05 <var> 7D <value:i16> 2C 7F``."""
|
|
134
|
+
return bytes([EXPR_OP]) + _push_var(var_class, idx) + bytes([T_CONST]) + _i16(value) + bytes([T_ASSIGN, T_END])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
FIELD_ENTRANCE_IDX = 2 # D8:2 = the arrival-entrance var the next Field()/WorldMap() arrives through
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def set_field_entrance(ent: int) -> bytes:
|
|
141
|
+
"""``D8:2 = ent`` -- set the entrance the next ``Field()``/``WorldMap()`` arrives through (the
|
|
142
|
+
destination field's player-init switches on it to place the player). ``05 D8 02 7D <ent:i16> 2C 7F``
|
|
143
|
+
(verified vs field 70's warp + field 380/404/706 ladder tops)."""
|
|
144
|
+
return set_var(GLOB_INT16, FIELD_ENTRANCE_IDX, ent)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def or_var(var_class, idx: int, value: int) -> bytes:
|
|
148
|
+
"""``VAR |= value`` -> ``05 <var> 7D <value:i16> 3F 7F`` (B_OR_LET). Used to OR a bit into a mask
|
|
149
|
+
scratch (real-field verified: Dali/Storage builds its moogle-mail availability mask this way)."""
|
|
150
|
+
return bytes([EXPR_OP]) + _push_var(var_class, idx) + bytes([T_CONST]) + _i16(value) + bytes([T_OR_ASSIGN, T_END])
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def var_expr(var_class, idx: int) -> bytes:
|
|
154
|
+
"""A BARE variable read for use as an opcode EXPRESSION-arg (no leading ``0x05`` statement byte):
|
|
155
|
+
``<var-token> 7F``. Pass with ``arg_flags`` bit set so the engine evaluates it (``getv`` -> CalcExpr).
|
|
156
|
+
Real-field verified (Dali/Storage 407: a CHOOSEPARAM arg is ``d6 09 7f`` = bare var + END)."""
|
|
157
|
+
return _push_var(var_class, idx) + bytes([T_END])
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def cond_truthy(var_class, idx: int) -> bytes:
|
|
161
|
+
"""``if (VAR)`` condition expr -> ``05 <var> 7F``."""
|
|
162
|
+
return bytes([EXPR_OP]) + _push_var(var_class, idx) + bytes([T_END])
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def cond_not(var_class, idx: int) -> bytes:
|
|
166
|
+
"""``if (!VAR)`` condition expr -> ``05 <var> 0E 7F``."""
|
|
167
|
+
return bytes([EXPR_OP]) + _push_var(var_class, idx) + bytes([T_NOT, T_END])
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def cond_eq(var_class, idx: int, value: int) -> bytes:
|
|
171
|
+
"""``if (VAR == value)`` condition expr -> ``05 <var> 7D <value:i16> 20 7F``."""
|
|
172
|
+
return bytes([EXPR_OP]) + _push_var(var_class, idx) + bytes([T_CONST]) + _i16(value) + bytes([T_EQ, T_END])
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def cond_item_count_lt(item_id: int, limit: int = 99) -> bytes:
|
|
176
|
+
"""``if (GetItemCount(item) < limit)`` condition expr -> ``05 7D <item:i16> 64 7D <limit:i16> 18 7F``.
|
|
177
|
+
The FF9 treasure-chest space guard: don't open/give if the player can't carry it (default cap 99).
|
|
178
|
+
Real-field verified (Dali/Storage 407: ``05 7d ec 00 64 7d 63 00 18 7f`` = ``GetItemCount(236) < 99``)."""
|
|
179
|
+
return (bytes([EXPR_OP, T_CONST]) + _i16(item_id) + bytes([T_ITEMCOUNT, T_CONST])
|
|
180
|
+
+ _i16(limit) + bytes([T_LT, T_END]))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def push_sysvar(code: int) -> bytes:
|
|
184
|
+
"""A system-variable read token: ``7A <code>`` -> push ``GetSysvar(code)`` (EBin.B_SYSVAR). The
|
|
185
|
+
movement gate is exactly this for code 2 (``05 7A 02 7F`` = IsMovementEnabled), so it's proven."""
|
|
186
|
+
return bytes([T_SYSVAR, code & 0xFF])
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def obj_var(uid: int, field: int) -> bytes:
|
|
190
|
+
"""An object-variable read token: ``78 <uid> <field>`` (uid 255 = self / the current object).
|
|
191
|
+
getvobj field codes: 0=X, 1=world-Y-up (=-pos.y), 2=Z, 3=angle, 4=flags, 5=uid, 6=level,
|
|
192
|
+
7=animFrame. Verified vs field 706's vine climb (``78 FF 01`` = self world-Y, ``78 FF 07`` =
|
|
193
|
+
self animFrame)."""
|
|
194
|
+
return bytes([T_OBJVAR, uid & 0xFF, field & 0xFF])
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def cond_sysvar_eq(code: int, value: int) -> bytes:
|
|
198
|
+
"""``if (GetSysvar(code) == value)`` condition expr -> ``05 7A <code> 7D <value:i16> 20 7F``.
|
|
199
|
+
|
|
200
|
+
With ``code`` = :data:`SYSVAR_CHOICE` (9) this is the dialogue-choice test: branch on which row the
|
|
201
|
+
player picked in the preceding choice window (``ETb.GetChoose()``)."""
|
|
202
|
+
return bytes([EXPR_OP]) + push_sysvar(code) + bytes([T_CONST]) + _i16(value) + bytes([T_EQ, T_END])
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def cond_ate_select(avail_class, avail_idx: int, select_mask: int = KEY_SELECT) -> bytes:
|
|
206
|
+
"""The real ATE menu-open gate, byte-for-byte as field 552 [11667]:
|
|
207
|
+
``if ( usercontrol==1 AND <avail>==1 AND B_KEYON(SELECT) )``.
|
|
208
|
+
|
|
209
|
+
RPN: ``usercontrol==1 , <avail>==1 , && , (SELECT B_KEYON) , &&``. Returns the full condition expr
|
|
210
|
+
(``EXPR_OP ... T_END``) for :func:`if_block`. ``<avail>`` is the author's own availability flag (set
|
|
211
|
+
in Main_Init alongside ``ATE(mode)``), so the menu opens ONLY while the ATE is offered and the player
|
|
212
|
+
taps SELECT this frame. Decoded from the Lindblum Main-St hub (Small-Town Knight ATE)."""
|
|
213
|
+
toks = (push_sysvar(SYSVAR_USERCONTROL) + bytes([T_CONST]) + _i16(1) + bytes([T_EQ])
|
|
214
|
+
+ _push_var(avail_class, avail_idx) + bytes([T_CONST]) + _i16(1) + bytes([T_EQ])
|
|
215
|
+
+ bytes([T_ANDAND])
|
|
216
|
+
+ bytes([T_CONST]) + _i16(select_mask) + bytes([T_KEYON])
|
|
217
|
+
+ bytes([T_ANDAND]))
|
|
218
|
+
return bytes([EXPR_OP]) + toks + bytes([T_END])
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# --- control flow ---
|
|
222
|
+
# 'ifnot (IsMovementEnabled) { return }' -- the verbatim region-trigger prologue (gates the body on
|
|
223
|
+
# usercontrol, exactly like every real exit/switch region). 7a 02 = IsMovementEnabled builtin.
|
|
224
|
+
MOVEMENT_GATE = bytes([EXPR_OP, 0x7A, 0x02, T_END, JMP_TRUE, 0x01, 0x00, opcodes.RETURN[0]])
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
JMP_UNCOND = 0x01 # the undocumented UNCONDITIONAL relative jump (operand = signed i16 skip), CLAUDE.md s7.
|
|
228
|
+
# The engine is uniformly IP-relative, so 0x01 <i16> skips <i16> bytes unconditionally --
|
|
229
|
+
# the hop the two-arm if/else uses to jump over the else-body after the then-body.
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def jump(skip: int) -> bytes:
|
|
233
|
+
"""An unconditional relative jump over ``skip`` bytes -> ``01 <skip:i16>``."""
|
|
234
|
+
return bytes([JMP_UNCOND]) + _i16(skip)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def if_block(cond: bytes, body: bytes) -> bytes:
|
|
238
|
+
"""``if (cond) { body }`` -> cond + ``02 <len(body):i16>`` (jump-if-false past body) + body."""
|
|
239
|
+
return cond + bytes([JMP_FALSE]) + _i16(len(body)) + body
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def if_else(cond: bytes, then_body: bytes, else_body: bytes) -> bytes:
|
|
243
|
+
"""``if (cond) { then_body } else { else_body }`` -- the explicit TWO-ARM branch.
|
|
244
|
+
|
|
245
|
+
Emits ``cond + 02 <len(then)+3> + then_body + 01 <len(else)> + else_body``: the JMP_FALSE skips the
|
|
246
|
+
then-body AND the 3-byte unconditional hop (landing on else); when cond is true the then-body runs and
|
|
247
|
+
the hop jumps over else. Byte-grounded on the real treasure-chest Init pose branch (fields 200/407:
|
|
248
|
+
``05{flag} 02 <7> SetStandAnimation(open) 01 <4> SetStandAnimation(closed)`` -- the savable open/closed
|
|
249
|
+
pose). The kit had only single-jump ``if_block``/``if_not_block``; this is the missing else arm."""
|
|
250
|
+
hop = jump(len(else_body))
|
|
251
|
+
return cond + bytes([JMP_FALSE]) + _i16(len(then_body) + len(hop)) + then_body + hop + else_body
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def if_not_block(cond: bytes, body: bytes) -> bytes:
|
|
255
|
+
"""``if (!cond) { body }`` -> cond + ``03 <len(body):i16>`` (jump-if-TRUE past body) + body."""
|
|
256
|
+
return cond + bytes([JMP_TRUE]) + _i16(len(body)) + body
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def flag_gate(var_class, idx: int, *, require_set: bool = True) -> bytes:
|
|
260
|
+
"""A story-flag PROLOGUE: ``ifnot (flag matches) { return }``. Prepend it to a function so the
|
|
261
|
+
function only proceeds when the flag is in the required state (the way real FF9 gates NPCs /
|
|
262
|
+
triggers by scenario). ``require_set`` True -> proceed only when the flag is SET; False -> only
|
|
263
|
+
when CLEAR. Same shape as :data:`MOVEMENT_GATE` (push flag, conditional jump over an early
|
|
264
|
+
``return``)."""
|
|
265
|
+
cond = cond_truthy(var_class, idx) # pushes the flag's truth
|
|
266
|
+
jmp = JMP_TRUE if require_set else JMP_FALSE # skip the 'return' when the flag is in-state
|
|
267
|
+
return cond + bytes([jmp]) + _i16(1) + opcodes.RETURN
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# --- region entry assembly ---
|
|
271
|
+
def set_region(points) -> bytes:
|
|
272
|
+
"""``SetRegion`` polygon op: ``29 00 <count> <(x,z) i16 pairs>``. 4 convex corners is the
|
|
273
|
+
real-field norm (the engine's IsInQuad fans consecutive triplets; a convex quad is safe)."""
|
|
274
|
+
pts = [tuple(p) for p in points]
|
|
275
|
+
if len(pts) < 3:
|
|
276
|
+
raise ValueError("a region needs at least 3 points")
|
|
277
|
+
out = bytes([SETREGION_OP, 0x00, len(pts) & 0xFF])
|
|
278
|
+
for x, z in pts:
|
|
279
|
+
out += _i16(x) + _i16(z)
|
|
280
|
+
return out
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def gated_set_region(zone, var_class, idx: int) -> bytes:
|
|
284
|
+
"""An Init body that defines the region quad ONLY while flag ``idx`` is CLEAR (else nothing) + a
|
|
285
|
+
return. So a spent one-shot trigger sets up no quad on a later visit -> no leftover interaction
|
|
286
|
+
prompt / tread. ``if (flag) skip SetRegion`` == :func:`if_not_block` over :func:`cond_truthy`."""
|
|
287
|
+
return if_not_block(cond_truthy(var_class, idx), set_region(zone)) + opcodes.RETURN
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def build_region_entry(zone, range_body: bytes, *, init_extra: bytes = b"", tag: int = RANGE_TAG,
|
|
291
|
+
init_body: bytes | None = None) -> bytes:
|
|
292
|
+
"""Assemble a type-1 region entry: Init (tag 0 = SetRegion(zone) + ``init_extra``; return) + a
|
|
293
|
+
trigger func at ``tag`` (default :data:`RANGE_TAG` 2 = tread, every frame in the quad;
|
|
294
|
+
:data:`INTERACT_TAG` 3 = press-action-in-quad, a lever/sign). ``init_extra`` runs once on field
|
|
295
|
+
load (when InitRegion arms the region) -- e.g. a ``set flag = 0`` to re-arm a once-per-visit
|
|
296
|
+
tread trigger each visit. ``init_body`` overrides the Init body entirely (e.g.
|
|
297
|
+
:func:`gated_set_region` for a one-shot trigger that vanishes once spent)."""
|
|
298
|
+
ib = init_body if init_body is not None else (set_region(zone) + init_extra + opcodes.RETURN)
|
|
299
|
+
funcs = [(0, ib), (tag, range_body)]
|
|
300
|
+
table_len = len(funcs) * 4
|
|
301
|
+
table = bytearray()
|
|
302
|
+
pos = table_len
|
|
303
|
+
for tag, body in funcs:
|
|
304
|
+
table += struct.pack("<HH", tag, pos)
|
|
305
|
+
pos += len(body)
|
|
306
|
+
return bytes([REGION_ENTRY_TYPE, len(funcs)]) + bytes(table) + b"".join(b for _, b in funcs)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def prepend_range_gate(data, slot: int, gate_bytes: bytes) -> bytes:
|
|
310
|
+
"""Insert ``gate_bytes`` at the start of the region in ``slot``'s Range (tag 2) function, so the
|
|
311
|
+
trigger only runs when the gate passes. Safe via :func:`edit.insert_bytes`: Range is the entry's
|
|
312
|
+
LAST function, so the gate just becomes its first bytes and no func-table ``fpos`` needs fixing."""
|
|
313
|
+
eb = EbScript.from_bytes(data)
|
|
314
|
+
rng = eb.entry(slot).func_by_tag(RANGE_TAG)
|
|
315
|
+
if rng is None:
|
|
316
|
+
raise ValueError(f"entry {slot} has no Range (tag {RANGE_TAG}) to gate")
|
|
317
|
+
if rng.abs_end != eb.entry(slot).abs_end:
|
|
318
|
+
raise ValueError(f"Range is not the last function of entry {slot}; cannot prepend safely")
|
|
319
|
+
return edit.insert_bytes(data, rng.abs_start, gate_bytes)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def inject_region(data, zone, range_body: bytes, *, slot: int | None = None, activate: bool = True,
|
|
323
|
+
spawn_wait_n: int = 2, spawn_wait_occurrence: int = 0, init_extra: bytes = b"",
|
|
324
|
+
tag: int = RANGE_TAG, init_body: bytes | None = None, reserve_party_band: bool = False):
|
|
325
|
+
"""Append a conditional region (Init=SetRegion(zone) + ``init_extra``, Range=range_body) into a
|
|
326
|
+
free slot.
|
|
327
|
+
|
|
328
|
+
Returns ``(new_bytes, slot)``. If ``activate`` (default), the region is turned on at field load
|
|
329
|
+
by overwriting a Main_Init ``Wait(n)`` filler with ``InitRegion(slot, 0)`` -- shift-free. Pass
|
|
330
|
+
``activate=False`` for a zone that another zone enables at runtime (the switch-pair toggle).
|
|
331
|
+
``init_extra`` runs in the region's Init on each load (e.g. a flag reset for once-per-visit).
|
|
332
|
+
``reserve_party_band`` (the VERBATIM-fork path): seat the region BELOW the engine's reserved
|
|
333
|
+
party-character band instead of into a free slot (else it lands in an unused character slot)."""
|
|
334
|
+
from . import object as _object # local: object imports region -> avoid the top-level cycle
|
|
335
|
+
entry = build_region_entry(zone, range_body, init_extra=init_extra, tag=tag, init_body=init_body)
|
|
336
|
+
out, slot = _object.seat_entry(data, entry, reserve_party_band=reserve_party_band, slot=slot)
|
|
337
|
+
if activate:
|
|
338
|
+
out = edit.activate(out, opcodes.init_region(slot, 0), spawn_wait_n=spawn_wait_n,
|
|
339
|
+
spawn_wait_occurrence=spawn_wait_occurrence)
|
|
340
|
+
return out, slot
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Add the after-battle handler (entry-0 tag-10 "Main_Reinit") a custom field needs.
|
|
2
|
+
|
|
3
|
+
After a random battle, EventEngine restores the field then calls Request(entry0, 0, 10).
|
|
4
|
+
``EnterBattleEnd`` has suspended every object; only when the tag-10 handler RETURNS at level
|
|
5
|
+
0 does ``ExitBattleEnd`` un-suspend them. Battle fields ship a Main_Reinit; fields cloned
|
|
6
|
+
from a cutscene field (like our blank) have none, so the player stays frozen after battle.
|
|
7
|
+
|
|
8
|
+
Minimal handler: ``EnableMove ; return``. With ``with_fade=True`` it is prefixed with a quick
|
|
9
|
+
``FadeFilter`` fade-in, because the battle-return fade is a 256-frame *timed* fade that only a
|
|
10
|
+
field-issued FadeFilter overrides (Main_Init issues one, but after battle the field runs
|
|
11
|
+
tag-10, not Main_Init).
|
|
12
|
+
|
|
13
|
+
Re-layout: entry-0's function table grows by one 4-byte slot (existing funcs' fpos += 4); the
|
|
14
|
+
new function body is appended after entry-0's code; every later entry shifts in the file so
|
|
15
|
+
its entry-table offset += growth. entryCount is unchanged.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import struct
|
|
21
|
+
|
|
22
|
+
from ..binutils import set_u16, u16
|
|
23
|
+
from ..eb import EbScript, opcodes
|
|
24
|
+
|
|
25
|
+
REINIT_TAG = 10
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def add_reinit(eb_bytes, *, with_fade: bool = True, fade_frames: int = 16,
|
|
29
|
+
tag: int = REINIT_TAG) -> bytes:
|
|
30
|
+
"""Add an entry-0 tag-10 handler (EnableMove; return), optionally with a fast fade-in."""
|
|
31
|
+
body = b""
|
|
32
|
+
if with_fade:
|
|
33
|
+
body += opcodes.fade_filter(2, fade_frames, 0, 0, 0, 0) # SUB => fade-IN over N frames
|
|
34
|
+
body += opcodes.ENABLE_MOVE + opcodes.RETURN
|
|
35
|
+
|
|
36
|
+
b = bytearray(eb_bytes)
|
|
37
|
+
entry_count = b[3]
|
|
38
|
+
off0, sz0 = u16(b, 128), u16(b, 130)
|
|
39
|
+
es = 128 + off0
|
|
40
|
+
etype, fc = b[es], b[es + 1]
|
|
41
|
+
fbase = es + 2
|
|
42
|
+
funcs = [[u16(b, fbase + i * 4), u16(b, fbase + i * 4 + 2)] for i in range(fc)]
|
|
43
|
+
if any(t == tag for t, _ in funcs):
|
|
44
|
+
raise ValueError(f"entry 0 already has a function with tag {tag}")
|
|
45
|
+
code = bytes(b[fbase + fc * 4: es + sz0])
|
|
46
|
+
new_funcs = [[t, fp + 4] for t, fp in funcs] + [[tag, (fc + 1) * 4 + len(code)]]
|
|
47
|
+
new_entry = bytearray([etype, fc + 1])
|
|
48
|
+
for t, fp in new_funcs:
|
|
49
|
+
new_entry += struct.pack("<HH", t, fp)
|
|
50
|
+
new_entry += code + body
|
|
51
|
+
growth = len(new_entry) - sz0
|
|
52
|
+
|
|
53
|
+
out = bytearray(bytes(b[:es]) + bytes(new_entry) + bytes(b[es + sz0:]))
|
|
54
|
+
set_u16(out, 130, len(new_entry)) # entry-0 size
|
|
55
|
+
for i in range(1, entry_count): # relocate later entries
|
|
56
|
+
slot = 128 + i * 8
|
|
57
|
+
if u16(out, slot + 2) > 0 and u16(out, slot) > off0:
|
|
58
|
+
set_u16(out, slot, u16(out, slot) + growth)
|
|
59
|
+
return bytes(out)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Save-point synthesis -- a functional FF9 save point as a press-to-interact region.
|
|
2
|
+
|
|
3
|
+
The FUNCTIONAL save is a single opcode: ``Menu(4, 0)`` (0x75) -> ``EventService.StartMenu`` ->
|
|
4
|
+
``OpenSaveMenu`` (``SaveLoadUI.SerializeType.Save``). Verified byte-exact (``75 00 04 00``) against the
|
|
5
|
+
real Dali save moogle (field 122 entry 5 tag 3). The real moogle's full act -- jump out of the barrel,
|
|
6
|
+
the Save/Shop dialogue choice, the player-pose ``RunScriptAsync`` surgery -- is COSMETIC; none of it is
|
|
7
|
+
needed to save the game. So instead of grafting that un-graftable 7-entry-ish cluster, the kit SYNTHESIZES
|
|
8
|
+
the save: a press-action region whose interact func opens the save menu.
|
|
9
|
+
|
|
10
|
+
It is the navigable cousin of :mod:`content.jump`'s ``action`` region -- same Init ``SetRegion`` / tread
|
|
11
|
+
``Bubble`` ("!") / action shape -- but the action dispatch is ``DisableMove; Menu(4, 0); EnableMove``
|
|
12
|
+
instead of a player-arc ``RunScriptSync`` (so, unlike a jump, NO player-function graft is required; the
|
|
13
|
+
save is a self-contained engine call). The optional visible barrel/moogle set-dressing + the cosmetic
|
|
14
|
+
jump-out are a separate, later layer (place a ``[[prop]]``/``[[npc]]`` over the zone); this is the
|
|
15
|
+
functional core.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import struct
|
|
20
|
+
|
|
21
|
+
from ..eb import EbScript, edit, opcodes
|
|
22
|
+
from . import region as _region
|
|
23
|
+
|
|
24
|
+
SAVE_MENU_ID = 4 # EventService.FF9Menu_Command case 4u -> OpenSaveMenu
|
|
25
|
+
SAVE_SUB_ID = 0 # OpenSaveMenu requires sub_id == 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def save_dispatch() -> bytes:
|
|
29
|
+
"""The interact body: ``DisableMove; Menu(4, 0); EnableMove; RETURN``. Locks control while the save
|
|
30
|
+
UI is up (so the player can't walk under it) and restores it after -- mirrors the jump action's
|
|
31
|
+
``DisableMove ... EnableMove`` bracket, with the save menu in place of the arc."""
|
|
32
|
+
return (opcodes.DISABLE_MOVE
|
|
33
|
+
+ opcodes.menu(SAVE_MENU_ID, SAVE_SUB_ID)
|
|
34
|
+
+ opcodes.ENABLE_MOVE + opcodes.RETURN)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _assemble_entry(funcs) -> bytes:
|
|
38
|
+
"""Assemble a type-1 (region) entry from ``[(tag, body), ...]`` -- the func table (4 bytes/func:
|
|
39
|
+
``<tag:u16><fpos:u16>``) then the concatenated bodies. Same layout as :func:`content.jump`."""
|
|
40
|
+
table = b""
|
|
41
|
+
pos = len(funcs) * 4
|
|
42
|
+
for tag, body in funcs:
|
|
43
|
+
table += struct.pack("<HH", tag, pos)
|
|
44
|
+
pos += len(body)
|
|
45
|
+
return bytes([_region.REGION_ENTRY_TYPE, len(funcs)]) + table + b"".join(b for _, b in funcs)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def savepoint_region(zone, *, bubble: bool = True) -> bytes:
|
|
49
|
+
"""A type-1 region entry for a save point: Init ``SetRegion(zone)`` / tread (tag 2) ``Bubble(1)`` (the
|
|
50
|
+
floating "!" prompt, if ``bubble``) / action (tag 3) :func:`save_dispatch`. Both trigger funcs are
|
|
51
|
+
gated by :data:`content.region.MOVEMENT_GATE` (fire only while ``usercontrol == 1``), exactly like
|
|
52
|
+
every real exit/switch/jump region."""
|
|
53
|
+
init = _region.set_region([tuple(p) for p in zone]) + opcodes.RETURN
|
|
54
|
+
tread = _region.MOVEMENT_GATE + (opcodes.bubble(1) if bubble else b"") + opcodes.RETURN
|
|
55
|
+
action = _region.MOVEMENT_GATE + save_dispatch()
|
|
56
|
+
funcs = [(0, init), (_region.RANGE_TAG, tread), (_region.INTERACT_TAG, action)]
|
|
57
|
+
return _assemble_entry(funcs)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def inject_savepoint(data, zone, *, bubble: bool = True, activate: bool = True):
|
|
61
|
+
"""Inject one save point: append a save-point region at the next free slot and arm it (``InitRegion``
|
|
62
|
+
in Main_Init). Returns ``(new_bytes, region_slot)``. ``zone`` is a 4- or 5-point quad (the press
|
|
63
|
+
area); ``bubble=False`` hides the "!" prompt (e.g. when a visible model already signals the save)."""
|
|
64
|
+
eb = EbScript.from_bytes(data)
|
|
65
|
+
slot = eb.first_free_slot()
|
|
66
|
+
data = edit.append_entry(data, slot, savepoint_region(zone, bubble=bubble))
|
|
67
|
+
if activate:
|
|
68
|
+
data = edit.activate(data, opcodes.init_region(slot, 0))
|
|
69
|
+
return data, slot
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def inject_savepoints(data, savepoints, *, activate: bool = True):
|
|
73
|
+
"""Inject every ``[[savepoint]]`` (each a dict with ``zone`` + optional ``bubble``). Returns
|
|
74
|
+
``(new_bytes, [slot, ...])``."""
|
|
75
|
+
slots = []
|
|
76
|
+
for sp in savepoints:
|
|
77
|
+
data, slot = inject_savepoint(data, sp["zone"], bubble=sp.get("bubble", True), activate=activate)
|
|
78
|
+
slots.append(slot)
|
|
79
|
+
return data, slots
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def graft_director(data, director_body):
|
|
83
|
+
"""Graft the save-sequence DIRECTOR (the donor field's entry-0 tag-1, from
|
|
84
|
+
:func:`eventscan.extract_savepoint_director`) into the fork's EMPTY entry-0 tag-1, so it puppeteers the
|
|
85
|
+
carried save Moogle. The director references no entries -- it drives the Moogle through shared transient
|
|
86
|
+
MAP vars only -- so it grafts VERBATIM (replace the empty system-loop body with it). The carried Moogle +
|
|
87
|
+
carried cask + this director then reconstitute the source field's exact state machine over those shared
|
|
88
|
+
vars: the Moogle lowers into the barrel, pops out on a cask push, and runs the save flourish. The fork's
|
|
89
|
+
entry-0 tag-1 is empty in a blank field, so this is a clean swap (docs/SAVEPOINT.md)."""
|
|
90
|
+
return edit.replace_function_body(data, 0, 1, bytes(director_body))
|