ff9mapkit 1.0.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. ff9mapkit/__init__.py +18 -0
  2. ff9mapkit/__main__.py +36 -0
  3. ff9mapkit/_animdb.py +2994 -0
  4. ff9mapkit/_animdb_all.py +14125 -0
  5. ff9mapkit/_fieldtable.py +1516 -0
  6. ff9mapkit/_fieldtext.py +845 -0
  7. ff9mapkit/_held_poses.py +44 -0
  8. ff9mapkit/_itemdb.py +65 -0
  9. ff9mapkit/_modeldb.py +725 -0
  10. ff9mapkit/_narrowmap_data.py +10 -0
  11. ff9mapkit/_npcparams.py +634 -0
  12. ff9mapkit/_regen_animdb.py +72 -0
  13. ff9mapkit/_regen_animdb_all.py +66 -0
  14. ff9mapkit/_regen_fieldtable.py +95 -0
  15. ff9mapkit/_regen_fieldtext.py +66 -0
  16. ff9mapkit/_regen_modeldb.py +67 -0
  17. ff9mapkit/_regen_npcparams.py +123 -0
  18. ff9mapkit/_regen_scenedb.py +57 -0
  19. ff9mapkit/_scenedb.py +869 -0
  20. ff9mapkit/abilities.py +225 -0
  21. ff9mapkit/animations.py +120 -0
  22. ff9mapkit/archetypes.py +218 -0
  23. ff9mapkit/areatitle.py +76 -0
  24. ff9mapkit/battle/__init__.py +21 -0
  25. ff9mapkit/battle/abilityfeatures.py +294 -0
  26. ff9mapkit/battle/actiondelta.py +441 -0
  27. ff9mapkit/battle/aiauthor.py +305 -0
  28. ff9mapkit/battle/ailint.py +140 -0
  29. ff9mapkit/battle/aipatch.py +175 -0
  30. ff9mapkit/battle/battleai.py +148 -0
  31. ff9mapkit/battle/battlecsv.py +390 -0
  32. ff9mapkit/battle/battlepatch.py +395 -0
  33. ff9mapkit/battle/build.py +558 -0
  34. ff9mapkit/battle/camera_codec.py +332 -0
  35. ff9mapkit/battle/camera_data.py +128 -0
  36. ff9mapkit/battle/characterdelta.py +789 -0
  37. ff9mapkit/battle/event_data.py +72 -0
  38. ff9mapkit/battle/extract.py +540 -0
  39. ff9mapkit/battle/fbx.py +223 -0
  40. ff9mapkit/battle/reskin.py +149 -0
  41. ff9mapkit/battle/scene_codec.py +314 -0
  42. ff9mapkit/battle/scene_data.py +369 -0
  43. ff9mapkit/battle/scenelint.py +125 -0
  44. ff9mapkit/battle/seqasm.py +131 -0
  45. ff9mapkit/battle/seqauthor.py +220 -0
  46. ff9mapkit/battle/seqcodec.py +300 -0
  47. ff9mapkit/battle/seqdis.py +106 -0
  48. ff9mapkit/battle/seqpatch.py +137 -0
  49. ff9mapkit/battle_bgm.py +133 -0
  50. ff9mapkit/binutils.py +60 -0
  51. ff9mapkit/build.py +5445 -0
  52. ff9mapkit/campaign.py +1276 -0
  53. ff9mapkit/catalog.py +316 -0
  54. ff9mapkit/chain.py +358 -0
  55. ff9mapkit/cli.py +3114 -0
  56. ff9mapkit/config.py +360 -0
  57. ff9mapkit/content/__init__.py +13 -0
  58. ff9mapkit/content/areatitle.py +36 -0
  59. ff9mapkit/content/ate.py +118 -0
  60. ff9mapkit/content/camera.py +123 -0
  61. ff9mapkit/content/chest.py +186 -0
  62. ff9mapkit/content/choice.py +163 -0
  63. ff9mapkit/content/conductor.py +217 -0
  64. ff9mapkit/content/cutscene.py +290 -0
  65. ff9mapkit/content/encounter.py +41 -0
  66. ff9mapkit/content/entry_settle.py +50 -0
  67. ff9mapkit/content/equipment.py +93 -0
  68. ff9mapkit/content/event.py +191 -0
  69. ff9mapkit/content/gateway.py +101 -0
  70. ff9mapkit/content/inventory.py +59 -0
  71. ff9mapkit/content/itemdata.py +644 -0
  72. ff9mapkit/content/itemtext.py +168 -0
  73. ff9mapkit/content/jump.py +114 -0
  74. ff9mapkit/content/ladder.py +633 -0
  75. ff9mapkit/content/movement.py +53 -0
  76. ff9mapkit/content/music.py +97 -0
  77. ff9mapkit/content/npc.py +348 -0
  78. ff9mapkit/content/object.py +340 -0
  79. ff9mapkit/content/onentry.py +135 -0
  80. ff9mapkit/content/party.py +111 -0
  81. ff9mapkit/content/pathfind.py +138 -0
  82. ff9mapkit/content/platform.py +314 -0
  83. ff9mapkit/content/player.py +168 -0
  84. ff9mapkit/content/prop.py +75 -0
  85. ff9mapkit/content/region.py +340 -0
  86. ff9mapkit/content/reinit.py +59 -0
  87. ff9mapkit/content/savepoint.py +90 -0
  88. ff9mapkit/content/shop.py +178 -0
  89. ff9mapkit/content/sps_trigger.py +66 -0
  90. ff9mapkit/content/startup.py +71 -0
  91. ff9mapkit/content/synthesis.py +106 -0
  92. ff9mapkit/content/text.py +183 -0
  93. ff9mapkit/content/textcarry.py +290 -0
  94. ff9mapkit/content/verbatim.py +86 -0
  95. ff9mapkit/content/walkmesh_hotfix.py +38 -0
  96. ff9mapkit/data/__init__.py +48 -0
  97. ff9mapkit/data/_regen_provenance.py +142 -0
  98. ff9mapkit/data/provenance/blank.es.patch +1 -0
  99. ff9mapkit/data/provenance/blank.fr.patch +1 -0
  100. ff9mapkit/data/provenance/blank.gr.patch +1 -0
  101. ff9mapkit/data/provenance/blank.it.patch +1 -0
  102. ff9mapkit/data/provenance/blank.jp.patch +1 -0
  103. ff9mapkit/data/provenance/blank.uk.patch +1 -0
  104. ff9mapkit/data/provenance/blank.us.patch +1 -0
  105. ff9mapkit/data/provenance/manifest.json +65 -0
  106. ff9mapkit/data/provenance/region_template.patch +1 -0
  107. ff9mapkit/data/reference_arcs.toml +89 -0
  108. ff9mapkit/data/region_catalog.toml +593 -0
  109. ff9mapkit/deploystack.py +358 -0
  110. ff9mapkit/dialogue.py +803 -0
  111. ff9mapkit/eb/__init__.py +12 -0
  112. ff9mapkit/eb/_exprtable.py +59 -0
  113. ff9mapkit/eb/_membertable.py +38 -0
  114. ff9mapkit/eb/_optables.py +537 -0
  115. ff9mapkit/eb/_regen_optables.py +76 -0
  116. ff9mapkit/eb/cmdasm.py +323 -0
  117. ff9mapkit/eb/disasm.py +332 -0
  118. ff9mapkit/eb/edit.py +439 -0
  119. ff9mapkit/eb/exprasm.py +158 -0
  120. ff9mapkit/eb/model.py +178 -0
  121. ff9mapkit/eb/opcodes.py +463 -0
  122. ff9mapkit/eblint.py +177 -0
  123. ff9mapkit/editor/__init__.py +20 -0
  124. ff9mapkit/editor/app.py +950 -0
  125. ff9mapkit/editor/battle_forms.py +240 -0
  126. ff9mapkit/editor/breadcrumb.py +89 -0
  127. ff9mapkit/editor/dialogs.py +116 -0
  128. ff9mapkit/editor/feedback.py +208 -0
  129. ff9mapkit/editor/forms.py +632 -0
  130. ff9mapkit/editor/graphview.py +350 -0
  131. ff9mapkit/editor/jobs.py +342 -0
  132. ff9mapkit/editor/model.py +243 -0
  133. ff9mapkit/editor/picker.py +120 -0
  134. ff9mapkit/editor/theme.py +212 -0
  135. ff9mapkit/eventscan.py +1441 -0
  136. ff9mapkit/extract.py +2279 -0
  137. ff9mapkit/flags.py +693 -0
  138. ff9mapkit/forkreport.py +1383 -0
  139. ff9mapkit/hub.py +477 -0
  140. ff9mapkit/idgated.py +101 -0
  141. ff9mapkit/infohub.py +580 -0
  142. ff9mapkit/items.py +63 -0
  143. ff9mapkit/itemstats.py +346 -0
  144. ff9mapkit/journey.py +1902 -0
  145. ff9mapkit/keyitems.py +93 -0
  146. ff9mapkit/logic_add.py +632 -0
  147. ff9mapkit/logic_edit.py +728 -0
  148. ff9mapkit/logic_map.py +526 -0
  149. ff9mapkit/pack.py +175 -0
  150. ff9mapkit/playerswap.py +231 -0
  151. ff9mapkit/prop_archetypes.py +228 -0
  152. ff9mapkit/provision.py +282 -0
  153. ff9mapkit/refarc.py +825 -0
  154. ff9mapkit/save.py +337 -0
  155. ff9mapkit/save_items.py +1673 -0
  156. ff9mapkit/scene/__init__.py +11 -0
  157. ff9mapkit/scene/arena.py +63 -0
  158. ff9mapkit/scene/bgart.py +140 -0
  159. ff9mapkit/scene/bgi.py +732 -0
  160. ff9mapkit/scene/bgs.py +174 -0
  161. ff9mapkit/scene/bgx.py +185 -0
  162. ff9mapkit/scene/cam.py +345 -0
  163. ff9mapkit/scene/guide.py +311 -0
  164. ff9mapkit/scene/paint.py +506 -0
  165. ff9mapkit/scene/placeholder.py +107 -0
  166. ff9mapkit/sjbinary.py +285 -0
  167. ff9mapkit/sps/__init__.py +17 -0
  168. ff9mapkit/sps/author.py +294 -0
  169. ff9mapkit/sps/catalog.py +88 -0
  170. ff9mapkit/sps/codec.py +264 -0
  171. ff9mapkit/sps/edit.py +184 -0
  172. ff9mapkit/sps/lint.py +58 -0
  173. ff9mapkit/sps/render.py +116 -0
  174. ff9mapkit/sps/templates.py +47 -0
  175. ff9mapkit/sps/texture.py +131 -0
  176. ff9mapkit/walkmesh_hotfixes.py +163 -0
  177. ff9mapkit/workspace/__init__.py +18 -0
  178. ff9mapkit/workspace/battledoc.py +985 -0
  179. ff9mapkit/workspace/builddoc.py +607 -0
  180. ff9mapkit/workspace/forms_qt.py +586 -0
  181. ff9mapkit/workspace/importdoc.py +665 -0
  182. ff9mapkit/workspace/mapview.py +131 -0
  183. ff9mapkit/workspace/palette.py +85 -0
  184. ff9mapkit/workspace/savedoc.py +664 -0
  185. ff9mapkit/workspace/shell.py +6907 -0
  186. ff9mapkit/workspace/style.py +105 -0
  187. ff9mapkit/workspace/tuningdialog.py +223 -0
  188. ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
  189. ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
  190. ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
  191. ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
  192. ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
  193. ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
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)