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
ff9mapkit/eventscan.py
ADDED
|
@@ -0,0 +1,1441 @@
|
|
|
1
|
+
"""Read authored content back OUT of a real field's compiled ``.eb`` -- the inverse of the
|
|
2
|
+
``content/*`` injectors, used by ``import`` to fork a real field WITH its gateways, music,
|
|
3
|
+
encounters, and movement tuning (not just its camera/walkmesh/art).
|
|
4
|
+
|
|
5
|
+
Everything here keys on the exact byte patterns the injectors emit (and that real fields use),
|
|
6
|
+
verified against a real field's disassembly (Alexandria/Main Street, field 100):
|
|
7
|
+
|
|
8
|
+
* EXIT / gateway -- a region entry (has both ``SetRegion`` 0x29 and ``Field`` 0x2B). The zone is
|
|
9
|
+
the SetRegion polygon (each point packs as x = v & 0xFFFF, z = v>>16, signed i16); the target is
|
|
10
|
+
the ``Field`` operand; the arrival entrance is the value assigned to the field-entrance variable
|
|
11
|
+
(``D8 02``) right before ``Field`` -- i.e. ``05 D8 02 7D <entrance:i16> 2C 7F``.
|
|
12
|
+
* field BGM -- ``RunSoundCode(0, song)`` (0xC5, sound_code 0 = ff9fldsnd_song_play).
|
|
13
|
+
* encounters -- ``SetRandomBattles(slot, s1..s4)`` (0x3C) + ``SetRandomBattleFrequency`` (0x57).
|
|
14
|
+
* movement dir -- ``SetControlDirection(x, y)`` (0x67, TWIST) in Main_Init.
|
|
15
|
+
|
|
16
|
+
These are unambiguous single-opcode patterns; the lossy/contextual content (NPCs + their dialogue,
|
|
17
|
+
arbitrary event triggers, cutscenes) is deliberately NOT scanned -- you author that fresh on the fork.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
|
|
24
|
+
from .binutils import u16
|
|
25
|
+
from .eb import EbScript
|
|
26
|
+
|
|
27
|
+
FIELD_OP = 0x2B # Field(target) -- a field transition (the exit)
|
|
28
|
+
WORLDMAP_OP = 0xB6 # WorldMap(loc) -- leave to the overworld; loc is a WORLD-MAP
|
|
29
|
+
# LOCATION id (e.g. 9000-9012), NOT a field id
|
|
30
|
+
SHARED_MENU_WARPS = frozenset(range(2950, 2956)) # chocobo/mognet shared menu warps -- not geography
|
|
31
|
+
SETREGION_OP = 0x29 # SetRegion(points) -- the trigger polygon
|
|
32
|
+
SET_RANDOM_BATTLES = 0x3C # SetRandomBattles(slot, s1..s4)
|
|
33
|
+
SET_BATTLE_FREQ = 0x57 # SetRandomBattleFrequency(freq)
|
|
34
|
+
BATTLE_OP = 0x2A # Battle(rush, btlId) -- scripted battle; scene = btlId & 0x7FFF, arg index 1
|
|
35
|
+
BATTLE_EX_OP = 0x8C # BattleEx(rush, group, btlId) -- scripted battle w/ group; scene = btlId, arg index 2
|
|
36
|
+
BATTLE_SCENE_MASK = 0x7FFF # btlId bit 15 = Steiner's state, NOT the scene id (EventEngine.DoEventCode.cs:962)
|
|
37
|
+
RUN_SOUND_CODE = 0xC5 # RunSoundCode(code, id); code 0 = song_play (field BGM)
|
|
38
|
+
TWIST_OP = 0x67 # SetControlDirection(x, y)
|
|
39
|
+
RUN_SCRIPT_SYNC = 0x14 # RunScriptSync(level, uid, tag) -- REQEW: run obj `uid`'s func `tag`, wait
|
|
40
|
+
RUN_SCRIPT_ASYNC = 0x10 # RunScriptAsync(level, uid, tag) -- run obj `uid`'s func, don't wait
|
|
41
|
+
RUN_SCRIPT = 0x12 # RunScript(level, uid, tag) -- run obj `uid`'s func
|
|
42
|
+
DISPATCH_OPS = frozenset((RUN_SCRIPT_SYNC, RUN_SCRIPT_ASYNC, RUN_SCRIPT)) # region -> player-func calls
|
|
43
|
+
SETUP_JUMP = 0xE2 # SetupJump(x, y, z, arc) -- a climb's / a jump's arc destination
|
|
44
|
+
JUMP_OP = 0xDC # Jump() -- perform the SetupJump arc (the navigable-jump signature, with SetupJump)
|
|
45
|
+
SET_JUMP_ANIM_OP = 0x94 # SetJumpAnimation(anim, a, b) -- the player Init's jump-clip setup
|
|
46
|
+
# A navigable hop is a SELF-CONTAINED arc: face -> jump-anim -> SetupJump/Jump -> land. If a
|
|
47
|
+
# SetupJump/Jump func ALSO does any of the following it's a scripted/cinematic sequence (a sand trap, a
|
|
48
|
+
# cutscene, a warp-jump), NOT player navigation -- and it references field-specific state (text, battle
|
|
49
|
+
# scenes, shared-script entries, destination fields) that doesn't port to a fork. Such arcs reuse
|
|
50
|
+
# SetupJump/Jump so they look like jumps by opcode, so scan_jumps excludes any that touch these:
|
|
51
|
+
NON_NAVIGABLE_OPS = frozenset((
|
|
52
|
+
0x1F, 0x20, 0x95, 0x96, # WindowSync/Async[Ex] -- a "press X!" prompt / dialogue (sand trap, cutscene)
|
|
53
|
+
0x2A, # Battle -- a forced encounter mid-arc (sand traps spawn one)
|
|
54
|
+
0x6F, 0x70, # MoveCamera / ReleaseCamera -- a cinematic camera follow (e.g. Alexandria pan)
|
|
55
|
+
0x2B, 0xB6, 0xFD, # Field / WorldMap / PreloadField -- the "jump" warps to another field
|
|
56
|
+
0x23, 0x25, 0xE8, # Walk / InitWalk / SideWalkXZY -- a scripted walk (a hop is a JUMP, not a walk)
|
|
57
|
+
0x10, 0x12, 0x14, # RunScript / Sync / Async -- nested object scripts that won't port
|
|
58
|
+
0x43, 0x44, 0x45, # Run/Wait/StopSharedScript -- per-field concurrent helpers a fork lacks
|
|
59
|
+
0xEC, # FadeFilter -- a screen fade (a transition, not a hop)
|
|
60
|
+
))
|
|
61
|
+
ADD_CHAR_ATTR = 0xCC # AddCharacterAttribute(flag); flag 4 (LADDER_FLAG) = "on a ladder"
|
|
62
|
+
DEFINE_PC = 0x2C # DefinePlayerCharacter -- marks the controlled player's entry
|
|
63
|
+
BUBBLE_OP = 0x68 # Bubble(state) -- the "!" interact prompt (ladder tread func)
|
|
64
|
+
RUN_SHARED_SCRIPT = 0x43 # RunSharedScript(n) -- camera/sound polish a fork doesn't have
|
|
65
|
+
PLAYER_UID = 250 # the controlled player's runtime UID
|
|
66
|
+
LADDER_FLAG = 4 # GetLadderFlag() == (attr & 4)
|
|
67
|
+
|
|
68
|
+
# the field-entrance variable token: an expression statement `set D8:02 = <i16>` right before Field.
|
|
69
|
+
# 05=expr, D8 02=var(class 0xD8, idx 2), 7D=push-const, <i16>, 2C=assign, 7F=end (8 bytes).
|
|
70
|
+
_ENTRANCE_SET_LEN = 8
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _s16(v: int) -> int:
|
|
74
|
+
return v - 0x10000 if v & 0x8000 else v
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _region_points(instr) -> list:
|
|
78
|
+
"""Unpack a ``SetRegion`` instruction's packed-32 args into (x, z) i16 points."""
|
|
79
|
+
pts = []
|
|
80
|
+
for i, v in enumerate(instr.args):
|
|
81
|
+
if instr.arg_is_expr[i]:
|
|
82
|
+
return [] # computed polygon -- can't extract statically
|
|
83
|
+
pts.append((_s16(v & 0xFFFF), _s16((v >> 16) & 0xFFFF)))
|
|
84
|
+
return pts
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _zone_quad(points) -> list:
|
|
88
|
+
"""Normalise a region polygon to the kit's quad: drop a doubled trailing vertex, take 4 corners."""
|
|
89
|
+
pts = list(points)
|
|
90
|
+
if len(pts) >= 2 and pts[-1] == pts[-2]: # the IsInQuad-safe doubled last vertex (kit + real)
|
|
91
|
+
pts = pts[:-1]
|
|
92
|
+
return [list(p) for p in pts[:4]]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _entrance_at(data: bytes, off: int):
|
|
96
|
+
"""If the instruction at ``off`` is ``set D8:02 = <i16>``, return that i16 (the arrival entrance)."""
|
|
97
|
+
r = data[off:off + _ENTRANCE_SET_LEN]
|
|
98
|
+
if (len(r) == _ENTRANCE_SET_LEN and r[0] == 0x05 and r[1] == 0xD8 and r[2] == 0x02
|
|
99
|
+
and r[3] == 0x7D and r[6] == 0x2C and r[7] == 0x7F):
|
|
100
|
+
return _s16(r[4] | (r[5] << 8))
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def scan_gateways(eb_bytes) -> list:
|
|
105
|
+
"""Exit gateways in the script. Returns ``[{to, entrance, zone}]`` (zone = up to 4 [x, z] corners).
|
|
106
|
+
|
|
107
|
+
A gateway is an entry that holds BOTH a ``SetRegion`` (the trigger polygon) and a ``Field``
|
|
108
|
+
(the destination) -- the walk-into-a-zone exit pattern. A bare ``Field`` with no region (e.g. a
|
|
109
|
+
scripted cutscene warp) is intentionally skipped. The arrival entrance is the ``D8:02`` assignment
|
|
110
|
+
immediately preceding the ``Field`` (default 0)."""
|
|
111
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
112
|
+
out = []
|
|
113
|
+
for e in eb.entries:
|
|
114
|
+
if e.empty:
|
|
115
|
+
continue
|
|
116
|
+
zone = None
|
|
117
|
+
fields = [] # (target, entrance) for each Field in this entry
|
|
118
|
+
for f in e.funcs:
|
|
119
|
+
entrance = 0
|
|
120
|
+
for ins in eb.instrs(f):
|
|
121
|
+
if ins.op == SETREGION_OP and zone is None:
|
|
122
|
+
pts = _region_points(ins)
|
|
123
|
+
if len(pts) >= 3:
|
|
124
|
+
zone = _zone_quad(pts)
|
|
125
|
+
elif ins.op == 0x05:
|
|
126
|
+
ent = _entrance_at(eb.data, ins.off)
|
|
127
|
+
if ent is not None:
|
|
128
|
+
entrance = ent
|
|
129
|
+
elif ins.op == FIELD_OP:
|
|
130
|
+
tgt = ins.imm(0)
|
|
131
|
+
if tgt is not None:
|
|
132
|
+
fields.append((tgt, entrance))
|
|
133
|
+
if zone and fields:
|
|
134
|
+
for tgt, entrance in fields:
|
|
135
|
+
out.append({"to": int(tgt), "entrance": int(entrance), "zone": zone})
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def scan_gateway_entries(eb_bytes) -> list:
|
|
140
|
+
"""Gateway region entries (a ``SetRegion`` + >=1 ``Field``), classified for faithful door carry -- the
|
|
141
|
+
entry-level view that :func:`scan_gateways` flattens to one dict per ``Field``.
|
|
142
|
+
|
|
143
|
+
Each: ``{entry_idx, zone, fields: [(to, entrance)], story_gated, entry_bytes, field_targets}``.
|
|
144
|
+
|
|
145
|
+
* ``story_gated`` -- a conditional jump (``JMP_FALSE`` 0x02 / ``JMP_TRUE`` 0x03) tests a GLOB SAVE story
|
|
146
|
+
flag (``opC4``/``opE4``) in the entry: the door's firing/destination depends on story state (~40 real
|
|
147
|
+
fields, e.g. Dali Inn). The kit's declarative re-synthesis DROPS that logic (the door goes
|
|
148
|
+
always-active), so such an entry is carried VERBATIM (its conditional state machine preserved; the
|
|
149
|
+
GLOB conditions then read the ``[startup]``-preset story state). NOTE: it may also read MAP/transient
|
|
150
|
+
vars set by the field's main logic -- a door-only carry doesn't reconstruct those (documented limit).
|
|
151
|
+
* ``field_targets`` -- ``[(offset_in_entry, field_id)]`` for every ``Field()`` literal (id at instr
|
|
152
|
+
offset +2), so the carry remaps destinations with a byte patch (no re-encode).
|
|
153
|
+
* ``fields`` -- ``(destination, arrival_entrance)`` per ``Field()`` (the declarative view used for the
|
|
154
|
+
simple, non-gated entries that keep the clean editable [[gateway]] path)."""
|
|
155
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
156
|
+
SETREGION, FIELD, JMP_F, JMP_T = 0x29, 0x2B, 0x02, 0x03
|
|
157
|
+
out = []
|
|
158
|
+
for e in eb.entries:
|
|
159
|
+
if e.empty:
|
|
160
|
+
continue
|
|
161
|
+
zone = None
|
|
162
|
+
field_ins = [] # (Field instr, arrival entrance)
|
|
163
|
+
gated = False
|
|
164
|
+
for f in e.funcs:
|
|
165
|
+
entrance = 0
|
|
166
|
+
ins = list(eb.instrs(f))
|
|
167
|
+
for k, i in enumerate(ins):
|
|
168
|
+
if i.op == SETREGION and zone is None:
|
|
169
|
+
pts = _region_points(i)
|
|
170
|
+
if len(pts) >= 3:
|
|
171
|
+
zone = _zone_quad(pts)
|
|
172
|
+
elif i.op == 0x05:
|
|
173
|
+
ent = _entrance_at(eb.data, i.off)
|
|
174
|
+
if ent is not None:
|
|
175
|
+
entrance = ent
|
|
176
|
+
elif i.op == FIELD and i.imm(0) is not None:
|
|
177
|
+
field_ins.append((i, entrance))
|
|
178
|
+
if i.op in (JMP_F, JMP_T) and k > 0 and ins[k - 1].op == 0x05 \
|
|
179
|
+
and any(("opC4" in str(a)) or ("opE4" in str(a)) for a in ins[k - 1].args):
|
|
180
|
+
gated = True # a conditional jump on a GLOB save story flag
|
|
181
|
+
if zone is None or not field_ins:
|
|
182
|
+
continue
|
|
183
|
+
base = 128 + u16(eb.data, 128 + e.index * 8) # entry start in eb.data
|
|
184
|
+
ref_ops = {0x07, 0x08, 0x09, 0x10, 0x12, 0x14, 0x43} # InitCode/Region/Object + RunScript family
|
|
185
|
+
out.append({
|
|
186
|
+
"entry_idx": e.index,
|
|
187
|
+
"zone": zone,
|
|
188
|
+
"fields": [(int(i.imm(0)), int(ent)) for i, ent in field_ins],
|
|
189
|
+
"story_gated": gated,
|
|
190
|
+
# self-contained = the entry references NO other entry, so a verbatim graft needs no ref carry
|
|
191
|
+
# (the door-only carry path). ~30% of gated entries fail this (they RunScript siblings) -> seam.
|
|
192
|
+
"self_contained": not any(i.op in ref_ops for f in e.funcs for i in eb.instrs(f)),
|
|
193
|
+
"entry_bytes": _entry_bytes(eb.data, e.index),
|
|
194
|
+
"field_targets": [((i.off - base) + 2, int(i.imm(0))) for i, _ent in field_ins],
|
|
195
|
+
})
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def scan_region_zones(eb_bytes) -> list:
|
|
200
|
+
"""Every static ``SetRegion`` trigger polygon in the script (exits AND interaction/event/trap
|
|
201
|
+
regions), as ``[x, z]``-corner quads. Used to keep an imported field's spawn OFF a trigger: a spawn
|
|
202
|
+
inside an exit gateway instant-warps you back out the moment you arrive, and inside a tread region it
|
|
203
|
+
auto-fires (e.g. a sand trap). Computed polygons (expression args) are skipped (can't place them)."""
|
|
204
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
205
|
+
out = []
|
|
206
|
+
for e in eb.entries:
|
|
207
|
+
if e.empty:
|
|
208
|
+
continue
|
|
209
|
+
for f in e.funcs:
|
|
210
|
+
for ins in eb.instrs(f):
|
|
211
|
+
if ins.op == SETREGION_OP:
|
|
212
|
+
pts = _region_points(ins)
|
|
213
|
+
if len(pts) >= 3:
|
|
214
|
+
out.append(_zone_quad(pts))
|
|
215
|
+
return out
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _first_instr(eb, op, *, entry_index=None):
|
|
219
|
+
"""First instruction with opcode ``op`` (optionally restricted to one entry), or None."""
|
|
220
|
+
entries = [eb.entry(entry_index)] if entry_index is not None else eb.entries
|
|
221
|
+
for e in entries:
|
|
222
|
+
if e.empty:
|
|
223
|
+
continue
|
|
224
|
+
for f in e.funcs:
|
|
225
|
+
for ins in eb.instrs(f):
|
|
226
|
+
if ins.op == op:
|
|
227
|
+
yield ins
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def scan_music(eb_bytes):
|
|
231
|
+
"""The field BGM song id (first ``RunSoundCode(0, song)``), or None. Prefers Main_Init (entry 0)."""
|
|
232
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
233
|
+
for source in (0, None): # Main_Init first, then anywhere
|
|
234
|
+
for ins in _first_instr(eb, RUN_SOUND_CODE, entry_index=source):
|
|
235
|
+
if ins.imm(0) == 0 and ins.imm(1) is not None:
|
|
236
|
+
return int(ins.imm(1))
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def scan_encounter(eb_bytes):
|
|
241
|
+
"""Random-battle config, or None. ``{scenes:[s1..s4], freq, pattern}`` from the first
|
|
242
|
+
``SetRandomBattles`` + the nearest ``SetRandomBattleFrequency``."""
|
|
243
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
244
|
+
srb = next(_first_instr(eb, SET_RANDOM_BATTLES), None)
|
|
245
|
+
if srb is None:
|
|
246
|
+
return None
|
|
247
|
+
if any(srb.arg_is_expr[:5]): # computed slot/scenes -- skip
|
|
248
|
+
return None
|
|
249
|
+
pattern = int(srb.imm(0))
|
|
250
|
+
scenes = [int(srb.imm(i)) for i in range(1, 5)]
|
|
251
|
+
freq_ins = next(_first_instr(eb, SET_BATTLE_FREQ), None)
|
|
252
|
+
freq = int(freq_ins.imm(0)) if (freq_ins is not None and freq_ins.imm(0) is not None) else 255
|
|
253
|
+
return {"scenes": scenes, "freq": freq, "pattern": pattern}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def scan_battle_scenes(eb_bytes):
|
|
257
|
+
"""Sorted unique SCRIPTED battle scene ids the field's ``.eb`` enters via ``Battle`` (0x2A, btlId at
|
|
258
|
+
arg 1) / ``BattleEx`` (0x8C, btlId at arg 2). The scene is ``btlId & 0x7FFF`` -- the high bit is
|
|
259
|
+
Steiner's state, not the scene (``EventEngine.DoEventCode.cs:962``). Random encounters
|
|
260
|
+
(``SetRandomBattles``) are NOT included (that's :func:`scan_encounter`). An expression-computed btlId
|
|
261
|
+
is skipped (``imm`` returns None -- can't resolve statically). Used by ``import --verbatim`` to carry
|
|
262
|
+
the donor's per-scene battle BGM (a fork's custom fldMapNo loses the engine's (field, scene) lookup)."""
|
|
263
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
264
|
+
out = set()
|
|
265
|
+
for e in eb.entries:
|
|
266
|
+
if e.empty:
|
|
267
|
+
continue
|
|
268
|
+
for f in e.funcs:
|
|
269
|
+
for ins in eb.instrs(f):
|
|
270
|
+
arg = 1 if ins.op == BATTLE_OP else 2 if ins.op == BATTLE_EX_OP else None
|
|
271
|
+
if arg is None:
|
|
272
|
+
continue
|
|
273
|
+
v = ins.imm(arg)
|
|
274
|
+
if v is not None:
|
|
275
|
+
out.add(int(v) & BATTLE_SCENE_MASK)
|
|
276
|
+
return sorted(out)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def scan_control_direction(eb_bytes):
|
|
280
|
+
"""The Main_Init ``SetControlDirection`` (TWIST) x value, or None (the WASD-vs-camera tuning)."""
|
|
281
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
282
|
+
ins = next(_first_instr(eb, TWIST_OP, entry_index=0), None)
|
|
283
|
+
if ins is None:
|
|
284
|
+
ins = next(_first_instr(eb, TWIST_OP), None)
|
|
285
|
+
return None if ins is None else (None if ins.imm(0) is None else int(ins.imm(0)))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _player_entry_index(eb):
|
|
289
|
+
"""Index of the controlled player's entry (the one defining the player character), or None."""
|
|
290
|
+
for e in eb.entries:
|
|
291
|
+
if e.empty:
|
|
292
|
+
continue
|
|
293
|
+
for f in e.funcs:
|
|
294
|
+
for ins in eb.instrs(f):
|
|
295
|
+
if ins.op == DEFINE_PC:
|
|
296
|
+
return e.index
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _func_ops(eb, player_index, tag):
|
|
301
|
+
"""The opcode set + an ``has_ladder_flag`` predicate for player function ``tag`` (or None)."""
|
|
302
|
+
f = eb.entry(player_index).func_by_tag(tag)
|
|
303
|
+
if f is None:
|
|
304
|
+
return None, False
|
|
305
|
+
ops = set()
|
|
306
|
+
ladder = False
|
|
307
|
+
for ins in eb.instrs(f):
|
|
308
|
+
ops.add(ins.op)
|
|
309
|
+
if ins.op == ADD_CHAR_ATTR and ins.imm(0) == LADDER_FLAG:
|
|
310
|
+
ladder = True
|
|
311
|
+
return ops, ladder
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _is_ladder_func(eb, player_index, tag) -> bool:
|
|
315
|
+
"""True if player function ``tag`` is a LADDER climb. The definitive signature is the ladder flag
|
|
316
|
+
(``AddCharacterAttribute(4)``) -- a hold-to-climb. A SetupJump arc WITHOUT the flag is a one-shot
|
|
317
|
+
navigable JUMP (see :func:`_is_jump_func`), not a ladder, so the flag is what separates them (the
|
|
318
|
+
census confirms every real ladder sets it; the 10 fields that don't were jumps mis-read as ladders)."""
|
|
319
|
+
ops, ladder = _func_ops(eb, player_index, tag)
|
|
320
|
+
return bool(ladder)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _is_jump_func(eb, player_index, tag) -> bool:
|
|
324
|
+
"""True if player function ``tag`` is a navigable JUMP arc: a ``SetupJump``+``Jump`` parabola that
|
|
325
|
+
is NOT a ladder (no ladder flag) AND is SELF-CONTAINED (none of :data:`NON_NAVIGABLE_OPS`). The
|
|
326
|
+
self-contained test is what separates an Ice-Cavern-style ledge HOP (face -> jump -> land) from the
|
|
327
|
+
look-alikes that also use SetupJump/Jump: a Cleyra/Tree-Trunk SAND TRAP (a 'press X!' Window +
|
|
328
|
+
struggle + Battle), a cinematic traversal (MoveCamera follow), a warp-jump (Field), or a scripted
|
|
329
|
+
walk/nested-script sequence -- none of which are free navigation, and all of which reference
|
|
330
|
+
field-specific state (text, scenes, shared-script entries, destinations) that a fork can't port."""
|
|
331
|
+
ops, ladder = _func_ops(eb, player_index, tag)
|
|
332
|
+
if ops is None or ladder or SETUP_JUMP not in ops or JUMP_OP not in ops:
|
|
333
|
+
return False
|
|
334
|
+
if ops & NON_NAVIGABLE_OPS: # a scripted/cinematic sequence (trap, cutscene, warp), not a hop
|
|
335
|
+
return False
|
|
336
|
+
return True
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _is_climb_func(eb, player_index, tag) -> bool:
|
|
340
|
+
"""Back-compat: a climb is now strictly a flagged ladder (was: flag OR any SetupJump). Kept for any
|
|
341
|
+
external caller; internal scanners use :func:`_is_ladder_func` / :func:`_is_jump_func`."""
|
|
342
|
+
return _is_ladder_func(eb, player_index, tag)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _entry_bytes(data, idx) -> bytes:
|
|
346
|
+
"""Raw bytes of entry ``idx`` (its type+func-table+bodies) via the entry table at offset 128."""
|
|
347
|
+
slot = 128 + idx * 8
|
|
348
|
+
off, sz = u16(data, slot), u16(data, slot + 2)
|
|
349
|
+
return data[128 + off:128 + off + sz]
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _climb_sequences(eb, func) -> dict:
|
|
353
|
+
"""The field entries a climb launches via ``STARTSEQ`` (RunSharedScript, 0x43) -- run as concurrent
|
|
354
|
+
per-frame Seqs on the climber (e.g. the SetPitchAngle forward-lean: the climb ramps a pitch helper
|
|
355
|
+
entry in, then out). STARTSEQ arg0 is an ENTRY index in THIS field, so a faithful fork must carry
|
|
356
|
+
those entries too (not NOP the calls). Returns ``{entry_index: entry_bytes}`` (deduped)."""
|
|
357
|
+
seqs = {}
|
|
358
|
+
for ins in eb.instrs(func):
|
|
359
|
+
if ins.op == RUN_SHARED_SCRIPT and ins.args:
|
|
360
|
+
ei = int(ins.args[0])
|
|
361
|
+
if ei not in seqs:
|
|
362
|
+
seqs[ei] = _entry_bytes(eb.data, ei)
|
|
363
|
+
return seqs
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def scan_ladders(eb_bytes) -> list:
|
|
367
|
+
"""FF9 ladders, the truthful way: a region whose trigger ``RunScriptSync``s the player's CLIMB
|
|
368
|
+
function -- where 'climb' is defined by the ladder flag / jump arcs, not just any RunScriptSync.
|
|
369
|
+
|
|
370
|
+
Returns ``[{zone, climb_tag, trigger, bubble, climb, sequences}]``:
|
|
371
|
+
* ``zone`` -- the trigger polygon (up to 4 [x, z] corners), or None if computed.
|
|
372
|
+
* ``climb_tag``-- the player function tag the trigger runs (the climb).
|
|
373
|
+
* ``trigger`` -- the region function tag that fires it (2 = tread/auto, 3 = action/press).
|
|
374
|
+
* ``bubble`` -- whether the trigger shows the "!" interact prompt.
|
|
375
|
+
* ``climb`` -- the climb function's raw bytecode, VERBATIM (STARTSEQ calls intact).
|
|
376
|
+
* ``sequences``-- ``{entry_index: bytes}`` for each entry the climb launches via STARTSEQ (the
|
|
377
|
+
concurrent per-frame helpers, e.g. the SetPitchAngle forward-lean); empty for simple ladders.
|
|
378
|
+
|
|
379
|
+
The climb is verbatim because its jump coordinates are hand-tuned to the ladder's geometry +
|
|
380
|
+
the fixed camera -- that perspective tuning can't be regenerated, only copied."""
|
|
381
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
382
|
+
pe = _player_entry_index(eb)
|
|
383
|
+
if pe is None:
|
|
384
|
+
return []
|
|
385
|
+
out, seen = [], set()
|
|
386
|
+
for e in eb.entries:
|
|
387
|
+
if e.empty:
|
|
388
|
+
continue
|
|
389
|
+
zone = None
|
|
390
|
+
for f in e.funcs:
|
|
391
|
+
for ins in eb.instrs(f):
|
|
392
|
+
if ins.op == SETREGION_OP and zone is None:
|
|
393
|
+
pts = _region_points(ins)
|
|
394
|
+
if len(pts) >= 3:
|
|
395
|
+
zone = _zone_quad(pts)
|
|
396
|
+
# the "!" prompt belongs to the region, not a single func -- the Bubble is usually in the tread
|
|
397
|
+
# (tag 2) while the climb's RunScriptSync is in the action (tag 3); check the whole entry.
|
|
398
|
+
bubble = any(ins.op == BUBBLE_OP for f in e.funcs for ins in eb.instrs(f))
|
|
399
|
+
for f in e.funcs:
|
|
400
|
+
for ins in eb.instrs(f):
|
|
401
|
+
if ins.op != RUN_SCRIPT_SYNC or ins.imm(1) != PLAYER_UID:
|
|
402
|
+
continue
|
|
403
|
+
tag = ins.imm(2)
|
|
404
|
+
if tag is None or (e.index, tag) in seen or not _is_ladder_func(eb, pe, tag):
|
|
405
|
+
continue
|
|
406
|
+
seen.add((e.index, tag))
|
|
407
|
+
cf = eb.entry(pe).func_by_tag(tag)
|
|
408
|
+
out.append({"zone": zone, "climb_tag": int(tag), "trigger": int(f.tag),
|
|
409
|
+
"bubble": bool(bubble), "climb": eb.data[cf.abs_start:cf.abs_end],
|
|
410
|
+
"sequences": _climb_sequences(eb, cf)})
|
|
411
|
+
return out
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def scan_jumps(eb_bytes) -> list:
|
|
415
|
+
"""FF9 navigable JUMPS -- ledge/gap hops (Ice Cavern etc.): a region whose trigger dispatches the
|
|
416
|
+
player's one-shot jump-arc function (``SetupJump``+``Jump``, NOT a ladder climb). The cousin of
|
|
417
|
+
:func:`scan_ladders`; the two are disjoint (ladder = has the ladder flag, jump = doesn't).
|
|
418
|
+
|
|
419
|
+
Returns ``[{zone, trigger, bubble, jump}]``:
|
|
420
|
+
* ``zone`` -- the trigger polygon (up to 4 [x, z] corners), or None if computed.
|
|
421
|
+
* ``trigger`` -- "action" (press the action button in the zone, the Ice-Cavern "!"+confirm hop)
|
|
422
|
+
or "tread" (auto-fires on walk-in), from whether the dispatch sits in the action (tag 3) or
|
|
423
|
+
tread (tag 2) func.
|
|
424
|
+
* ``bubble`` -- whether the region shows the floating "!" prompt.
|
|
425
|
+
* ``jump`` -- the player's jump-arc bytecode, VERBATIM (the exact perspective-tuned world
|
|
426
|
+
coords -- only copyable, like a ladder climb).
|
|
427
|
+
|
|
428
|
+
The dispatch may be ``RunScriptSync``/``Async``/``RunScript`` and may reference the player by the
|
|
429
|
+
runtime UID 250 OR by the player's entry index (Ice Cavern uses the entry index -- which is exactly
|
|
430
|
+
why these jumps slipped past the uid-250-only ladder scan and were dropped on fork)."""
|
|
431
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
432
|
+
pe = _player_entry_index(eb)
|
|
433
|
+
if pe is None:
|
|
434
|
+
return []
|
|
435
|
+
out, seen = [], set()
|
|
436
|
+
for e in eb.entries:
|
|
437
|
+
if e.empty or e.index == pe:
|
|
438
|
+
continue
|
|
439
|
+
zone = None
|
|
440
|
+
for f in e.funcs:
|
|
441
|
+
for ins in eb.instrs(f):
|
|
442
|
+
if ins.op == SETREGION_OP and zone is None:
|
|
443
|
+
pts = _region_points(ins)
|
|
444
|
+
if len(pts) >= 3:
|
|
445
|
+
zone = _zone_quad(pts)
|
|
446
|
+
if zone is None:
|
|
447
|
+
# The dispatching entry isn't a navigable region: either it has NO SetRegion (the jump is
|
|
448
|
+
# fired from Main_Loop / a cutscene sequence -- a scripted hop, not player navigation) or its
|
|
449
|
+
# SetRegion is computed (expression args -> not statically placeable). Either way it's not an
|
|
450
|
+
# authorable ledge jump, so skip it. (A field can mix both: field 950 has a loop-fired hop in
|
|
451
|
+
# entry 0 AND a real region jump in entry 6 -- this keeps only the latter.)
|
|
452
|
+
continue
|
|
453
|
+
bubble = any(ins.op == BUBBLE_OP for f in e.funcs for ins in eb.instrs(f))
|
|
454
|
+
for f in e.funcs:
|
|
455
|
+
for ins in eb.instrs(f):
|
|
456
|
+
if ins.op not in DISPATCH_OPS:
|
|
457
|
+
continue
|
|
458
|
+
if ins.imm(1) not in (PLAYER_UID, pe): # not a call into the player object
|
|
459
|
+
continue
|
|
460
|
+
tag = ins.imm(2)
|
|
461
|
+
if tag is None or (e.index, tag) in seen or not _is_jump_func(eb, pe, tag):
|
|
462
|
+
continue
|
|
463
|
+
seen.add((e.index, tag))
|
|
464
|
+
jf = eb.entry(pe).func_by_tag(tag)
|
|
465
|
+
trigger = "action" if int(f.tag) == 3 else "tread"
|
|
466
|
+
out.append({"zone": zone, "trigger": trigger, "bubble": bool(bubble),
|
|
467
|
+
"jump": eb.data[jf.abs_start:jf.abs_end]})
|
|
468
|
+
return out
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# --- persistent NPCs / props (faithful fork) ---------------------------------------------
|
|
472
|
+
SET_MODEL_OP = 0x2F # SetModel(model, animset)
|
|
473
|
+
SET_STAND_ANIM_OP = 0x33 # SetStandAnimation(pose)
|
|
474
|
+
MOVE_INSTANT_OP = 0xA1 # MoveInstantXZY(worldX, -worldY, worldZ) -- the object's static placement
|
|
475
|
+
TURN_INSTANT_OP = 0x36 # TurnInstant(dir)
|
|
476
|
+
SET_OBJECT_FLAGS_OP = 0x93 # SetObjectFlags(bits); bit 1 = SHOW model (off => loaded hidden, script-driven)
|
|
477
|
+
SHOW_MODEL_BIT = 1
|
|
478
|
+
INIT_OBJECT_OP = 0x09 # InitObject(slot, arg) in Main_Init -- spawns/activates object `slot`
|
|
479
|
+
SETVAR_EXPR_OP = 0x05 # an expression; `05 D9 idx 7D lo hi 2C 7F` = SetVar D9(idx)=const
|
|
480
|
+
POS_VAR_CLASS = 0xD9 # the D9 var class CreateObject/MoveInstantXZY read for x/y/z
|
|
481
|
+
ENTRANCE_VAR_CLASS = 0xD8 # the field-entrance var class (D8); idx 2 = the arrival entrance a warp sets
|
|
482
|
+
ENTRANCE_VAR_IDX = 2 # the warp sets D8:2 just before Field(); the target reads it to position the player
|
|
483
|
+
ASSIGN_TOK = 0x2C # the expression assign token (`=`); its ABSENCE marks a bare var READ (a push)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _read_object_init(eb, init_func) -> dict:
|
|
487
|
+
"""Decode the render-defining fields an object's Init (tag 0) sets: model/animset, pose, face, a
|
|
488
|
+
literal placement (``lit``), the object's OWN local D9 consts, its SetObjectFlags, and whether it is
|
|
489
|
+
the player. Shared by :func:`scan_objects` (decoded facts) and :func:`scan_objects_verbatim` (graft)."""
|
|
490
|
+
model = animset = pose = face = lit = flags = None
|
|
491
|
+
local: dict = {}
|
|
492
|
+
player = False
|
|
493
|
+
for ins in eb.instrs(init_func):
|
|
494
|
+
if ins.op == DEFINE_PC:
|
|
495
|
+
player = True
|
|
496
|
+
elif ins.op == SETVAR_EXPR_OP:
|
|
497
|
+
raw = eb.data[ins.off:ins.off + 8]
|
|
498
|
+
if len(raw) >= 6 and raw[1] == POS_VAR_CLASS and raw[3] == 0x7D:
|
|
499
|
+
local[raw[2]] = _s16(raw[4] | (raw[5] << 8))
|
|
500
|
+
elif ins.op == SET_MODEL_OP and model is None and len(ins.args) >= 2 \
|
|
501
|
+
and isinstance(ins.args[0], int):
|
|
502
|
+
model, animset = int(ins.args[0]), int(ins.args[1])
|
|
503
|
+
elif ins.op == SET_STAND_ANIM_OP and pose is None and ins.args and isinstance(ins.args[0], int):
|
|
504
|
+
pose = int(ins.args[0])
|
|
505
|
+
elif ins.op == TURN_INSTANT_OP and face is None and ins.args and isinstance(ins.args[0], int):
|
|
506
|
+
face = int(ins.args[0])
|
|
507
|
+
elif ins.op == MOVE_INSTANT_OP and lit is None and len(ins.args) >= 3 \
|
|
508
|
+
and all(isinstance(a, int) for a in ins.args[:3]):
|
|
509
|
+
lit = (_s16(int(ins.args[0])), _s16(int(ins.args[2]))) # (worldX, worldZ)
|
|
510
|
+
elif ins.op == SET_OBJECT_FLAGS_OP and ins.args and isinstance(ins.args[0], int):
|
|
511
|
+
flags = int(ins.args[0]) # last wins (an object may hide then show)
|
|
512
|
+
return {"model": model, "animset": animset, "pose": pose, "face": face, "lit": lit,
|
|
513
|
+
"local": local, "flags": flags, "player": player}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# --- the cross-reference surface a verbatim graft must remap (docs/OBJECT_CARRY.md S3) -------------
|
|
517
|
+
# Every reference-bearing opcode's uid/slot operand is a 1-byte immediate (verified vs eb/_optables.py),
|
|
518
|
+
# so a graft remap is always a same-length in-place patch (like the ladder STARTSEQ remap). UID-space:
|
|
519
|
+
# 250=player, 255=self, 251-254=party, else == an entry slot. SLOT-space: Init*/STARTSEQ entry index.
|
|
520
|
+
# (0x44/0x45 Wait/StopSharedScript have NO operand -- they act on the current shared script -- so they
|
|
521
|
+
# are NOT here; same for 0x16/18/1A REPLY* which target the dynamic caller, and 0xD4/0x1D/0xA2 which act
|
|
522
|
+
# on self with non-uid args.)
|
|
523
|
+
REF_OPS = {
|
|
524
|
+
0x09: {"slot": (0,), "uid": (1,)}, 0x07: {"slot": (0,), "uid": (1,)}, 0x08: {"slot": (0,), "uid": (1,)},
|
|
525
|
+
0x10: {"uid": (1,)}, 0x12: {"uid": (1,)}, 0x14: {"uid": (1,)}, # RunScript[Async|Sync](level, uid, tag)
|
|
526
|
+
0x24: {"uid": (0,)}, 0x39: {"uid": (0,)}, 0x3A: {"uid": (0,)}, # Walk/Show/HideObject
|
|
527
|
+
0x4C: {"uid": (0, 1)}, # AttachObject(attached, carrying, bone)
|
|
528
|
+
0x4D: {"uid": (0,)}, 0x51: {"uid": (0,)}, 0x87: {"uid": (0,)}, 0x8A: {"uid": (0,)},
|
|
529
|
+
0x8F: {"uid": (0,)}, 0x95: {"uid": (0,)}, 0x96: {"uid": (0,)}, 0x97: {"uid": (0,)},
|
|
530
|
+
0x9F: {"uid": (0,)}, 0xA9: {"uid": (0,)}, 0xAD: {"uid": (0,)}, 0xBB: {"uid": (0,)},
|
|
531
|
+
0xBC: {"uid": (0,)}, 0xBD: {"uid": (0,)}, 0xBE: {"uid": (0,)}, 0xBF: {"uid": (0,)},
|
|
532
|
+
0xB5: {"uid": (0,)}, 0xC2: {"uid": (0,)},
|
|
533
|
+
0x43: {"slot": (0,)}, # RunSharedScript (STARTSEQ) -- entry idx
|
|
534
|
+
}
|
|
535
|
+
INIT_OPS = (0x09, 0x07, 0x08) # the uid arg defaults to the slot when it is 0 (not an explicit ref)
|
|
536
|
+
RUNSCRIPT_OPS = (0x10, 0x12, 0x14) # carry a (uid, tag) -- the tag is the player function the object calls
|
|
537
|
+
UID_PLAYER, UID_SELF = 250, 255
|
|
538
|
+
PARTY_UIDS = (251, 252, 253, 254)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def resolve_uid(uid, current_entry, player_entries=(), entry_count=0):
|
|
542
|
+
"""The engine's ``GetObjUID`` convention, in ONE place -- an object's uid defaults to its ENTRY INDEX,
|
|
543
|
+
with reserved high ids for self / the player / party slots (engine-verified ``EventEngine.cs``: uid 0
|
|
544
|
+
resolves to the object whose ``obj.uid == 0``, which defaults to entry index 0 = Main_Init). Returns
|
|
545
|
+
``(kind, targets)`` where ``targets`` is the entry index(es) a call could dispatch into (empty when it
|
|
546
|
+
can't be resolved offline -- party / unknown):
|
|
547
|
+
|
|
548
|
+
255 -> ('self', [current_entry])
|
|
549
|
+
250 or a PC -> ('player', [player_entries...]) # 182 fields define >1 DefinePlayerCharacter
|
|
550
|
+
251-254 -> ('party', [])
|
|
551
|
+
0 -> ('main', [0]) # Main_Init shared logic
|
|
552
|
+
0 < uid < N -> ('object', [uid])
|
|
553
|
+
anything else -> ('unknown', [])
|
|
554
|
+
|
|
555
|
+
The single source of truth for the convention otherwise re-derived in ``forkreport._explain_call`` and
|
|
556
|
+
(with a graft-specific taxonomy) ``_classify_ref`` / ``_savepoint_cluster``."""
|
|
557
|
+
pents = tuple(player_entries)
|
|
558
|
+
if uid == UID_SELF:
|
|
559
|
+
return ("self", [current_entry])
|
|
560
|
+
if uid == UID_PLAYER or uid in pents:
|
|
561
|
+
return ("player", list(pents))
|
|
562
|
+
if uid in PARTY_UIDS:
|
|
563
|
+
return ("party", [])
|
|
564
|
+
if uid == 0:
|
|
565
|
+
return ("main", [0])
|
|
566
|
+
if 0 <= uid < entry_count:
|
|
567
|
+
return ("object", [uid])
|
|
568
|
+
return ("unknown", [])
|
|
569
|
+
FORK_PLAYER_TAGS = frozenset((0, 1)) # a blank fork's player (Zidane) defines only Init+Loop -- a carried
|
|
570
|
+
# object that RunScripts a player tag >= 2 dangles (softlock); that
|
|
571
|
+
# interaction can only be lit up by a later donor-player-script graft.
|
|
572
|
+
RENDER_TAGS = (0, 1) # Init + Loop: model/pose/placement/flags/size all live here
|
|
573
|
+
_OBJVAR_RE = re.compile(r"op78\((\d+),") # B_OBJSPECA expression token: op78(uid, field) -- a uid read
|
|
574
|
+
|
|
575
|
+
# --- player-function graft (docs/PLAYER_GRAFT.md): carry the donor player funcs a carried object RunScripts ---
|
|
576
|
+
RUN_MODEL_CODE_OP = 0x88 # RunModelCode(code, pack) -- the player Init's animation-pack loads
|
|
577
|
+
# Every Zidane field-model FORM (GEO_MAIN_*_ZDN main/LOD F0-F5 + the ZDD disguise), so a Zidane field is not
|
|
578
|
+
# misread as "non-Zidane" (was {98, 93} -> the ZDD/LOD forms leaked into the non-Zidane lists, e.g. field 401
|
|
579
|
+
# "Zidane(ZDD)"). 93 = the blank custom-field placeholder rig (kept). A non-Zidane donor's clips won't match these.
|
|
580
|
+
ZIDANE_MODELS = frozenset((93, 98, 203, 432, 532, 668, 669, 670))
|
|
581
|
+
TEXT_OPS = frozenset((0x1F, 0x20, 0x95, 0x96)) # WindowSync/Async[Ex] -- references a .mes TXID the fork lacks
|
|
582
|
+
ANIM_OPS = frozenset((0x33, 0x34, 0x40, 0x94)) # Set{Stand,Walk}Animation/RunAnimation/SetJumpAnimation: MODEL-keyed clips
|
|
583
|
+
# --- STARTSEQ-helper closure (docs/OBJECT_CARRY.md S2 v1.5): a carried object launches a concurrent type-1
|
|
584
|
+
# Seq helper via STARTSEQ (0x43, an ENTRY index). The fork drops the helper -> the object refused/init_only.
|
|
585
|
+
# Carry the helper too (like the ladder `sequences` graft). But a bare type-1 check is WRONG: ~15 of the 164
|
|
586
|
+
# real helpers contain a CUTSCENE op (a MoveCamera sweep, a Battle, a Field warp, a menu) that must NOT fire in
|
|
587
|
+
# a static fork -- so vet the helper BODY. UNSAFE_SEQ_OPS = warp / battle / camera / menu / window / fade (the
|
|
588
|
+
# census found ONLY these families across all 164 helpers; reproduces the 48/32/9 flip split byte-for-byte).
|
|
589
|
+
UNSAFE_SEQ_OPS = frozenset((
|
|
590
|
+
0x1F, 0x20, 0x21, 0x95, 0x96, 0x8E, 0xEB, 0x54, 0x53, 0xC9, # Window* / Close*Window / Raise/WaitWindow / tile-loop
|
|
591
|
+
0x2A, 0x8C, 0xD0, 0xE1, 0x1B, 0x3C, 0x4A, 0x57, # Battle / BattleEx / BattleDialog / ...Battle...
|
|
592
|
+
0x2B, 0xB6, 0xFD, # Field / WorldMap / PreloadField (a warp mid-Seq)
|
|
593
|
+
0x6F, 0x70, # MoveCamera / ReleaseCamera (a cutscene camera)
|
|
594
|
+
0x75, 0xAA, 0xAB, # Menu / Enable / DisableMenu
|
|
595
|
+
0xEC, # FadeFilter
|
|
596
|
+
))
|
|
597
|
+
# the allow-list for a graft-safe player GESTURE: turn / animation / wait / head-focus / char-attr / jump-arc +
|
|
598
|
+
# structure (nop/jumps/return/expr/switch/wait). Anything ELSE (text, warp, camera, scripted walk, menu, sound,
|
|
599
|
+
# give-item, a sibling uid ref, a RunScript) disqualifies the func -> it stays refused (its object stays init_only).
|
|
600
|
+
SAFE_GESTURE_OPS = frozenset((
|
|
601
|
+
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x22, # nop / jumps / return / expr / switch / wait
|
|
602
|
+
0x36, 0x56, 0x9B, 0x51, 0x50, 0x99, # TurnInstant/TimedTurn/TurnToward{Position,Object}/WaitTurn/SetTurnSpeed
|
|
603
|
+
# (0x51 TurnTowardObject is safe -- its object ref is vetted
|
|
604
|
+
# by the carried-sibling check, like the save Moogle's 13/14/15)
|
|
605
|
+
0x33, 0x34, 0x40, 0x41, 0x3F, 0x3D, # Set{Stand,Walk}Anim/RunAnimation/WaitAnimation/AnimFlags/AnimInOut
|
|
606
|
+
0x47, 0x8B, # EnableHeadFocus / SetHeadFocusMask
|
|
607
|
+
0xCC, 0xCD, # Add/RemoveCharacterAttribute (ladder flag etc.)
|
|
608
|
+
0xE2, 0xDC, 0x9C, 0x9D, 0x94, 0xA8, # jump-arc: SetupJump/Jump/RunJump/RunLand/SetJumpAnim/SetPathing
|
|
609
|
+
))
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _is_player_entry(val, donor_player_entry) -> bool:
|
|
613
|
+
"""True if ``val`` is a donor player ENTRY INDEX. ``donor_player_entry`` is an int (the primary PC) OR a
|
|
614
|
+
collection of every PC entry index (182 real fields define >1 ``DefinePlayerCharacter`` -- a secondary-PC
|
|
615
|
+
ref must classify as ``player``, else it leaks into ``uncarried`` and is mistaken for a closeable sibling)."""
|
|
616
|
+
if donor_player_entry is None:
|
|
617
|
+
return False
|
|
618
|
+
if isinstance(donor_player_entry, int):
|
|
619
|
+
return val == donor_player_entry
|
|
620
|
+
return val in donor_player_entry
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _seq_helper_safe(eb, ei: int) -> bool:
|
|
624
|
+
"""Is entry ``ei`` a CLOSEABLE STARTSEQ helper (docs/OBJECT_CARRY.md S2 v1.5)? In-range + a type-1
|
|
625
|
+
Seq/region entry + a BENIGN body (no :data:`UNSAFE_SEQ_OPS` cutscene op and no nested ``STARTSEQ`` --
|
|
626
|
+
census: depth-1, 0 nested). A benign helper is launched as a concurrent per-frame Seq, so carrying it +
|
|
627
|
+
remapping the launcher's entry-arg is the proven ladder ``sequences`` graft. An unsafe one (a MoveCamera
|
|
628
|
+
sweep / a Battle / a warp) must stay refused so it can't fire in a static fork."""
|
|
629
|
+
if not (0 <= ei < eb.entry_count):
|
|
630
|
+
return False
|
|
631
|
+
e = eb.entry(ei)
|
|
632
|
+
if e.empty or _entry_bytes(eb.data, ei)[:1] != bytes([1]): # type byte 1 = a Seq/region helper entry
|
|
633
|
+
return False
|
|
634
|
+
for f in e.funcs:
|
|
635
|
+
for ins in eb.instrs(f):
|
|
636
|
+
if ins.op == RUN_SHARED_SCRIPT or ins.op in UNSAFE_SEQ_OPS: # nested STARTSEQ or a cutscene op
|
|
637
|
+
return False
|
|
638
|
+
return True
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _classify_ref(kind: str, val: int, donor_player_entry, carried_slots, self_slot: int) -> str:
|
|
642
|
+
"""Classify one slot/uid reference value: self | player | party | sibling | uncarried."""
|
|
643
|
+
if val == self_slot:
|
|
644
|
+
return "self"
|
|
645
|
+
if kind == "uid":
|
|
646
|
+
if val == UID_SELF:
|
|
647
|
+
return "self"
|
|
648
|
+
if val == UID_PLAYER or _is_player_entry(val, donor_player_entry):
|
|
649
|
+
return "player" # 250, or the player BY ENTRY INDEX (-> 250 on graft)
|
|
650
|
+
if val in PARTY_UIDS:
|
|
651
|
+
return "party"
|
|
652
|
+
return "sibling" if val in carried_slots else "uncarried"
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _expr_obj_uids(expr) -> list:
|
|
656
|
+
"""The object UIDs an expression operand reads via the ``op78(uid, field)`` token (B_OBJSPECA)."""
|
|
657
|
+
return [int(m) for m in _OBJVAR_RE.findall(expr)] if isinstance(expr, str) else []
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _classify_entry_refs(eb, entry, donor_player_entry, carried_slots, self_slot):
|
|
661
|
+
"""Classify every outbound slot/uid reference the entry's functions make. Returns
|
|
662
|
+
``(refs, player_tags)`` -- ``refs`` is one record per reference (func_tag/op/kind/value/klass[/tag]);
|
|
663
|
+
``player_tags`` is the set of player function tags the entry RunScripts (the donor-script dependency)."""
|
|
664
|
+
refs, player_tags = [], set()
|
|
665
|
+
for f in entry.funcs:
|
|
666
|
+
for ins in eb.instrs(f):
|
|
667
|
+
spec = REF_OPS.get(ins.op)
|
|
668
|
+
if spec:
|
|
669
|
+
for kind in ("slot", "uid"):
|
|
670
|
+
for ai in spec.get(kind, ()):
|
|
671
|
+
if ins.arg_is_expr[ai] if ai < len(ins.arg_is_expr) else False:
|
|
672
|
+
refs.append({"func_tag": f.tag, "op": ins.op, "op_name": ins.name,
|
|
673
|
+
"kind": kind, "arg_index": ai, "value": None, "klass": "expr"})
|
|
674
|
+
continue
|
|
675
|
+
val = ins.imm(ai)
|
|
676
|
+
if val is None:
|
|
677
|
+
continue
|
|
678
|
+
if kind == "uid" and ins.op in INIT_OPS and val == 0:
|
|
679
|
+
continue # uid 0 aliases the slot arg -- not an explicit ref
|
|
680
|
+
rec = {"func_tag": f.tag, "op": ins.op, "op_name": ins.name, "kind": kind,
|
|
681
|
+
"arg_index": ai, "value": int(val),
|
|
682
|
+
"klass": _classify_ref(kind, int(val), donor_player_entry, carried_slots, self_slot)}
|
|
683
|
+
if ins.op in RUNSCRIPT_OPS and ai == 1:
|
|
684
|
+
t = ins.imm(2) # the called function tag (player OR self/sibling)
|
|
685
|
+
if t is not None:
|
|
686
|
+
rec["tag"] = int(t)
|
|
687
|
+
if rec["klass"] == "player":
|
|
688
|
+
player_tags.add(int(t))
|
|
689
|
+
refs.append(rec)
|
|
690
|
+
for ai, is_expr in enumerate(ins.arg_is_expr):
|
|
691
|
+
if is_expr:
|
|
692
|
+
for uidv in _expr_obj_uids(ins.args[ai]):
|
|
693
|
+
refs.append({"func_tag": f.tag, "op": ins.op, "op_name": ins.name,
|
|
694
|
+
"kind": "expr_objvar", "arg_index": ai, "value": uidv,
|
|
695
|
+
"klass": _classify_ref("uid", uidv, donor_player_entry, carried_slots, self_slot)})
|
|
696
|
+
return refs, player_tags
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _graft_safety(entry, refs, fork_player_tags, *, graftable_player_tags=frozenset(),
|
|
700
|
+
seq_closeable=frozenset()):
|
|
701
|
+
"""Per the carry policy (docs/OBJECT_CARRY.md S4): is the WHOLE entry graftable, only its
|
|
702
|
+
render-defining tags (the rest reference player funcs a blank fork lacks / uncarried siblings), or
|
|
703
|
+
must it be refused? Returns ``(safety, carry_tags)``. A reference is SAFE when it resolves to
|
|
704
|
+
self / a carried sibling / the player at a tag the fork has / a BENIGN STARTSEQ helper the closure
|
|
705
|
+
carries (``seq_closeable``); everything else (a player tag >= 2, an uncarried sibling, party, an
|
|
706
|
+
expression-computed uid, an UNSAFE STARTSEQ helper) leaves its function un-graftable. A FIXPOINT then
|
|
707
|
+
also drops any function that ``RunScript``s a SELF tag we are dropping (else it would dangle).
|
|
708
|
+
``seq_closeable`` defaults empty -> byte-identical to before (the v1.5 closure is opt-in)."""
|
|
709
|
+
bad_tags, self_deps = set(), {}
|
|
710
|
+
for r in refs:
|
|
711
|
+
if r["op"] in RUNSCRIPT_OPS and r["kind"] == "uid" and r["klass"] == "self" and "tag" in r:
|
|
712
|
+
self_deps.setdefault(r["func_tag"], set()).add(r["tag"]) # F depends on its own func `tag`
|
|
713
|
+
if r["klass"] in ("self", "sibling"):
|
|
714
|
+
ok = True
|
|
715
|
+
elif r["klass"] == "player":
|
|
716
|
+
# safe if the fork player has the tag (0/1) OR it WILL be grafted (the player-function graft,
|
|
717
|
+
# docs/PLAYER_GRAFT.md). graftable_player_tags defaults empty -> byte-identical to before.
|
|
718
|
+
ok = r.get("tag") is None or r["tag"] in fork_player_tags or r["tag"] in graftable_player_tags
|
|
719
|
+
elif r["op"] == RUN_SHARED_SCRIPT and r["kind"] == "slot" and r.get("value") in seq_closeable:
|
|
720
|
+
ok = True # an uncarried but BENIGN STARTSEQ helper -> closure carries it
|
|
721
|
+
else: # party / uncarried / expr / unsafe-helper -> not resolvable
|
|
722
|
+
ok = False
|
|
723
|
+
if not ok:
|
|
724
|
+
bad_tags.add(r["func_tag"])
|
|
725
|
+
changed = True
|
|
726
|
+
while changed: # propagate: a kept func calling a dropped func is bad
|
|
727
|
+
changed = False
|
|
728
|
+
for ftag, targets in self_deps.items():
|
|
729
|
+
if ftag not in bad_tags and (targets & bad_tags):
|
|
730
|
+
bad_tags.add(ftag)
|
|
731
|
+
changed = True
|
|
732
|
+
all_tags = sorted({f.tag for f in entry.funcs})
|
|
733
|
+
if not bad_tags:
|
|
734
|
+
return "clean", all_tags
|
|
735
|
+
if any(t in bad_tags for t in RENDER_TAGS): # can't even render faithfully -> hand back to author
|
|
736
|
+
return "refuse", []
|
|
737
|
+
return "init_only", [t for t in all_tags if t not in bad_tags]
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def scan_objects(eb_bytes) -> list:
|
|
741
|
+
"""Persistent NPCs/props a real field places, for a FAITHFUL fork. Returns a list of
|
|
742
|
+
``{kind: "npc"|"prop", model, model_id, animset, pose, x, z, face, talkable, slot}``.
|
|
743
|
+
|
|
744
|
+
FF9 spawns an object with ``InitObject(slot)`` in Main_Init (entry 0, tag 0); the object's own Init
|
|
745
|
+
(tag 0) does ``SetModel`` + ``SetStandAnimation`` + a placement. We walk Main_Init in order, tracking
|
|
746
|
+
the D9 position vars (``SetVar D9(0/2/4)=const``) so each ``InitObject`` records the (x,z) in force;
|
|
747
|
+
then read each spawned object's Init. Placement = a LITERAL ``MoveInstantXZY(worldX,-worldY,worldZ)``
|
|
748
|
+
if present, else the tracked D9 (x,z). One entry InitObject'd N times yields N instances (a row of
|
|
749
|
+
boxes). SKIPPED: the player (``DefinePlayerCharacter``) and ``GEO_MAIN`` models (the party) -- and
|
|
750
|
+
CUTSCENE actors fall out naturally (they have neither a literal ``MoveInstantXZY`` nor a tracked D9
|
|
751
|
+
placement; they position by expression). Objects loaded HIDDEN -- ``SetObjectFlags`` without the
|
|
752
|
+
show-model bit (1) -- are also skipped: those are SCRIPT-driven (a save point's moogle/book/tent, an
|
|
753
|
+
event prop), shown/animated by the field script, NOT static set-dressing (carrying them places the
|
|
754
|
+
machinery wrong, e.g. an always-deployed tent). A talkable object (tag 3) -> ``"npc"``; else ->
|
|
755
|
+
``"prop"``. The model + pose + placement ARE carried; dialogue TEXT is NOT (author it on the fork)."""
|
|
756
|
+
from ._modeldb import MODELS # local: only import needs the model-name table
|
|
757
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
758
|
+
e0 = next((e for e in eb.entries if not e.empty and e.index == 0), None)
|
|
759
|
+
f0 = e0.func_by_tag(0) if e0 else None
|
|
760
|
+
if f0 is None:
|
|
761
|
+
return []
|
|
762
|
+
|
|
763
|
+
# 1) walk Main_Init: track D9(idx)=const, record each InitObject(slot) at the current (x,z)
|
|
764
|
+
d9: dict = {}
|
|
765
|
+
instances = [] # (slot, x_or_None, z_or_None)
|
|
766
|
+
for ins in eb.instrs(f0):
|
|
767
|
+
if ins.op == SETVAR_EXPR_OP:
|
|
768
|
+
raw = eb.data[ins.off:ins.off + 8]
|
|
769
|
+
if len(raw) >= 6 and raw[1] == POS_VAR_CLASS and raw[3] == 0x7D:
|
|
770
|
+
d9[raw[2]] = _s16(raw[4] | (raw[5] << 8))
|
|
771
|
+
elif ins.op == INIT_OBJECT_OP and ins.args:
|
|
772
|
+
instances.append((int(ins.args[0]), d9.get(0), d9.get(4)))
|
|
773
|
+
slot_count: dict = {}
|
|
774
|
+
for s, _x, _z in instances:
|
|
775
|
+
slot_count[s] = slot_count.get(s, 0) + 1
|
|
776
|
+
|
|
777
|
+
# 2) read each spawned object's Init
|
|
778
|
+
out = []
|
|
779
|
+
for slot, dx, dz in instances:
|
|
780
|
+
if not 0 <= slot < eb.entry_count:
|
|
781
|
+
continue
|
|
782
|
+
e = eb.entry(slot)
|
|
783
|
+
fi = e.func_by_tag(0) if not e.empty else None
|
|
784
|
+
if fi is None:
|
|
785
|
+
continue
|
|
786
|
+
rd = _read_object_init(eb, fi)
|
|
787
|
+
model, animset, pose, face = rd["model"], rd["animset"], rd["pose"], rd["face"]
|
|
788
|
+
lit, local, flags, player = rd["lit"], rd["local"], rd["flags"], rd["player"]
|
|
789
|
+
if player or model is None:
|
|
790
|
+
continue
|
|
791
|
+
if flags is not None and not (flags & SHOW_MODEL_BIT):
|
|
792
|
+
continue # loaded HIDDEN -> shown/animated by SCRIPT (a save
|
|
793
|
+
# point, an event prop), NOT static set-dressing
|
|
794
|
+
name = MODELS.get(model)
|
|
795
|
+
if name and name.startswith("GEO_MAIN"): # the party -- not set-dressing
|
|
796
|
+
continue
|
|
797
|
+
if lit is not None: # a literal MoveInstantXZY -- the real props
|
|
798
|
+
x, z = lit
|
|
799
|
+
elif 0 in local and 4 in local and slot_count[slot] == 1: # the object set its OWN single position
|
|
800
|
+
x, z = local[0], local[4] # (a kit-injected prop, or a single real D9-positioned one)
|
|
801
|
+
elif dx is not None and dz is not None: # position carried in Main_Init's D9 before InitObject
|
|
802
|
+
x, z = dx, dz
|
|
803
|
+
else:
|
|
804
|
+
continue # no STATIC placement (cutscene actor / arg-instanced) -> skip
|
|
805
|
+
out.append({"kind": "npc" if e.func_by_tag(3) is not None else "prop",
|
|
806
|
+
"model": name or model, "model_id": model, "animset": animset, "pose": pose,
|
|
807
|
+
"x": int(x), "z": int(z), "face": face,
|
|
808
|
+
"talkable": e.func_by_tag(3) is not None, "slot": slot})
|
|
809
|
+
return out
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
SAVE_MOOGLE_MODEL = 220 # GEO_NPC_F0_MOG -- the save Moogle (the save-point cluster's seed)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _savepoint_cluster(eb, init_slots) -> frozenset:
|
|
816
|
+
"""The save-Moogle CLUSTER a faithful save-point fork carries as a unit: a hidden, InitObject'd Moogle
|
|
817
|
+
(model :data:`SAVE_MOOGLE_MODEL`) + the transitive closure of the hidden sibling entries it
|
|
818
|
+
``RunScript``s -- its book/feather/tent props (entries 6/7/9 in field 122). These are script-hidden
|
|
819
|
+
(loaded without the show-model bit) so they're SKIPPED by default (correctly -- a hidden object is
|
|
820
|
+
generally shown/animated by script and can't be statically placed); the save cluster is the recognised
|
|
821
|
+
exception. The Moogle's STARTSEQ helpers are carried by the separate ``graft_seq_helpers`` closure, its
|
|
822
|
+
player-pose funcs by the player graft. Returns the cluster entry indices (empty if no hidden Moogle is
|
|
823
|
+
InitObject'd)."""
|
|
824
|
+
def hidden_init(idx):
|
|
825
|
+
if not 0 <= idx < eb.entry_count:
|
|
826
|
+
return None
|
|
827
|
+
e = eb.entry(idx)
|
|
828
|
+
fi = e.func_by_tag(0) if not e.empty else None
|
|
829
|
+
if fi is None:
|
|
830
|
+
return None
|
|
831
|
+
rd = _read_object_init(eb, fi)
|
|
832
|
+
return rd if (rd["model"] is not None and rd["flags"] is not None
|
|
833
|
+
and not (rd["flags"] & SHOW_MODEL_BIT)) else None
|
|
834
|
+
|
|
835
|
+
seeds = [s for s in init_slots if (hidden_init(s) or {}).get("model") == SAVE_MOOGLE_MODEL]
|
|
836
|
+
cluster = set(seeds)
|
|
837
|
+
frontier = list(seeds)
|
|
838
|
+
while frontier:
|
|
839
|
+
for f in eb.entry(frontier.pop()).funcs:
|
|
840
|
+
for ins in eb.instrs(f):
|
|
841
|
+
if ins.op in RUNSCRIPT_OPS and len(ins.args) >= 2 and isinstance(ins.args[1], int):
|
|
842
|
+
ref = int(ins.args[1])
|
|
843
|
+
if (0 <= ref < eb.entry_count and ref not in cluster
|
|
844
|
+
and ref not in (250, 255) and not (251 <= ref <= 254)
|
|
845
|
+
and hidden_init(ref)): # only HIDDEN siblings (the cluster props)
|
|
846
|
+
cluster.add(ref)
|
|
847
|
+
frontier.append(ref)
|
|
848
|
+
return frozenset(cluster)
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def extract_savepoint_director(eb_bytes):
|
|
852
|
+
"""The save-sequence DIRECTOR a faithful save-Moogle carry needs: the donor field's **entry-0 tag-1**
|
|
853
|
+
(the system/main entry's loop). It puppeteers the carried Moogle PURELY via shared MAP vars -- it
|
|
854
|
+
``Wait``s on a handshake var, advances the Moogle's state var through its sequence, and fires the save
|
|
855
|
+
flash. Unlike the Moogle/cask it is NOT an object (never ``InitObject``'d) -- it IS the field's main-loop
|
|
856
|
+
logic -- so the object carry misses it and the carried Moogle defaults to its resting pose
|
|
857
|
+
(docs/SAVEPOINT.md). Returns the director's VERBATIM body bytes (to graft into the fork's empty entry-0
|
|
858
|
+
tag-1), or ``None`` when the field has no save Moogle, no entry-0 tag-1, or the director makes direct
|
|
859
|
+
entry references (then it isn't a clean shared-var driver -- refuse rather than dangle).
|
|
860
|
+
|
|
861
|
+
The director drives the Moogle through shared MAP vars ONLY (zero RunScript/Init* entry refs), so it
|
|
862
|
+
grafts verbatim with no remap -- the Moogle (carried) + cask (carried) + director write/read the same
|
|
863
|
+
transient MAP vars, reconstituting the exact source-field state machine."""
|
|
864
|
+
eb = EbScript.from_bytes(eb_bytes) if isinstance(eb_bytes, (bytes, bytearray)) else eb_bytes
|
|
865
|
+
e0 = eb.entry(0) if eb.entry_count > 0 else None
|
|
866
|
+
f0 = e0.func_by_tag(0) if (e0 and not e0.empty) else None
|
|
867
|
+
if f0 is None:
|
|
868
|
+
return None
|
|
869
|
+
init_slots = [int(i.imm(0)) for i in eb.instrs(f0) if i.op == 0x09] # InitObject targets in Main_Init
|
|
870
|
+
if not _savepoint_cluster(eb, init_slots): # no save Moogle in this field -> no director
|
|
871
|
+
return None
|
|
872
|
+
director = e0.func_by_tag(1)
|
|
873
|
+
if director is None:
|
|
874
|
+
return None
|
|
875
|
+
ins = list(eb.instrs(director))
|
|
876
|
+
if not ins:
|
|
877
|
+
return None
|
|
878
|
+
# safety: a clean director references NO entries (drives the Moogle via shared vars). If it RunScripts /
|
|
879
|
+
# Inits an entry, grafting it verbatim would dangle -> refuse (this field's main loop does more than drive
|
|
880
|
+
# the Moogle; a future refinement would slice out just the Moogle-state portion).
|
|
881
|
+
if any(i.op in (0x10, 0x12, 0x14, 0x43, 0x07, 0x08, 0x09) for i in ins):
|
|
882
|
+
return None
|
|
883
|
+
body = bytearray(eb.data[ins[0].off:ins[-1].end])
|
|
884
|
+
base = ins[0].off
|
|
885
|
+
for i in ins:
|
|
886
|
+
if i.op == 0x6B: # SetBackgroundColor = the save FLASH. The donor field
|
|
887
|
+
for k in range(i.off, i.end): # restores it elsewhere (not carried), so in a fork it
|
|
888
|
+
body[k - base] = 0x00 # would persist (white pillarbox bars). NOP it in place
|
|
889
|
+
# (keep the byte length so the director's relative jumps stay valid). In-game proven: the bars vanish.
|
|
890
|
+
return bytes(body)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def spawn_settle_mismatch(eb, idx):
|
|
894
|
+
"""The 'spawn-flash' signature of a director-driven carried object (P6.1, docs/SAVEPOINT.md): its **Init**
|
|
895
|
+
raises it to one height -- a literal Y with self-relative (``op78``) X/Z -- but its **loop** settles it to
|
|
896
|
+
another, via a fully-literal ``MoveInstantXZY``. A real field's entrance fade hides that one-shot
|
|
897
|
+
spawn-then-move; a fork's (F6-warp / a custom entrance) may not, so the object visibly spawns at the wrong
|
|
898
|
+
pose then snaps to rest (e.g. the save Moogle standing ON the barrel for ~100ms, then dropping IN).
|
|
899
|
+
|
|
900
|
+
Returns ``(init_y, settle_y, init_y_offset_in_entry, size)`` when the Init Y differs from the settle Y
|
|
901
|
+
(so a caller can normalise the Init Y to the settle Y -- a same-length patch in the entry's bytes), else
|
|
902
|
+
``None``. Pure detection; static, no runtime needed -- the insight the in-game capture surfaced."""
|
|
903
|
+
from .eb.disasm import argsize, read_expr
|
|
904
|
+
e = eb.entry(idx)
|
|
905
|
+
f0 = e.func_by_tag(0) if not e.empty else None
|
|
906
|
+
f1 = e.func_by_tag(1) if not e.empty else None
|
|
907
|
+
if f0 is None or f1 is None:
|
|
908
|
+
return None
|
|
909
|
+
init_mv = next((i for i in eb.instrs(f0) if i.op == 0xA1 and len(i.arg_is_expr) >= 2
|
|
910
|
+
and i.arg_is_expr[0] and not i.arg_is_expr[1]), None) # spawn: self X/Z, literal Y
|
|
911
|
+
settle_mv = next((i for i in eb.instrs(f1) if i.op == 0xA1 and not any(i.arg_is_expr)), None) # rest: all literal
|
|
912
|
+
if init_mv is None or settle_mv is None:
|
|
913
|
+
return None
|
|
914
|
+
iy, sy = init_mv.imm(1), settle_mv.imm(1)
|
|
915
|
+
if iy is None or sy is None or iy == sy:
|
|
916
|
+
return None
|
|
917
|
+
raw = eb.data[init_mv.off:init_mv.end] # a1 argflags <Xexpr> Y <Zexpr>
|
|
918
|
+
_, ypos = read_expr(raw, 2) # walk the self X-expr -> Y offset
|
|
919
|
+
base = 128 + u16(eb.data, 128 + idx * 8) # entry start in eb.data
|
|
920
|
+
return (iy, sy, (init_mv.off - base) + ypos, argsize(0xA1, 1))
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def scan_objects_verbatim(eb_bytes, *, fork_player_tags=FORK_PLAYER_TAGS, graft_player_funcs=False,
|
|
924
|
+
carry_text=False, graft_seq_helpers=False, graft_savepoint=False) -> list:
|
|
925
|
+
"""Graft specs for a FAITHFUL fork: each persistent object's VERBATIM ``.eb`` entry plus the data
|
|
926
|
+
needed to append it at a free slot, arm it, and remap its references -- the faithful counterpart of
|
|
927
|
+
:func:`scan_objects` (which emits human-authored ``[[npc]]``/``[[prop]]`` stubs). Where scan_objects
|
|
928
|
+
returns the DECODED facts (model/pose/pos), this carries the RAW entry bytes so the object renders
|
|
929
|
+
byte-identical to the real field (no player-clone lossiness), with the cross-reference classification
|
|
930
|
+
that decides the carry (docs/OBJECT_CARRY.md). The FULL entry bytes are ALWAYS carried (non-
|
|
931
|
+
destructive), so a later 'graft the donor player scripts' pass can light up the deferred tags.
|
|
932
|
+
|
|
933
|
+
Skips the same non-set-dressing objects as scan_objects (player / ``GEO_MAIN`` party / script-hidden)
|
|
934
|
+
AND adds the player-entry-index guard ``scan_jumps`` uses. One dict per carried object (grouped by
|
|
935
|
+
donor slot):
|
|
936
|
+
``donor_idx, entry_bytes, kind, model, model_id, animset, pose, face, instances[{arg,x,z}],
|
|
937
|
+
self_positions, needs_d9{idx:val}, donor_player_entry, donor_player_entries, refs[...],
|
|
938
|
+
player_tags_needed[...], graft_safety("clean"|"init_only"|"refuse"), carry_tags[...], seqs[...]``.
|
|
939
|
+
|
|
940
|
+
``graft_seq_helpers`` (docs/OBJECT_CARRY.md S2 v1.5): when on, an object whose only blocker is an
|
|
941
|
+
uncarried but BENIGN ``STARTSEQ`` (RunSharedScript) helper entry is un-refused -- the closure carries the
|
|
942
|
+
helper too (``seqs``: one ``{entry, bytes}`` per distinct closeable helper the object launches from a kept
|
|
943
|
+
tag) and ``build`` appends + remaps it like the ladder ``sequences`` graft. OFF by default -> byte-identical.
|
|
944
|
+
"""
|
|
945
|
+
from ._modeldb import MODELS
|
|
946
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
947
|
+
pents = resolve_player_entries(eb) # ALL DefinePlayerCharacter entries (182 fields define >1)
|
|
948
|
+
dpe = pents[0] if pents else None # the primary, kept for the sidecar/grafter back-compat
|
|
949
|
+
e0 = next((e for e in eb.entries if not e.empty and e.index == 0), None)
|
|
950
|
+
f0 = e0.func_by_tag(0) if e0 else None
|
|
951
|
+
if f0 is None:
|
|
952
|
+
return []
|
|
953
|
+
|
|
954
|
+
# 1) walk Main_Init: each InitObject records the D9 position snapshot in force + its instancing arg
|
|
955
|
+
d9: dict = {}
|
|
956
|
+
grouped: dict = {}
|
|
957
|
+
order = []
|
|
958
|
+
for ins in eb.instrs(f0):
|
|
959
|
+
if ins.op == SETVAR_EXPR_OP:
|
|
960
|
+
raw = eb.data[ins.off:ins.off + 8]
|
|
961
|
+
if len(raw) >= 6 and raw[1] == POS_VAR_CLASS and raw[3] == 0x7D:
|
|
962
|
+
d9[raw[2]] = _s16(raw[4] | (raw[5] << 8))
|
|
963
|
+
elif ins.op == INIT_OBJECT_OP and ins.args and isinstance(ins.args[0], int):
|
|
964
|
+
slot = int(ins.args[0])
|
|
965
|
+
arg = int(ins.args[1]) if len(ins.args) >= 2 and isinstance(ins.args[1], int) else 0
|
|
966
|
+
if slot not in grouped:
|
|
967
|
+
grouped[slot] = []
|
|
968
|
+
order.append(slot)
|
|
969
|
+
grouped[slot].append((arg, dict(d9)))
|
|
970
|
+
|
|
971
|
+
# the recognised save-Moogle cluster (hidden Moogle + its hidden book/feather/tent props): carried as a
|
|
972
|
+
# UNIT despite being script-hidden, so a forked field's save point comes along verbatim (docs/SAVEPOINT.md).
|
|
973
|
+
savepoint_cluster = _savepoint_cluster(eb, order) if graft_savepoint else frozenset()
|
|
974
|
+
|
|
975
|
+
# 2) which slots are actually carried (apply the skip rules first, so sibling refs classify right)
|
|
976
|
+
info: dict = {}
|
|
977
|
+
for slot in order:
|
|
978
|
+
if not 0 <= slot < eb.entry_count or slot in pents: # player-entry guard (every PC, as scan_jumps does)
|
|
979
|
+
continue
|
|
980
|
+
e = eb.entry(slot)
|
|
981
|
+
fi = e.func_by_tag(0) if not e.empty else None
|
|
982
|
+
if fi is None:
|
|
983
|
+
continue
|
|
984
|
+
rd = _read_object_init(eb, fi)
|
|
985
|
+
if rd["player"] or rd["model"] is None:
|
|
986
|
+
continue
|
|
987
|
+
if rd["flags"] is not None and not (rd["flags"] & SHOW_MODEL_BIT) and slot not in savepoint_cluster:
|
|
988
|
+
continue # script-hidden (save machinery / event)
|
|
989
|
+
name = MODELS.get(rd["model"])
|
|
990
|
+
if name and name.startswith("GEO_MAIN"): # the party -- not set-dressing
|
|
991
|
+
continue
|
|
992
|
+
info[slot] = rd
|
|
993
|
+
carried = set(info)
|
|
994
|
+
|
|
995
|
+
# the player funcs the player-function graft WILL carry (docs/PLAYER_GRAFT.md): those tags become SAFE,
|
|
996
|
+
# flipping an object from init_only to whole-entry. OFF by default (byte-identical). scan_player_funcs
|
|
997
|
+
# calls scan_objects_verbatim with graft_player_funcs OFF (its default), so there is no recursion --
|
|
998
|
+
# ``graft_savepoint`` is threaded so it sees the save cluster's player tags (13/14/15). ``carry_text``
|
|
999
|
+
# ALSO admits a "text" player func (its window TXID is carried + remapped by content.textcarry, so its
|
|
1000
|
+
# bytes are graft-safe once the text ships) -- so the seeding object carries its interactive tag whole.
|
|
1001
|
+
graftable_player = frozenset()
|
|
1002
|
+
if graft_player_funcs:
|
|
1003
|
+
ok = {"clean", "text"} if carry_text else {"clean"}
|
|
1004
|
+
graftable_player = frozenset(p["donor_tag"] for p in scan_player_funcs(eb_bytes, graft_savepoint=graft_savepoint)
|
|
1005
|
+
if p["safety"] in ok)
|
|
1006
|
+
|
|
1007
|
+
# the BENIGN STARTSEQ helpers the closure carries (docs/OBJECT_CARRY.md S2 v1.5): every uncarried entry a
|
|
1008
|
+
# carried object launches via STARTSEQ that passes the body vet. OFF by default (byte-identical). These make
|
|
1009
|
+
# a STARTSEQ ref SAFE in _graft_safety, flipping an object refuse->graftable / init_only->whole-entry.
|
|
1010
|
+
seq_closeable = frozenset()
|
|
1011
|
+
if graft_seq_helpers:
|
|
1012
|
+
cand = set()
|
|
1013
|
+
for slot in carried:
|
|
1014
|
+
for f in eb.entry(slot).funcs:
|
|
1015
|
+
for ins in eb.instrs(f):
|
|
1016
|
+
if ins.op == RUN_SHARED_SCRIPT and ins.args and isinstance(ins.args[0], int):
|
|
1017
|
+
ei = int(ins.args[0])
|
|
1018
|
+
if ei not in carried: # a carried sibling already resolves; vet the rest
|
|
1019
|
+
cand.add(ei)
|
|
1020
|
+
seq_closeable = frozenset(ei for ei in cand if _seq_helper_safe(eb, ei))
|
|
1021
|
+
|
|
1022
|
+
# 3) build a graft spec per carried object
|
|
1023
|
+
out = []
|
|
1024
|
+
for slot in order:
|
|
1025
|
+
if slot not in info:
|
|
1026
|
+
continue
|
|
1027
|
+
rd = info[slot]
|
|
1028
|
+
e = eb.entry(slot)
|
|
1029
|
+
insts = grouped[slot]
|
|
1030
|
+
# An object SELF-POSITIONS when its own Init pins its placement -- a literal MoveInstantXZY, or
|
|
1031
|
+
# local D9(0)/D9(4) sets (a fixed spot, OR a per-arg row's base that it offsets by the arg).
|
|
1032
|
+
# Otherwise it inherits the D9 snapshot in force at its InitObject (carried as needs_d9).
|
|
1033
|
+
self_positions = rd["lit"] is not None or (0 in rd["local"] and 4 in rd["local"])
|
|
1034
|
+
# #13(a): collapse DUPLICATE-arg InitObjects. InitObject(slot, arg) addresses INSTANCE `arg`, so
|
|
1035
|
+
# the same (slot, arg) emitted twice is one instance re-init'd -- the donor's beat director runs
|
|
1036
|
+
# just one site per beat, but a synth fork (no director) would emit both and STACK identical
|
|
1037
|
+
# copies (forking the Dali shop, DAF is InitObject'd twice at arg 0 -> a stacked pair). DISTINCT
|
|
1038
|
+
# args are a genuine row (field-122 BBX: args 128/129/130, one entry offset per arg) and are kept.
|
|
1039
|
+
instances, seen_args = [], set()
|
|
1040
|
+
for arg, snap in insts:
|
|
1041
|
+
if arg in seen_args:
|
|
1042
|
+
continue
|
|
1043
|
+
seen_args.add(arg)
|
|
1044
|
+
if rd["lit"] is not None:
|
|
1045
|
+
x, z = rd["lit"]
|
|
1046
|
+
elif self_positions:
|
|
1047
|
+
x, z = rd["local"][0], rd["local"][4]
|
|
1048
|
+
elif 0 in snap and 4 in snap:
|
|
1049
|
+
x, z = snap[0], snap[4]
|
|
1050
|
+
else:
|
|
1051
|
+
x, z = None, None
|
|
1052
|
+
instances.append({"arg": arg, "x": x, "z": z})
|
|
1053
|
+
needs_d9: dict = {}
|
|
1054
|
+
if not self_positions: # Main_Init-D9-positioned (the moogle class)
|
|
1055
|
+
snap0 = insts[0][1]
|
|
1056
|
+
needs_d9 = {i: snap0[i] for i in (0, 2, 4) if i in snap0}
|
|
1057
|
+
refs, player_tags = _classify_entry_refs(eb, e, pents, carried, slot)
|
|
1058
|
+
safety, carry_tags = _graft_safety(e, refs, fork_player_tags, graftable_player_tags=graftable_player,
|
|
1059
|
+
seq_closeable=seq_closeable)
|
|
1060
|
+
spec = {
|
|
1061
|
+
"donor_idx": slot,
|
|
1062
|
+
"entry_bytes": _entry_bytes(eb.data, slot), # VERBATIM (full entry, all tags)
|
|
1063
|
+
"kind": "npc" if e.func_by_tag(3) is not None else "prop",
|
|
1064
|
+
"model": MODELS.get(rd["model"], rd["model"]), "model_id": rd["model"],
|
|
1065
|
+
"animset": rd["animset"], "pose": rd["pose"], "face": rd["face"],
|
|
1066
|
+
"instances": instances, "self_positions": self_positions, "needs_d9": needs_d9,
|
|
1067
|
+
"donor_player_entry": dpe, "donor_player_entries": pents,
|
|
1068
|
+
"refs": refs, "player_tags_needed": sorted(player_tags),
|
|
1069
|
+
"graft_safety": safety, "carry_tags": carry_tags,
|
|
1070
|
+
}
|
|
1071
|
+
# P6.1 spawn-flash (docs/SAVEPOINT.md): an object whose Init height != its settled height shows a
|
|
1072
|
+
# one-shot spawn-then-move on a fork (the source field's entrance fade hides it; a fork's may not).
|
|
1073
|
+
mism = spawn_settle_mismatch(eb, slot)
|
|
1074
|
+
if mism:
|
|
1075
|
+
iy, sy, pos, sz = mism
|
|
1076
|
+
if graft_savepoint and slot in savepoint_cluster and rd["model"] == SAVE_MOOGLE_MODEL:
|
|
1077
|
+
b = bytearray(spec["entry_bytes"]) # AUTO-FIX the save Moogle: spawn at the in-barrel Y
|
|
1078
|
+
b[pos:pos + sz] = (int(sy) & ((1 << (8 * sz)) - 1)).to_bytes(sz, "little")
|
|
1079
|
+
spec["entry_bytes"] = bytes(b)
|
|
1080
|
+
else:
|
|
1081
|
+
spec["spawn_flash"] = {"init_y": iy, "settle_y": sy} # LINT signal for any other such object
|
|
1082
|
+
if seq_closeable: # the closeable helpers this object launches
|
|
1083
|
+
keep = set(carry_tags) # from a KEPT tag (a dropped tag's Seq never runs)
|
|
1084
|
+
seqs, seen = [], set()
|
|
1085
|
+
for f in e.funcs:
|
|
1086
|
+
if f.tag not in keep:
|
|
1087
|
+
continue
|
|
1088
|
+
for ins in eb.instrs(f):
|
|
1089
|
+
if ins.op == RUN_SHARED_SCRIPT and ins.args and isinstance(ins.args[0], int):
|
|
1090
|
+
ei = int(ins.args[0])
|
|
1091
|
+
if ei in seq_closeable and ei not in seen:
|
|
1092
|
+
seen.add(ei)
|
|
1093
|
+
seqs.append({"entry": ei, "bytes": _entry_bytes(eb.data, ei)})
|
|
1094
|
+
if seqs:
|
|
1095
|
+
spec["seqs"] = seqs
|
|
1096
|
+
out.append(spec)
|
|
1097
|
+
return out
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def resolve_player_entries(eb) -> list:
|
|
1101
|
+
"""Every entry index that defines a player character (``DefinePlayerCharacter`` 0x2C). A field can have
|
|
1102
|
+
MORE THAN ONE (fields 820/108/316-319/332/...); :func:`_player_entry_index` returns only the FIRST, which
|
|
1103
|
+
misses a referenced func defined on a later player entry."""
|
|
1104
|
+
return [e.index for e in eb.entries if not e.empty
|
|
1105
|
+
and any(ins.op == DEFINE_PC for f in e.funcs for ins in eb.instrs(f))]
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def _player_model(eb, player_entry_index):
|
|
1109
|
+
"""The model id the player entry's Init ``SetModel``s (the donor player rig), or None."""
|
|
1110
|
+
fi = eb.entry(player_entry_index).func_by_tag(0) if 0 <= player_entry_index < eb.entry_count else None
|
|
1111
|
+
return _read_object_init(eb, fi)["model"] if fi is not None else None
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def scan_player_arrivals(eb_bytes) -> dict:
|
|
1115
|
+
"""The player's per-ENTRANCE arrival table -- how a real field positions the player based on which door
|
|
1116
|
+
they walked in through (#9). A warp sets the entrance var ``D8:2`` then ``Field()``; the target field's
|
|
1117
|
+
player Init READS ``D8:2`` (a bare ``05 D8 02 7F`` push feeding a ``0x06`` switch) and branches to one
|
|
1118
|
+
``D9(0)/D9(4)/D9(6)`` (x/z/face) placement block per entrance.
|
|
1119
|
+
|
|
1120
|
+
Returns ``{reads_entrance, arrivals, distinct}``: ``arrivals`` = the ``(x, z, face)`` blocks in bytecode
|
|
1121
|
+
order (``face`` may be None); ``distinct`` = the count of UNIQUE ``(x, z)`` spots. A ``--verbatim`` fork
|
|
1122
|
+
ships this whole Init verbatim, so per-door arrival is FAITHFUL; a SYNTHESIZED fork re-authors the player
|
|
1123
|
+
with a single ``[player] spawn``, collapsing the table -- so ``reads_entrance and distinct > 1`` is the #9
|
|
1124
|
+
signal that a synth fork loses per-door spawn (``fork-report`` surfaces it; ``--verbatim`` preserves it).
|
|
1125
|
+
Read-only; never raises on an odd field (returns the empty table)."""
|
|
1126
|
+
try:
|
|
1127
|
+
eb = eb_bytes if isinstance(eb_bytes, EbScript) else EbScript.from_bytes(eb_bytes)
|
|
1128
|
+
pents = resolve_player_entries(eb)
|
|
1129
|
+
init = eb.entry(pents[0]).func_by_tag(0) if pents else None
|
|
1130
|
+
if init is None:
|
|
1131
|
+
return {"reads_entrance": False, "arrivals": [], "distinct": 0}
|
|
1132
|
+
reads_entrance = False
|
|
1133
|
+
blocks, cur = [], {}
|
|
1134
|
+
for ins in eb.instrs(init):
|
|
1135
|
+
if ins.op != SETVAR_EXPR_OP:
|
|
1136
|
+
continue
|
|
1137
|
+
raw = eb.data[ins.off:ins.end]
|
|
1138
|
+
# entrance READ: an expr mentioning D8:2 with NO assign token -- the bare push feeding the arrival
|
|
1139
|
+
# switch. (The entrance WRITE `... D8 02 7D <i16> 2C 7F` is a gateway exit, not the player Init.)
|
|
1140
|
+
if (len(raw) >= 4 and raw[1] == ENTRANCE_VAR_CLASS and raw[2] == ENTRANCE_VAR_IDX
|
|
1141
|
+
and ASSIGN_TOK not in raw):
|
|
1142
|
+
reads_entrance = True
|
|
1143
|
+
# an arrival block: `05 D9 idx 7D lo hi 2C 7F` const-sets; a new D9(0) opens the next block.
|
|
1144
|
+
if len(raw) >= 8 and raw[1] == POS_VAR_CLASS and raw[3] == 0x7D and raw[6] == ASSIGN_TOK:
|
|
1145
|
+
idx, val = raw[2], _s16(raw[4] | (raw[5] << 8))
|
|
1146
|
+
if idx == 0 and cur:
|
|
1147
|
+
blocks.append(cur)
|
|
1148
|
+
cur = {}
|
|
1149
|
+
cur[idx] = val
|
|
1150
|
+
if cur:
|
|
1151
|
+
blocks.append(cur)
|
|
1152
|
+
arrivals = [(b[0], b[4], b.get(6)) for b in blocks if 0 in b and 4 in b]
|
|
1153
|
+
distinct = len({(x, z) for x, z, _f in arrivals})
|
|
1154
|
+
return {"reads_entrance": reads_entrance, "arrivals": arrivals, "distinct": distinct}
|
|
1155
|
+
except (ValueError, IndexError, KeyError):
|
|
1156
|
+
return {"reads_entrance": False, "arrivals": [], "distinct": 0}
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _player_init_packs(eb, player_entries) -> list:
|
|
1160
|
+
"""The animation-pack loads (``RunModelCode``) in the player Init(s). The fork player loads only the
|
|
1161
|
+
blank-field default pack, so a grafted func that plays a clip from one of these donor packs needs the
|
|
1162
|
+
pack spliced into the fork player Init (else the clip is silently unloaded -- docs/PLAYER_GRAFT.md S4)."""
|
|
1163
|
+
packs = []
|
|
1164
|
+
for pe in player_entries:
|
|
1165
|
+
fi = eb.entry(pe).func_by_tag(0)
|
|
1166
|
+
if fi is None:
|
|
1167
|
+
continue
|
|
1168
|
+
for ins in eb.instrs(fi):
|
|
1169
|
+
if ins.op == RUN_MODEL_CODE_OP and ins.args and not any(ins.arg_is_expr):
|
|
1170
|
+
t = tuple(int(a) for a in ins.args)
|
|
1171
|
+
if t not in packs:
|
|
1172
|
+
packs.append(t)
|
|
1173
|
+
return packs
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _player_func_safety(eb, func, donor_model, donor_player_entry, carried_siblings=frozenset()):
|
|
1177
|
+
"""Classify a referenced player function for graftability (docs/PLAYER_GRAFT.md S2). Returns
|
|
1178
|
+
``(safety, runscript_tags, sibling_refs)`` -- safety in clean | text | sibling | transitive | model |
|
|
1179
|
+
exotic | missing. Only ``clean`` is v1-graftable; the rest keep the seeding object ``init_only``
|
|
1180
|
+
(lint-warned). A uid ref to a CARRIED sibling (in ``carried_siblings``) does NOT refuse -- it is recorded
|
|
1181
|
+
in ``sibling_refs`` to be remapped to the sibling's fork slot at graft (the save Moogle's player funcs
|
|
1182
|
+
13/14/15 ``TurnTowardObject`` the carried Moogle); only an UNCARRIED sibling refuses."""
|
|
1183
|
+
if func is None:
|
|
1184
|
+
return "missing", [], []
|
|
1185
|
+
ops, rs_tags, sibling_refs, uncarried = set(), [], [], False
|
|
1186
|
+
for ins in eb.instrs(func):
|
|
1187
|
+
ops.add(ins.op)
|
|
1188
|
+
spec = REF_OPS.get(ins.op)
|
|
1189
|
+
if spec:
|
|
1190
|
+
for ai in spec.get("uid", ()):
|
|
1191
|
+
if ai >= len(ins.arg_is_expr) or ins.arg_is_expr[ai]:
|
|
1192
|
+
continue
|
|
1193
|
+
v = ins.imm(ai)
|
|
1194
|
+
if v is None or (ins.op in INIT_OPS and v == 0):
|
|
1195
|
+
continue
|
|
1196
|
+
if v in (UID_PLAYER, UID_SELF) or (donor_player_entry is not None and v == donor_player_entry):
|
|
1197
|
+
if ins.op in RUNSCRIPT_OPS and ai == 1:
|
|
1198
|
+
t = ins.imm(2)
|
|
1199
|
+
if t is not None:
|
|
1200
|
+
rs_tags.append(int(t)) # a player->player call (transitive; depth-0 in practice)
|
|
1201
|
+
elif int(v) in carried_siblings:
|
|
1202
|
+
sibling_refs.append(int(v)) # a CARRIED sibling -> graftable (remap the uid at graft)
|
|
1203
|
+
else:
|
|
1204
|
+
uncarried = True # a party / uncarried-sibling uid ref -> can't resolve
|
|
1205
|
+
if ops & TEXT_OPS:
|
|
1206
|
+
return "text", rs_tags, sibling_refs # needs a .mes the fork doesn't carry -> v1.5
|
|
1207
|
+
if uncarried:
|
|
1208
|
+
return "sibling", rs_tags, sibling_refs # references an uncarried object -> can't resolve on a fork
|
|
1209
|
+
if rs_tags:
|
|
1210
|
+
return "transitive", rs_tags, sibling_refs # depth-0 census -> v1 refuses (no closure walker)
|
|
1211
|
+
if (ops & ANIM_OPS) and donor_model not in ZIDANE_MODELS:
|
|
1212
|
+
return "model", rs_tags, sibling_refs # clip ids are another character's -> wrong on Zidane
|
|
1213
|
+
if ops - SAFE_GESTURE_OPS:
|
|
1214
|
+
return "exotic", rs_tags, sibling_refs # warp / camera / scripted-walk / menu / sound / give
|
|
1215
|
+
return "clean", rs_tags, sibling_refs
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def scan_player_funcs(eb_bytes, *, graft_savepoint=False) -> list:
|
|
1219
|
+
"""The donor PLAYER functions a fork must graft so its carried objects' INTERACTIONS fire -- the tags an
|
|
1220
|
+
object's interactive func ``RunScript``s (the object scanner's ``player_tags_needed``). One spec per needed
|
|
1221
|
+
tag: ``{donor_tag, safety, body (verbatim), runscript_tags, donor_player_entry, donor_player_model,
|
|
1222
|
+
donor_init_packs}``. ``safety == "clean"`` is v1-graftable (grafted onto the fork player via
|
|
1223
|
+
``edit.add_function`` at a fresh tag); the rest keep the seeding object ``init_only``. The donor tag is
|
|
1224
|
+
later remapped to a fresh fork-player tag (docs/PLAYER_GRAFT.md)."""
|
|
1225
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
1226
|
+
specs = scan_objects_verbatim(eb_bytes, graft_savepoint=graft_savepoint)
|
|
1227
|
+
needed = sorted({t for s in specs for t in s["player_tags_needed"]})
|
|
1228
|
+
if not needed:
|
|
1229
|
+
return []
|
|
1230
|
+
carried = frozenset(s["donor_idx"] for s in specs) # a player func may TurnTowardObject a carried sibling
|
|
1231
|
+
pents = resolve_player_entries(eb)
|
|
1232
|
+
if not pents:
|
|
1233
|
+
return []
|
|
1234
|
+
model = _player_model(eb, pents[0])
|
|
1235
|
+
packs = _player_init_packs(eb, pents)
|
|
1236
|
+
out = []
|
|
1237
|
+
for tag in needed:
|
|
1238
|
+
func = pe = None
|
|
1239
|
+
for p in pents:
|
|
1240
|
+
func = eb.entry(p).func_by_tag(tag)
|
|
1241
|
+
if func is not None:
|
|
1242
|
+
pe = p
|
|
1243
|
+
break
|
|
1244
|
+
safety, rs_tags, sibling_refs = _player_func_safety(eb, func, model, pe, carried_siblings=carried)
|
|
1245
|
+
out.append({"donor_tag": tag, "safety": safety,
|
|
1246
|
+
"body": eb.data[func.abs_start:func.abs_end] if func is not None else b"",
|
|
1247
|
+
"runscript_tags": rs_tags, "sibling_refs": sibling_refs, "donor_player_entry": pe,
|
|
1248
|
+
"donor_player_model": model, "donor_init_packs": packs})
|
|
1249
|
+
return out
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def scan_content(eb_bytes) -> dict:
|
|
1253
|
+
"""All importable content from a field's ``.eb`` in one pass: ``{gateways, music, encounter,
|
|
1254
|
+
control_direction, ladders, jumps, objects, objects_verbatim}`` (inverse of the injectors)."""
|
|
1255
|
+
return {
|
|
1256
|
+
"gateways": scan_gateways(eb_bytes),
|
|
1257
|
+
"music": scan_music(eb_bytes),
|
|
1258
|
+
"encounter": scan_encounter(eb_bytes),
|
|
1259
|
+
"control_direction": scan_control_direction(eb_bytes),
|
|
1260
|
+
"ladders": scan_ladders(eb_bytes),
|
|
1261
|
+
"jumps": scan_jumps(eb_bytes),
|
|
1262
|
+
"objects": scan_objects(eb_bytes),
|
|
1263
|
+
# the STARTSEQ-helper closure (docs/OBJECT_CARRY.md S2 v1.5) is a pure fidelity win for the import
|
|
1264
|
+
# carry, so the import-content aggregator scans WITH it (the default `import` path reads this).
|
|
1265
|
+
"objects_verbatim": scan_objects_verbatim(eb_bytes, graft_seq_helpers=True),
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def _entry_has_region(eb, entry) -> bool:
|
|
1270
|
+
"""True if any function in ``entry`` contains a ``SetRegion`` (so its ``Field`` ops are walk-in)."""
|
|
1271
|
+
for f in entry.funcs:
|
|
1272
|
+
for ins in eb.instrs(f):
|
|
1273
|
+
if ins.op == SETREGION_OP:
|
|
1274
|
+
return True
|
|
1275
|
+
return False
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def _classify_trigger(entry_index: int, tag: int) -> str:
|
|
1279
|
+
"""Cheap classification of a scripted warp from its host entry/tag (grounded in the real-bytes
|
|
1280
|
+
survey): Main_Init -> auto-on-entry; tag 10 -> after-battle reinit; tag 1 -> cutscene/sequence loop."""
|
|
1281
|
+
if entry_index == 0 and tag == 0:
|
|
1282
|
+
return "auto-on-entry"
|
|
1283
|
+
if tag == 10:
|
|
1284
|
+
return "after-battle"
|
|
1285
|
+
if tag == 1:
|
|
1286
|
+
return "cutscene-loop"
|
|
1287
|
+
return "scripted"
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def scan_all_warps(eb_bytes) -> dict:
|
|
1291
|
+
"""Every field-to-field connection in the script, classified by KIND -- the import-chain taxonomy.
|
|
1292
|
+
Returns ``{walk_in, scripted, overworld_exits}``:
|
|
1293
|
+
|
|
1294
|
+
* ``walk_in`` -- a ``SetRegion``+``Field`` region exit (from :func:`scan_gateways`); the
|
|
1295
|
+
player walks into a zone. Each carries the extra ``story_conditional`` flag: True when >=2
|
|
1296
|
+
edges share a BYTE-IDENTICAL zone polygon but reach DIFFERENT destinations -- FF9's stacked /
|
|
1297
|
+
``if(flag){A}else{B}`` story-conditional door (only one active per story state; re-author with
|
|
1298
|
+
``requires_flag`` on each). ~2.9% of real region exits; the rest are plain unconditional doors.
|
|
1299
|
+
* ``scripted`` -- ``[{to, entrance, host_entry, host_tag, trigger}]`` for a bare ``Field()``
|
|
1300
|
+
whose entry has NO region (cutscene / teleport / post-battle warp). Target + entrance are
|
|
1301
|
+
literals (FF9 never computes a warp id). ~41% of real connectivity is scripted, so the strict
|
|
1302
|
+
walk-in scan alone misses a lot -- but these are predominantly one-way story transitions, so a
|
|
1303
|
+
walk should treat them as seams by default, not auto-followed.
|
|
1304
|
+
* ``overworld_exits`` -- sorted ``WorldMap`` (0xB6) operands: WORLD-MAP LOCATION ids (e.g.
|
|
1305
|
+
9000-9012), NOT field ids. A 'this screen leaves to the overworld' marker, never a graph edge.
|
|
1306
|
+
|
|
1307
|
+
Shared chocobo/mognet menu warps (2950-2955) are filtered out (they appear in nearly every field).
|
|
1308
|
+
Field ops in a region-bearing entry are attributed to ``walk_in`` (matching :func:`scan_gateways`)."""
|
|
1309
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
1310
|
+
|
|
1311
|
+
walk_in = scan_gateways(eb_bytes)
|
|
1312
|
+
by_zone: dict = {}
|
|
1313
|
+
for g in walk_in:
|
|
1314
|
+
by_zone.setdefault(tuple(map(tuple, g["zone"])), set()).add(g["to"])
|
|
1315
|
+
for g in walk_in:
|
|
1316
|
+
g["story_conditional"] = len(by_zone[tuple(map(tuple, g["zone"]))]) > 1
|
|
1317
|
+
|
|
1318
|
+
scripted, overworld = [], []
|
|
1319
|
+
for e in eb.entries:
|
|
1320
|
+
if e.empty:
|
|
1321
|
+
continue
|
|
1322
|
+
region_entry = _entry_has_region(eb, e)
|
|
1323
|
+
for f in e.funcs:
|
|
1324
|
+
entrance = 0
|
|
1325
|
+
for ins in eb.instrs(f):
|
|
1326
|
+
if ins.op == 0x05:
|
|
1327
|
+
ent = _entrance_at(eb.data, ins.off)
|
|
1328
|
+
if ent is not None:
|
|
1329
|
+
entrance = ent
|
|
1330
|
+
elif ins.op == WORLDMAP_OP:
|
|
1331
|
+
loc = ins.imm(0)
|
|
1332
|
+
if loc is not None:
|
|
1333
|
+
overworld.append(int(loc))
|
|
1334
|
+
elif ins.op == FIELD_OP and not region_entry:
|
|
1335
|
+
tgt = ins.imm(0)
|
|
1336
|
+
if tgt is None or int(tgt) in SHARED_MENU_WARPS:
|
|
1337
|
+
continue
|
|
1338
|
+
scripted.append({"to": int(tgt), "entrance": int(entrance),
|
|
1339
|
+
"host_entry": e.index, "host_tag": int(f.tag),
|
|
1340
|
+
"trigger": _classify_trigger(e.index, f.tag)})
|
|
1341
|
+
return {"walk_in": walk_in, "scripted": scripted, "overworld_exits": sorted(set(overworld))}
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
# --- GLOB story-flag scanners (cross-field flag dependencies; raw-byte, like _entrance_at) ---
|
|
1345
|
+
# Filters to GLOBAL bools (save-persistent gEventGlobal). MAP bools (0xC5/0xE5) are per-field TRANSIENT
|
|
1346
|
+
# -> never a cross-field dependency, so _glob_var_token returns None for them.
|
|
1347
|
+
GLOB_BOOL_SHORT = 0xC4 # Global+Bit, idx <= 0xFF (1-byte index)
|
|
1348
|
+
GLOB_BOOL_LONG = 0xE4 # Global+Bit, long-index form (class | 0x20) for idx > 0xFF (2-byte LE)
|
|
1349
|
+
_PUSH_CONST16 = 0x7D
|
|
1350
|
+
_T_ASSIGN = 0x2C
|
|
1351
|
+
_T_OR_ASSIGN = 0x3F
|
|
1352
|
+
_T_NOT = 0x0E
|
|
1353
|
+
_T_END = 0x7F
|
|
1354
|
+
_JMP_FALSE = 0x02
|
|
1355
|
+
_JMP_TRUE = 0x03
|
|
1356
|
+
_RETURN = 0x04 # eb/opcodes.RETURN == bytes([0x04])
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def _glob_var_token(data: bytes, off: int):
|
|
1360
|
+
"""If ``data[off]`` is a GLOBAL bool var token, return ``(glob_idx, token_len)``; else None.
|
|
1361
|
+
0xC4 -> (data[off+1], 2); 0xE4 -> (u16le, 3). MAP bools (0xC5/0xE5) and everything else -> None."""
|
|
1362
|
+
if off >= len(data):
|
|
1363
|
+
return None
|
|
1364
|
+
b = data[off]
|
|
1365
|
+
if b == GLOB_BOOL_SHORT and off + 1 < len(data):
|
|
1366
|
+
return (data[off + 1], 2)
|
|
1367
|
+
if b == GLOB_BOOL_LONG and off + 2 < len(data):
|
|
1368
|
+
return (data[off + 1] | (data[off + 2] << 8), 3)
|
|
1369
|
+
return None
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
def _expr_offsets(eb):
|
|
1373
|
+
for e in eb.entries:
|
|
1374
|
+
if e.empty:
|
|
1375
|
+
continue
|
|
1376
|
+
for f in e.funcs:
|
|
1377
|
+
for ins in eb.instrs(f):
|
|
1378
|
+
if ins.op == 0x05: # EXPR statement -> the byte after is the var token
|
|
1379
|
+
yield ins.off
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def scan_flags_set(eb_bytes) -> list:
|
|
1383
|
+
"""GLOB flag WRITES. Pattern ``05 <glob-var> 7D <i16> <2C|3F> 7F`` (set / or-assign). Returns
|
|
1384
|
+
sorted-unique ``[(glob_idx, op)]`` with op in {'set', 'or'}. Round-trips region.set_var/or_var."""
|
|
1385
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
1386
|
+
d = eb.data
|
|
1387
|
+
out = set()
|
|
1388
|
+
for off in _expr_offsets(eb):
|
|
1389
|
+
tok = _glob_var_token(d, off + 1)
|
|
1390
|
+
if tok is None:
|
|
1391
|
+
continue
|
|
1392
|
+
idx, vlen = tok
|
|
1393
|
+
p = off + 1 + vlen
|
|
1394
|
+
if p + 4 < len(d) and d[p] == _PUSH_CONST16 and d[p + 3] in (_T_ASSIGN, _T_OR_ASSIGN) and d[p + 4] == _T_END:
|
|
1395
|
+
out.add((idx, "set" if d[p + 3] == _T_ASSIGN else "or"))
|
|
1396
|
+
return sorted(out)
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
def scan_required_flags(eb_bytes) -> list:
|
|
1400
|
+
"""GLOB flag READS that drive a conditional jump (general if-block, ANY body length). Pattern
|
|
1401
|
+
``05 <glob-var> [0E] 7F <02|03> <skip:i16> ...``. Returns sorted-unique ``[(glob_idx, require_set)]``
|
|
1402
|
+
(require_set = the flag state that lets the guarded block run). Catches region.flag_gate as a special case."""
|
|
1403
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
1404
|
+
d = eb.data
|
|
1405
|
+
out = set()
|
|
1406
|
+
for off in _expr_offsets(eb):
|
|
1407
|
+
tok = _glob_var_token(d, off + 1)
|
|
1408
|
+
if tok is None:
|
|
1409
|
+
continue
|
|
1410
|
+
idx, vlen = tok
|
|
1411
|
+
p = off + 1 + vlen
|
|
1412
|
+
negated = p < len(d) and d[p] == _T_NOT
|
|
1413
|
+
if negated:
|
|
1414
|
+
p += 1
|
|
1415
|
+
if p + 1 >= len(d) or d[p] != _T_END:
|
|
1416
|
+
continue
|
|
1417
|
+
jmp = d[p + 1]
|
|
1418
|
+
if jmp not in (_JMP_FALSE, _JMP_TRUE):
|
|
1419
|
+
continue
|
|
1420
|
+
require_set = (jmp == _JMP_TRUE and not negated) or (jmp == _JMP_FALSE and negated)
|
|
1421
|
+
out.add((idx, require_set))
|
|
1422
|
+
return sorted(out)
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
def scan_edge_flag_gates(eb_bytes) -> list:
|
|
1426
|
+
"""STRICT kit-prologue gate ``05 <glob-var> 7F <02|03> 01 00 04`` (skip=1 + RETURN=0x04) -- the exact
|
|
1427
|
+
shape region.flag_gate emits. Returns ``[(glob_idx, require_set)]``. (Use scan_required_flags for the
|
|
1428
|
+
general real-field form; this is the round-trip self-test target.)"""
|
|
1429
|
+
eb = EbScript.from_bytes(eb_bytes)
|
|
1430
|
+
d = eb.data
|
|
1431
|
+
out = set()
|
|
1432
|
+
for off in _expr_offsets(eb):
|
|
1433
|
+
tok = _glob_var_token(d, off + 1)
|
|
1434
|
+
if tok is None:
|
|
1435
|
+
continue
|
|
1436
|
+
idx, vlen = tok
|
|
1437
|
+
p = off + 1 + vlen
|
|
1438
|
+
if (p + 4 < len(d) and d[p] == _T_END and d[p + 1] in (_JMP_FALSE, _JMP_TRUE)
|
|
1439
|
+
and d[p + 2] == 0x01 and d[p + 3] == 0x00 and d[p + 4] == _RETURN):
|
|
1440
|
+
out.add((idx, d[p + 1] == _JMP_TRUE))
|
|
1441
|
+
return sorted(out)
|