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
@@ -0,0 +1,1383 @@
1
+ """``fork-report`` -- preview, OFFLINE, what a fork of a real FF9 field will and won't reproduce.
2
+
3
+ The north star is fork FIDELITY (``docs/FORK_FIDELITY.md``): "fork a real field -> does it play identically?"
4
+ Before you fork, this answers it. For any real field it reads the compiled ``.eb`` (no game running) and reports:
5
+
6
+ * **Roster fidelity** -- how many persistent objects a fork carries, how many are ``Field()``-warp **directors**
7
+ (cutscene actors carried as NPCs -> the rotating-cast mess), and whether content rotates by story beat.
8
+ * **Interaction fidelity** -- per carried NPC, whether its talk handler PORTS (`graft_safety`): ``clean`` = fully
9
+ interactive on the fork, ``init_only`` = renders but its talk is dropped (re-author it), ``refuse`` = a stub.
10
+ * **Story gating** -- story-gated doors + the ScenarioCounter beats the field gates content on.
11
+ * **Items / treasure** -- the item/gil grants + shops the field's ``.eb`` performs (``AddItem`` / ``AddGil``
12
+ / ``Menu(2, id)``). A ``--verbatim`` fork RUNS these (carries them byte-identically); a plain/synthesize
13
+ fork has no item scanner, so it DROPS every treasure + shop. (memory ``project-ff9-items-equipment``.)
14
+ * **Home beat** -- a suggested ``[startup] scenario`` (the author picks the beat -- they have the game knowledge).
15
+
16
+ It is **read-only** and reuses the existing scanners (``eventscan.scan_objects_verbatim`` for the carry
17
+ classification, ``eventscan.scan_gateway_entries`` for gated doors, ``flags`` for the beat table) -- it adds
18
+ no carry/scanner logic of its own. Two axes are reported SEPARATELY because they are independent: Daguerreo
19
+ is a clean *roster* (0 directors, renders faithfully) yet degrades *interactions* (half its NPCs go render-only).
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import bisect as _bisect
24
+ import re
25
+ import struct
26
+ from dataclasses import dataclass, field as _dc_field
27
+
28
+ from . import flags as _flags
29
+ from .eb.model import EbScript
30
+
31
+ # --- bytecode signals -------------------------------------------------------------------------------
32
+ FIELD_OP = 0x2B # Field(target) -- a warp; in an object's tag-1 LOOP => a cutscene director/actor
33
+ PHASE_SWITCH_OP = 0x06 # op_06 -- a phase/state jump-table (the other director tell)
34
+ LOOP_TAG = 1 # object LOOP function (where cutscene warps live)
35
+ TALK_TAG = 3 # press-action talk handler
36
+
37
+ # A ScenarioCounter gate in an expression: push GLOB_UINT16[0] (DC 00), a constant (7D lo hi), then a
38
+ # COMPARISON op (a write would use 2C/3F instead, so comparisons alone are the field's story gates).
39
+ _SC_GATE = re.compile(rb"\xDC\x00\x7D(..)(.)", re.DOTALL)
40
+ _CMP_OPS = frozenset({0x18, 0x19, 0x1A, 0x1B, 0x20}) # < > <= >= ==
41
+ # Many distinct gate values => the field rotates its content/cast by story progress (the Dali shop gates
42
+ # at 11 values, Dali through Pandemonium; a static room gates at <=1).
43
+ _ROTATING_GATE_COUNT = 3
44
+
45
+ # The controlled PLAYER character (DefinePlayerCharacter's SetModel id). Most fields are Zidane; a
46
+ # non-Zidane primary means "you play as someone else" -- which forks faithfully ONLY via --verbatim (it
47
+ # ships the donor player rig + anim packs + the field's own party/cutscene setup whole). The graft path
48
+ # refuses non-Zidane player funcs ("model" graft-safety -- another rig's clip ids). Proven on Vivi/field 100.
49
+ # (memory project-ff9-non-zidane-donors). Names for the playable cast; others fall back to the GEO model name.
50
+ PLAYABLE_NAMES = {98: "Zidane", 532: "Zidane(ZDD)", 8: "Vivi", 5489: "Steiner", 526: "Steiner(STD)",
51
+ 192: "Freya", 443: "Eiko", 185: "Garnet", 509: "Amarant", 273: "Kuja"}
52
+
53
+
54
+ def player_name(model_id) -> str:
55
+ """A friendly name for a player model id (the playable cast), else its GEO model name, else 'none'."""
56
+ if model_id is None:
57
+ return "none"
58
+ if model_id in PLAYABLE_NAMES:
59
+ return PLAYABLE_NAMES[model_id]
60
+ from ._modeldb import MODELS
61
+ return MODELS.get(model_id, f"model {model_id}")
62
+
63
+
64
+ # Which entry the engine BINDS CONTROL to when a field defines >1 DefinePlayerCharacter (0x2C). The engine
65
+ # sets controlUID = the uid of each 0x2C as it EXECUTES (last-write-wins; Memoria EventEngine.DoEventCode.cs),
66
+ # and entries run their Init in InitObject (0x09 in Main_Init) order -- so control binds to the entry whose
67
+ # 0x2C runs LAST: among entries whose tag-0 Init runs a 0x2C UNCONDITIONALLY, the one InitObject'd latest.
68
+ # In-game PROVEN on the Treno Dagger+Steiner room (-> Garnet, the last-executed 0x2C, NOT the first-spawned
69
+ # Steiner nor the warp-in Zidane). memory project-ff9-non-zidane-donors. Reliable for FIXED-SID character
70
+ # fields (the non-Zidane lane); a normal party field can route control through a party slot to the LIVE
71
+ # leader, which this doesn't model -- so trust it only when no Zidane is among the PCs (the lane).
72
+ _BRANCH_OPS = frozenset({0x02, 0x03, 0x04}) # conditional-branch family (empirically gates a following 0x2C)
73
+ INITOBJ_OP = 0x09
74
+ DEFINE_PC_OP = 0x2C
75
+ # control-flow opcodes for the beat-roster walk: an EXPR (0x05) ScenarioCounter comparison drives a
76
+ # conditional jump -- 0x02 skips its body when the condition is FALSE, 0x03 skips when TRUE. 0x01 is the
77
+ # undocumented UNCONDITIONAL jump (CLAUDE.md §7); it does NOT gate its body, so the walk FOLLOWS it (which
78
+ # is what correctly steps over an if/else's else-branch) rather than treating it as a guard.
79
+ JMP_UNCOND = 0x01
80
+ JMP_FALSE = 0x02
81
+ JMP_TRUE = 0x03
82
+
83
+
84
+ def _init_0x2c_status(eb, entry_index) -> str:
85
+ """A player entry's load-time Init (tag 0) DefinePlayerCharacter: 'uncond' (binds at spawn), 'cond'
86
+ (behind a conditional branch -> story-dependent), or 'absent' (its 0x2C is in a cutscene func, not Init)."""
87
+ try:
88
+ f = eb.entry(entry_index).func_by_tag(0)
89
+ except (IndexError, AttributeError):
90
+ return "absent"
91
+ if f is None:
92
+ return "absent"
93
+ ins = list(eb.instrs(f))
94
+ idx = next((k for k, i in enumerate(ins) if i.op == DEFINE_PC_OP), None)
95
+ if idx is None:
96
+ return "absent"
97
+ return "cond" if any(i.op in _BRANCH_OPS for i in ins[:idx]) else "uncond"
98
+
99
+
100
+ def controlled_player(eb):
101
+ """Best-effort (entry_index | None, confidence in {'high','low','none'}) for the player entry the engine
102
+ binds control to at field load (see the module note above). Single-PC -> that entry. Multi-PC -> among
103
+ the entries whose Init runs a 0x2C unconditionally (else any 0x2C-in-Init), the one InitObject'd latest in
104
+ Main_Init; 'low' confidence when that entry is multi-spawned or only gated (the binder is then ambiguous)."""
105
+ from . import eventscan as _es # lazy (extraction-free, but keeps import cost off the core path)
106
+ pents = _es.resolve_player_entries(eb)
107
+ if not pents:
108
+ return (None, "none")
109
+ if len(pents) == 1:
110
+ return (pents[0], "high")
111
+ mi = eb.entry(0).func_by_tag(0) if eb.entry_count > 0 else None
112
+ order = [i.imm(0) for i in eb.instrs(mi) if i.op == INITOBJ_OP] if mi is not None else []
113
+
114
+ def last_pos(p):
115
+ occ = [k for k, v in enumerate(order) if v == p]
116
+ return max(occ) if occ else -1
117
+
118
+ status = {p: _init_0x2c_status(eb, p) for p in pents}
119
+ pool = ([p for p in pents if status[p] == "uncond"]
120
+ or [p for p in pents if status[p] == "cond"] or list(pents))
121
+ binder = max(pool, key=last_pos)
122
+ multi_spawn = sum(1 for v in order if v == binder) > 1
123
+ conf = "high" if (status[binder] == "uncond" and not multi_spawn) else "low"
124
+ # Zidane-present hedge: if a Zidane model is defined among the PCs but the crowned binder is NOT Zidane,
125
+ # control likely routes through the party slot to the Zidane leader (the last-0x2C binder is unreliable
126
+ # here -- the Cargo Ship mispredicts) -> downgrade so no caller treats the pick as certain.
127
+ if conf == "high" and _es._player_model(eb, binder) not in _es.ZIDANE_MODELS \
128
+ and any(_es._player_model(eb, p) in _es.ZIDANE_MODELS for p in pents):
129
+ conf = "low"
130
+ return (binder, conf)
131
+
132
+
133
+ # --- party-membership ops (a verbatim fork RUNS these -> the fork can change your party) ----------------
134
+ # CharacterOldIndex (the .eb id space the party ops take; project-ff9-pc-party-system). NOT the GEO model id.
135
+ CHAR_OLD_INDEX = {0: "Zidane", 1: "Vivi", 2: "Garnet", 3: "Steiner", 4: "Freya", 5: "Quina", 6: "Eiko",
136
+ 7: "Amarant", 8: "Beatrix", 9: "Cinna", 10: "Marcus", 11: "Blank"}
137
+ PARTY_NONE = 0xFFFF # the NONE sentinel (slot-clear / add terminator) -- not a real member
138
+ REMOVE_PARTY_OP = 0xDD # RemoveParty(charIndex)
139
+ SET_PARTY_RESERVE_OP = 0xB4 # SetPartyReserve(mask) -- rebuilds the recruitable roster
140
+ JOIN_OP = 0xFE # SetCharacterData / JOIN -- a formal recruit (battle+menu init)
141
+ PARTY_MENU_OP = 0xB2 # Party() -- the change-members menu UI
142
+ EXPR_STMT_OP = 0x05 # an expression statement (holds the B_PARTYADD call)
143
+ # literal single-char ADD inside an expression: B_CONST(0x7D) <2-byte CharacterOldIndex> B_PARTYADD(0x6D)
144
+ _PARTYADD_RE = re.compile(rb"\x7d(..)\x6d", re.DOTALL)
145
+
146
+
147
+ def party_char_name(idx) -> str:
148
+ return CHAR_OLD_INDEX.get(int(idx), "#%d" % int(idx))
149
+
150
+
151
+ def scan_party_ops(eb_bytes) -> dict:
152
+ """The party-membership operations a field performs -- a ``--verbatim`` fork RUNS these, so they preview
153
+ how a fork will change your party. Returns ``{adds, removes}`` (sorted distinct CharacterOldIndex, NONE
154
+ filtered) + the flags ``reset`` (``SetPartyReserve`` -> rebuilds the recruitable roster), ``recruit``
155
+ (``SetCharacterData``/JOIN), ``menu`` (the change-members UI). Heuristic: the literal single-char ADD
156
+ (``B_CONST <id> B_PARTYADD``) is decoded inside expression statements; the statement ops by their arg.
157
+ A field that drives membership from a variable (the common reserve-mask form) is captured by ``reset``."""
158
+ data = bytes(eb_bytes)
159
+ eb = EbScript.from_bytes(data)
160
+ adds, removes = set(), set()
161
+ reset = recruit = menu = False
162
+ for e in eb.entries:
163
+ if e.empty:
164
+ continue
165
+ for f in e.funcs:
166
+ for ins in eb.instrs(f):
167
+ if ins.op == REMOVE_PARTY_OP:
168
+ if ins.args and not any(ins.arg_is_expr):
169
+ removes.add(int(ins.args[0]))
170
+ elif ins.op == SET_PARTY_RESERVE_OP:
171
+ reset = True
172
+ elif ins.op == JOIN_OP:
173
+ recruit = True
174
+ elif ins.op == PARTY_MENU_OP:
175
+ menu = True
176
+ elif ins.op == EXPR_STMT_OP:
177
+ for h in _PARTYADD_RE.findall(data[ins.off:ins.end]):
178
+ adds.add(struct.unpack("<H", h)[0])
179
+ adds.discard(PARTY_NONE)
180
+ removes.discard(PARTY_NONE)
181
+ return {"adds": sorted(adds), "removes": sorted(removes), "reset": reset, "recruit": recruit, "menu": menu}
182
+
183
+
184
+ # --- item / treasure / shop ops -----------------------------------------------------------------------
185
+ # These live WHOLLY in the field `.eb`, so a `--verbatim` fork RUNS them (carries them byte-identically) but
186
+ # a plain/synthesize fork DROPS them -- eventscan has no AddItem scanner, and there is no shop authoring. A
187
+ # shop's `Menu(2, id)` carries too, but its STOCK comes from the base `ShopItems.csv` (a fork is parasitic on
188
+ # it -- it can't change the inventory). The kit catalogs only the regular 0-255 item space, so a key/card id
189
+ # gets a generic label. (memory project-ff9-items-equipment; opcodes AddItem/AddGil/Menu in eb/opcodes.py.)
190
+ ADD_ITEM_OP = 0x48 # AddItem(item_id, count) -- the real-chest / reward opcode (item_id 2B, count 1B)
191
+ REMOVE_ITEM_OP = 0x49 # RemoveItem(item_id, count)
192
+ ADD_GIL_OP = 0xCE # AddGil(amount) -- treasure gil (amount 3B, unsigned)
193
+ REMOVE_GIL_OP = 0xCF # RemoveGil(amount)
194
+ MENU_OP = 0x75 # Menu(menu_id, sub_id); menu_id 2 = SHOP (sub_id = shop id). 1=name 4=save 5=chocograph
195
+ SHOP_MENU_ID = 2
196
+ NO_ITEM = 255 # the RegularItem empty sentinel -- not a real grant (filtered, like PARTY_NONE)
197
+ GIL_CAP = 9_999_999 # the FF9 party-gil ceiling; a larger literal AddGil is a scripted sentinel, not treasure
198
+ # The event `AddItem` operand is a POOL-ENCODED item id, classified by `id % 1000` (ff9item.FF9Item_Add_Generic):
199
+ # 0-255 = regular item, 256-511 = important/key item, 512-611 = Tetra Master card, >= 612 = engine NO-OP (inert).
200
+ # A plain regular item (the normal chest/reward) has raw id 0-255 (pool 0), so `items.name_of` names it directly;
201
+ # higher pools (e.g. 31000, %1000=0) reference extended/modded regular ids the kit doesn't name. (project-ff9-items-equipment.)
202
+ POOL = 1000
203
+ REGULAR_MAX = 256 # id % 1000 < 256 -> regular item
204
+ IMPORTANT_MAX = 512 # 256 <= id % 1000 < 512 -> important/key item
205
+ CARD_MAX = 612 # 512 <= id % 1000 < 612 -> card; id % 1000 >= 612 -> inert (no grant)
206
+
207
+
208
+ def item_inert(item_id) -> bool:
209
+ """True if the engine treats this ``AddItem`` id as a NO-OP (``id % 1000 >= 612`` falls in no item pool, so
210
+ ``FF9Item_Add_Generic`` returns 0). Such ids grant nothing -> excluded from the preview's give list."""
211
+ return int(item_id) % POOL >= CARD_MAX
212
+
213
+
214
+ def item_label(item_id) -> str:
215
+ """A friendly label for an ``AddItem`` id, faithful to the engine's ``id % 1000`` pool decode: the
216
+ ``RegularItem`` name for a plain 0-255 id (the normal treasure case), else a classified-but-unnamed
217
+ ``item #N`` (extended/modded regular) / ``key item #N`` / ``card #N`` (the kit catalogs only the regular
218
+ 0-255 space -- project-ff9-items-equipment)."""
219
+ iid = int(item_id)
220
+ if 0 <= iid < REGULAR_MAX: # pool 0: the raw id IS the RegularItem id (names directly)
221
+ from . import items as _items
222
+ nm = _items.name_of(iid)
223
+ if nm is not None:
224
+ return nm
225
+ m = iid % POOL
226
+ if m < REGULAR_MAX:
227
+ return "item #%d" % iid # a regular item in a higher pool (extended / modded id space)
228
+ if m < IMPORTANT_MAX:
229
+ return "key item #%d" % (m - REGULAR_MAX) # important/key item (a separate space the kit doesn't name)
230
+ if m < CARD_MAX:
231
+ return "card #%d" % (m - IMPORTANT_MAX) # Tetra Master card
232
+ return "item #%d (inert)" % iid # id % 1000 >= 612 -> engine no-op (shouldn't reach gives)
233
+
234
+
235
+ def scan_item_ops(eb_bytes) -> dict:
236
+ """The item / gil / shop operations a field performs -- a ``--verbatim`` fork RUNS these, so they preview
237
+ the treasure + shops a fork reproduces (a plain/synthesize fork has NO item scanner, so it DROPS them all).
238
+ Returns ``{gives, gil_max, gil_any, shops, removes, var_give}``: ``gives`` = sorted distinct
239
+ ``(item_id, count)`` literal ``AddItem`` grants (NoItem filtered; ``count`` = the MAX single-grant amount,
240
+ ``None`` when computed); ``gil_max`` = the largest single PLAUSIBLE literal ``AddGil`` (<= ``GIL_CAP``);
241
+ ``gil_any`` = any ``AddGil`` at all (literal or computed); ``shops`` = sorted distinct ``Menu(2, id)`` shop
242
+ ids; ``removes`` = ``RemoveItem`` op count; ``var_give`` = any ``AddItem`` with a COMPUTED id; ``var_shop`` =
243
+ any ``Menu(2, <computed>)`` (a story-gated shop whose id is picked at runtime) -- both un-previewable.
244
+
245
+ IMPORTANT -- DON'T SUM across paths: a field's ``.eb`` runs many MUTUALLY-EXCLUSIVE story-gated branches,
246
+ so the same chest's ``AddItem``/``AddGil`` recurs across them. We report DISTINCT items (max single-grant
247
+ count, not a sum) and gil as a per-grant max -- summing wildly overcounts (field 854 grants Ether x1 on two
248
+ parallel paths, not x2; its ~16.7M-gil literal is a scripted sentinel above the 9,999,999 cap, so it is
249
+ suppressed from ``gil_max`` but still flips ``gil_any``)."""
250
+ data = bytes(eb_bytes)
251
+ eb = EbScript.from_bytes(data)
252
+ gives: dict = {} # item_id -> max single-AddItem count (None once any occurrence has a computed count)
253
+ gil_max = 0 # largest single PLAUSIBLE literal AddGil (<= GIL_CAP)
254
+ gil_any = False # any AddGil at all (literal or computed)
255
+ shops: set = set()
256
+ removes = 0
257
+ var_give = False
258
+ var_shop = False
259
+ for e in eb.entries:
260
+ if e.empty:
261
+ continue
262
+ for f in e.funcs:
263
+ for ins in eb.instrs(f):
264
+ if ins.op == ADD_ITEM_OP:
265
+ iid = ins.imm(0)
266
+ if iid is None: # a computed item id -> can't say which item
267
+ var_give = True
268
+ continue
269
+ if iid == NO_ITEM or item_inert(iid): # NoItem / engine no-op (id % 1000 >= 612) -> no grant
270
+ continue
271
+ cnt = ins.imm(1)
272
+ prev = gives.get(iid, 0)
273
+ gives[iid] = None if (prev is None or cnt is None) else max(prev, cnt)
274
+ elif ins.op == REMOVE_ITEM_OP:
275
+ removes += 1
276
+ elif ins.op == ADD_GIL_OP:
277
+ gil_any = True
278
+ amt = ins.imm(0)
279
+ if amt is not None and amt <= GIL_CAP:
280
+ gil_max = max(gil_max, amt)
281
+ elif ins.op == MENU_OP and ins.imm(0) == SHOP_MENU_ID:
282
+ sid = ins.imm(1)
283
+ if sid is not None:
284
+ shops.add(sid)
285
+ else: # a story-gated shop (computed sub_id) -> can't name the id
286
+ var_shop = True
287
+ return {"gives": sorted(gives.items()), "gil_max": gil_max, "gil_any": gil_any,
288
+ "shops": sorted(shops), "removes": removes, "var_give": var_give, "var_shop": var_shop}
289
+
290
+
291
+ @dataclass
292
+ class ForkReport:
293
+ field_id: int
294
+ fbg_name: str = ""
295
+ event_name: str = ""
296
+ has_script: bool = True
297
+ n_objects: int = 0
298
+ n_props: int = 0 # non-talkable set-dressing
299
+ n_talkable: int = 0
300
+ n_interactive: int = 0 # talkable NPCs whose talk grafts CLEAN (keep interactions; props excluded)
301
+ n_speaking: int = 0 # carried NPCs whose tag-3 talk SHOWS dialogue (need --carry-text)
302
+ n_dialogue_lines: int = 0 # total distinct talk txids those NPCs show
303
+ directors: list = _dc_field(default_factory=list) # donor_idx of carried objects that warp/switch in LOOP
304
+ stacked: list = _dc_field(default_factory=list) # donor_idx of multi-instance (one-spot stacking) objects
305
+ safety: dict = _dc_field(default_factory=dict) # {clean: n, init_only: n, refuse: n}
306
+ gated_doors: int = 0
307
+ sc_gates: list = _dc_field(default_factory=list) # [(value, (milestone_value, beat))] sorted
308
+ suggested_scenario: int | None = None
309
+ roster_class: str = "static-roster" # "static-roster" | "story-event"
310
+ beat_roster: list = _dc_field(default_factory=list) # [(beat, milestone, [(slot, model_name, is_director)])]
311
+ # per ScenarioCounter beat (incl. 0), which carried objects the director actually spawns -- the
312
+ # #13 "rotating cast" preview (empty unless the roster genuinely VARIES across beats)
313
+ player_models: list = _dc_field(default_factory=list) # [(entry_index, model_id, name)] -- the defined PC(s)
314
+ multi_pc: bool = False # the field defines >1 DefinePlayerCharacter
315
+ non_zidane: bool = False # the controlled player isn't Zidane -> --verbatim is the faithful mode
316
+ controlled_entry: int | None = None # the entry the engine BINDS control to (multi-PC; controlled_player)
317
+ controlled_name: str = "" # its character name
318
+ control_confidence: str = "none" # 'high' | 'low' | 'none' (binder ambiguity)
319
+ swap_gesture_count: int = 0 # scripted player GESTURES that would glitch on a --swap-player
320
+ arrival_spots: int = 0 # distinct per-ENTRANCE player spawn points (#9); >1 = a synth
321
+ # fork collapses them to one [player] spawn (loses per-door arrival) -- --verbatim ships the real table
322
+ cam_pitch: float | None = None # camera downward pitch (deg); None = not read (.eb-only / no install)
323
+ cam_fov: float | None = None # horizontal FOV (deg) -> close/medium/wide feel
324
+ cam_scrolling: bool = False # a wide/scrolling field (range past one 384x448 screen)
325
+ cam_count: int = 0 # number of cameras (> 1 = a multi-camera field)
326
+ cam_range_h: int = 0 # camera visible height (screen units); the "how far back" signal
327
+ party_adds: list = _dc_field(default_factory=list) # distinct CharacterOldIndex names the field ADDS (B_PARTYADD)
328
+ party_removes: list = _dc_field(default_factory=list) # distinct names it REMOVES (RemoveParty)
329
+ party_reset: bool = False # SetPartyReserve -- rebuilds the recruitable roster (story reset)
330
+ party_recruit: bool = False # SetCharacterData/JOIN -- a formal recruit (battle+menu init)
331
+ party_menu: bool = False # opens the change-members MENU (moogle/save-point UI)
332
+ item_gives: list = _dc_field(default_factory=list) # [(item_id, count)] distinct AddItem grants (count = max single)
333
+ item_gil_max: int = 0 # largest single PLAUSIBLE literal AddGil (<= GIL_CAP)
334
+ item_gil_any: bool = False # any AddGil at all (literal or computed) -> treasure gil
335
+ item_shops: list = _dc_field(default_factory=list) # distinct Menu(2, id) shop ids the field opens
336
+ item_removes: int = 0 # RemoveItem op count
337
+ item_var_give: bool = False # an AddItem with a COMPUTED id (un-previewable)
338
+ item_var_shop: bool = False # a Menu(2, <computed>) -- a story-gated shop (id un-previewable)
339
+ lost_on_mint: list = _dc_field(default_factory=list) # [(label, detail)] -- USER-VISIBLE engine behaviors
340
+ # keyed on the real fldMapNo that a fork loses on its custom id (walkmesh hotfix / narrow-map letterbox /
341
+ # Chocobo HUD / intro FMV). The "impossible" axis of the taxonomy, per field (idgated.lost_on_mint).
342
+ area_title: tuple = None # (startOvr, endOvr) if the field has an area-title
343
+ # CARD -- donor identity SHOWN on --verbatim, dropped/auto-hidden on a synth (BG-borrow/native) fork
344
+ notes: list = _dc_field(default_factory=list)
345
+
346
+
347
+ def _is_director(eb: EbScript, donor_idx: int) -> bool:
348
+ """True if the object's LOOP (tag 1) warps (``Field()``) or runs a phase-switch -- a cutscene
349
+ director/actor carried as an NPC (the rotating-cast / stacked-spawn failure mode)."""
350
+ try:
351
+ loop = eb.entry(donor_idx).func_by_tag(LOOP_TAG)
352
+ except (IndexError, AttributeError):
353
+ return False
354
+ if loop is None:
355
+ return False
356
+ return any(ins.op in (FIELD_OP, PHASE_SWITCH_OP) for ins in eb.instrs(loop))
357
+
358
+
359
+ def scenario_gates(eb_bytes) -> list[int]:
360
+ """Distinct ScenarioCounter values the field COMPARES against (the beats it gates content on), sorted.
361
+ A field with many of these rotates its cast/content by story progress; one (or none) is static."""
362
+ out = set()
363
+ for m in _SC_GATE.finditer(bytes(eb_bytes)):
364
+ if m.group(2)[0] in _CMP_OPS:
365
+ out.add(struct.unpack("<H", m.group(1))[0])
366
+ return sorted(out)
367
+
368
+
369
+ # --- roster by beat (#13): which carried objects the director actually spawns at each ScenarioCounter beat -
370
+ # A story-event field gates its InitObject calls on ScenarioCounter (the "rotating cast"). To preview the cast
371
+ # at a given beat WITHOUT deploying, we symbolically walk Main_Init at that beat: evaluate only the
372
+ # ScenarioCounter comparisons that drive conditional jumps (fall through every OTHER conditional -- flag gates
373
+ # are assumed satisfied), follow unconditional jumps, and collect the InitObject slots actually reached. This
374
+ # correctly handles if/else, nesting, and the `if(SC==BEAT){spawn}` dispatch chain (vs naive range-containment).
375
+
376
+ def _sc_cond(data, off):
377
+ """If the EXPR statement at ``off`` is a SIMPLE ScenarioCounter comparison (``05 DC 00 7D <u16> <cmp> 7F``),
378
+ return ``(cmp_op, const)``; else ``None`` (compound/other exprs are treated as non-SC -> fall through)."""
379
+ if off + 7 >= len(data) or data[off] != EXPR_STMT_OP:
380
+ return None
381
+ if data[off + 1] != 0xDC or data[off + 2] != 0x00 or data[off + 3] != 0x7D:
382
+ return None
383
+ cmp = data[off + 6]
384
+ if cmp not in _CMP_OPS or data[off + 7] != 0x7F:
385
+ return None
386
+ return (cmp, data[off + 4] | (data[off + 5] << 8))
387
+
388
+
389
+ def _eval_cmp(sc, cond) -> bool:
390
+ """Does ``ScenarioCounter == sc`` satisfy the comparison ``cond = (cmp_op, const)``?"""
391
+ cmp, const = cond
392
+ return {0x20: sc == const, 0x18: sc < const, 0x19: sc > const,
393
+ 0x1A: sc <= const, 0x1B: sc >= const}.get(cmp, False)
394
+
395
+
396
+ def _jump_target(ins) -> int:
397
+ """Absolute byte target of a jump instr (operand is a signed i16 skip distance from the instr end)."""
398
+ raw = ins.imm(0)
399
+ if raw is None:
400
+ return -1
401
+ return ins.end + (raw - 0x10000 if raw >= 0x8000 else raw)
402
+
403
+
404
+ def _spawned_slots(instrs, sc_conds, sc) -> list:
405
+ """Symbolically execute the Main_Init instr list at ``ScenarioCounter == sc``: take a conditional jump
406
+ only when its driving ScenarioCounter comparison is known (else fall through = run the guarded body),
407
+ follow forward jumps (incl. the unconditional 0x01 that steps over an if's else-branch), and return the
408
+ ordered InitObject slots reached. A FORWARD jump lands on the first instr at-or-after the target (so a
409
+ jump to the function end correctly terminates); a BACKWARD jump is not followed (loop guard). Bounded by
410
+ a visited set + step cap."""
411
+ offs = [ins.off for ins in instrs] # ascending (Main_Init in order)
412
+ n = len(instrs)
413
+
414
+ def _forward(i, ins): # next index for a jump from instr i, or fall-through
415
+ tgt = _jump_target(ins)
416
+ k = _bisect.bisect_left(offs, tgt) if tgt >= 0 else i + 1
417
+ return k if k > i else i + 1 # forward only; backward/unknown -> fall through
418
+
419
+ out, visited, last, i, steps = [], set(), None, 0, 0
420
+ while 0 <= i < n and steps < 20000:
421
+ steps += 1
422
+ if i in visited:
423
+ break
424
+ visited.add(i)
425
+ ins = instrs[i]
426
+ op = ins.op
427
+ if op == EXPR_STMT_OP:
428
+ last = sc_conds.get(ins.off) # the SC condition for an immediately-following jump (or None)
429
+ i += 1
430
+ continue
431
+ if op in (JMP_FALSE, JMP_TRUE):
432
+ take = None
433
+ if last is not None: # known SC condition -> decide the jump deterministically
434
+ base = _eval_cmp(sc, last)
435
+ take = (not base) if op == JMP_FALSE else base
436
+ last = None
437
+ i = _forward(i, ins) if take else i + 1 # take -> skip guarded body; else/unknown -> run it
438
+ continue
439
+ if op == JMP_UNCOND: # follow forward (steps over an if's else-branch)
440
+ last = None
441
+ i = _forward(i, ins)
442
+ continue
443
+ last = None
444
+ if op == INITOBJ_OP:
445
+ s = ins.imm(0)
446
+ if s is not None:
447
+ out.append(int(s))
448
+ i += 1
449
+ return out
450
+
451
+
452
+ def _roster_entry_name(eb, slot):
453
+ """The model name for an InitObject slot (incl. cutscene actors ``scan_objects`` skips), or ``None`` for
454
+ the player / party (excluded from the roster table -- the cast of interest is the NPCs/actors)."""
455
+ from . import eventscan as _eventscan
456
+ from ._modeldb import MODELS
457
+ try:
458
+ e = eb.entry(slot)
459
+ except (IndexError, AttributeError):
460
+ return None
461
+ if e is None or e.empty:
462
+ return None
463
+ fi = e.func_by_tag(0)
464
+ if fi is None:
465
+ return None
466
+ try:
467
+ rd = _eventscan._read_object_init(eb, fi)
468
+ except Exception:
469
+ return None
470
+ m = rd.get("model")
471
+ if m is None or rd.get("player"):
472
+ return None
473
+ name = MODELS.get(m)
474
+ if name and name.startswith("GEO_MAIN"): # the party rig, not set-dressing
475
+ return None
476
+ return name or f"model {m}"
477
+
478
+
479
+ def roster_by_beat(eb, data, director_slots) -> list:
480
+ """For each ScenarioCounter beat the field gates on (plus 0 = the scenario-zero baseline), the set of
481
+ carried objects the director SPAWNS at that beat. Returns ``[(beat, milestone, [(slot, name, is_dir)])]``,
482
+ or ``[]`` when the field has no gates OR the roster does not actually vary across beats (then the flat
483
+ ``sc_gates`` line already says it all).
484
+
485
+ APPROXIMATE (a guide, confirm in-game): it evaluates only SIMPLE ScenarioCounter comparisons that drive a
486
+ jump; flag gates are assumed satisfied (so flag-gated actors are over-included), compound/looping
487
+ ScenarioCounter logic is run once (backward jumps fall through rather than iterate), and a director's OWN
488
+ per-beat model swap inside its LOOP is not traced (only WHICH objects spawn in Main_Init)."""
489
+ try:
490
+ mi = eb.entry(0).func_by_tag(0)
491
+ except (IndexError, AttributeError):
492
+ return []
493
+ if mi is None:
494
+ return []
495
+ instrs = list(eb.instrs(mi))
496
+ sc_conds = {ins.off: c for ins in instrs if ins.op == EXPR_STMT_OP
497
+ for c in (_sc_cond(data, ins.off),) if c is not None}
498
+ gates = scenario_gates(data)
499
+ if not gates:
500
+ return []
501
+ table = []
502
+ for beat in sorted(set(gates) | {0}):
503
+ seen, entries = set(), []
504
+ for s in _spawned_slots(instrs, sc_conds, beat):
505
+ if s in seen:
506
+ continue
507
+ nm = _roster_entry_name(eb, s)
508
+ if nm is None:
509
+ continue
510
+ seen.add(s)
511
+ entries.append((s, nm, s in director_slots))
512
+ table.append((beat, _flags.nearest_milestone(beat), entries))
513
+ # only meaningful if the cast genuinely rotates -- else the flat sc_gates line suffices. Compare by
514
+ # (slot, model) so a same-slot model swap also counts as variation (not just add/remove of slots).
515
+ rosters = {frozenset((s, n) for s, n, _d in row[2]) for row in table}
516
+ return table if len(rosters) >= 2 else []
517
+
518
+
519
+ def resolve_field_id(token, *, game=None) -> int:
520
+ """A field id (digit) or an FBG/event-name substring -> the numeric field id. Raises ValueError on no
521
+ match or an ambiguous substring (unless one candidate is an exact FBG/mapid match)."""
522
+ from .extract import ID_TO_FBG, ID_TO_EVT
523
+ s = str(token).strip()
524
+ if s.isdigit():
525
+ fid = int(s)
526
+ if fid in ID_TO_FBG: # a real, forkable field id (vs a typo that would silently read empty)
527
+ return fid
528
+ raise ValueError(f"no field with id {fid} -- pass a real field id or an FBG substring (see "
529
+ f"`list-fields`). Note: a bare number here is a FIELD ID, not a map number.")
530
+ sl = s.lower()
531
+ hits = [fid for fid, fbg in ID_TO_FBG.items() if sl in (fbg or "").lower()]
532
+ hits += [fid for fid, evt in ID_TO_EVT.items() if sl in (evt or "").lower() and fid not in hits]
533
+ if not hits:
534
+ raise ValueError(f"no field matches {token!r} -- pass a field id or an FBG substring (see `list-fields`)")
535
+ if len(hits) > 1:
536
+ exact = [fid for fid in hits if sl == (ID_TO_FBG.get(fid, "") or "").lower()
537
+ or ("_map" + sl) in (ID_TO_FBG.get(fid, "") or "").lower()]
538
+ if len(exact) == 1:
539
+ return exact[0]
540
+ # several fields can SHARE one FBG folder (the same room at different story beats), so listing folders
541
+ # would just repeat -- list the field IDS, which is exactly what disambiguates them.
542
+ ex = ", ".join(str(f) for f in hits[:8])
543
+ raise ValueError(f"{token!r} matches {len(hits)} fields (ids {ex}{'...' if len(hits) > 8 else ''}) "
544
+ f"-- pass the field id (a shared FBG folder maps to several fields)")
545
+ return hits[0]
546
+
547
+
548
+ def analyze(field_id: int, *, game=None, bundle=None) -> ForkReport:
549
+ """Build the fidelity preview for a real field id. ``bundle`` (an ``extract.EventBundle``) is reused
550
+ across calls when given; otherwise one is created. Read-only -- never touches the install's bytes."""
551
+ from .extract import EventBundle, ID_TO_FBG, ID_TO_EVT, field_camera_info # lazy: UnityPy only when used
552
+ b = bundle or EventBundle(game)
553
+ data = b.eb_for_id(field_id)
554
+ fbg = ID_TO_FBG.get(field_id, "")
555
+ rep = analyze_eb(data, field_id=field_id, fbg_name=fbg, event_name=ID_TO_EVT.get(field_id, ""))
556
+ # the Camera axis lives in the scene .bgs (not the .eb), so it needs the install -- populate it here,
557
+ # NOT in the pure analyze_eb (which stays .eb-only + fixture-testable). None -> the line is omitted.
558
+ ci = field_camera_info(fbg, game=game) if fbg else None
559
+ if ci:
560
+ rep.cam_pitch, rep.cam_fov = ci["pitch"], ci["fov"]
561
+ rep.cam_scrolling, rep.cam_count = ci["scrolling"], ci["count"]
562
+ rep.cam_range_h = ci.get("range_h", 0)
563
+ # the area-title CARD is keyed on the scene name (manifest in resources.assets), so it needs the install too.
564
+ # ID_TO_FBG is lowercase; the manifest keys are the real (UPPER) scene names -> match on upper.
565
+ if fbg:
566
+ from . import areatitle as _at
567
+ rep.area_title = _at.title_range(fbg.upper(), game=game)
568
+ return rep
569
+
570
+
571
+ def analyze_eb(eb_bytes, *, field_id: int = 0, fbg_name: str = "", event_name: str = "") -> ForkReport:
572
+ """The pure analysis: a fidelity preview from a field's ``.eb`` bytes (no install needed -- so it is
573
+ unit-testable against a fixture). :func:`analyze` is the thin id->bytes loader over this."""
574
+ rep = ForkReport(field_id=field_id, fbg_name=fbg_name, event_name=event_name)
575
+ if not eb_bytes:
576
+ rep.has_script = False
577
+ rep.notes.append("no field event script (a world/special/unmapped field) -- nothing to fork")
578
+ return rep
579
+ data = bytes(eb_bytes)
580
+
581
+ from . import eventscan as _eventscan # lazy (keeps import cost off the core path)
582
+ try:
583
+ eb = EbScript.from_bytes(data) # raises on bad magic -> report gracefully, don't crash a preview
584
+ except ValueError as e:
585
+ rep.has_script = False
586
+ rep.notes.append(f"not a parseable field script ({e})")
587
+ return rep
588
+ # Classify carry at the FULL faithful-fork setting -- the recommended `import --native
589
+ # --graft-player-funcs --carry-text` recipe + the default STARTSEQ-helper closure -- so the portability
590
+ # numbers match what the author actually gets (else an object only blocked by a benign Seq helper or a
591
+ # graftable player gesture reads as render-only here but carries clean in a real fork).
592
+ objs = _eventscan.scan_objects_verbatim(data, graft_player_funcs=True, carry_text=True,
593
+ graft_seq_helpers=True)
594
+ rep.n_objects = len(objs)
595
+ for o in objs:
596
+ di = o.get("donor_idx")
597
+ rep.safety[o.get("graft_safety", "?")] = rep.safety.get(o.get("graft_safety", "?"), 0) + 1
598
+ if o.get("kind") == "npc":
599
+ rep.n_talkable += 1
600
+ if o.get("graft_safety") == "clean":
601
+ rep.n_interactive += 1 # clean-graft NPCs (those that KEEP their talk) -- props excluded
602
+ else:
603
+ rep.n_props += 1
604
+ if di is not None and _is_director(eb, di):
605
+ rep.directors.append(di)
606
+ if len(o.get("instances", []) or []) > 1:
607
+ rep.stacked.append(di)
608
+
609
+ # #5 preview (the TEXT axis, orthogonal to the interaction safety above): which carried NPCs SPEAK. A
610
+ # talk handler's WindowSync shows a donor txid that renders WRONG/missing unless the fork carries the
611
+ # words -- `--carry-text` remaps them, `--verbatim` ships the whole donor `.mes`. Mirrors the build-side
612
+ # lint (`build._entry_window_txids`) as a BEFORE-you-fork preview, via the dialogue reader (analysis layer).
613
+ try:
614
+ from . import dialogue as _dialogue
615
+ obj_idxs = {o.get("donor_idx") for o in objs}
616
+ speaking: dict = {}
617
+ for c in _dialogue.scan_dialogue(eb):
618
+ if c.func_tag == TALK_TAG and c.entry_idx in obj_idxs and c.txid is not None:
619
+ speaking.setdefault(c.entry_idx, set()).add(c.txid)
620
+ rep.n_speaking = len(speaking)
621
+ rep.n_dialogue_lines = sum(len(v) for v in speaking.values())
622
+ except Exception: # a preview must never crash on an odd field
623
+ pass
624
+
625
+ try:
626
+ gw = _eventscan.scan_gateway_entries(data)
627
+ rep.gated_doors = sum(1 for g in gw if g.get("story_gated"))
628
+ except (ValueError, IndexError, KeyError, struct.error): # a malformed gateway region -> just omit the count
629
+ rep.gated_doors = 0
630
+
631
+ # The controlled player character(s). resolve_player_entries returns EVERY DefinePlayerCharacter entry
632
+ # (182 fields define >1). CAUTION: in a multi-PC field the FIRST entry is NOT reliably who you control --
633
+ # the Cargo Ship lists Blank first but you play Zidane; co-actors are also "player characters". So we crown
634
+ # a single-PC field confidently, but for multi-PC we only enumerate + infer: if ANY pc is Zidane you most
635
+ # likely control the Zidane party-leader (the rest are co-actors); ONLY when NO Zidane is defined is the
636
+ # controlled character genuinely non-Zidane (the Treno Dagger/Steiner split). The exact bind is the frontier.
637
+ pents = _eventscan.resolve_player_entries(eb)
638
+ rep.player_models = [(pe, _eventscan._player_model(eb, pe),
639
+ player_name(_eventscan._player_model(eb, pe))) for pe in pents]
640
+ rep.multi_pc = len(pents) > 1
641
+ # #9 per-door spawn: a field that positions the player by ENTRANCE (reads D8:2, branches to N spots). A
642
+ # synth fork re-authors a single [player] spawn -> collapses the table; --verbatim ships the real Init.
643
+ try:
644
+ rep.arrival_spots = _eventscan.scan_player_arrivals(eb)["distinct"]
645
+ except Exception: # a preview must never crash on an odd field
646
+ rep.arrival_spots = 0
647
+ # swap-friendliness: how a `--swap-player` fares -- the scripted gestures on the entr(ies) the swap targets
648
+ # (the controlled-leader model). 0 = a clean free-roam swap; >0 = a cutscene field where those gestures
649
+ # glitch on the new rig (only movement clips are swapped). Reuses the same logic the swap + CLI WARN use.
650
+ from . import playerswap as _playerswap
651
+ try:
652
+ rep.swap_gesture_count = _playerswap.scripted_gesture_ops(data)
653
+ except Exception: # never let the preview crash on a swap-edge field
654
+ rep.swap_gesture_count = 0
655
+ models = [m for _, m, _ in rep.player_models if m is not None]
656
+ zidane_present = any(m in _eventscan.ZIDANE_MODELS for m in models)
657
+ if not rep.multi_pc:
658
+ rep.non_zidane = bool(models) and models[0] not in _eventscan.ZIDANE_MODELS
659
+ if rep.non_zidane:
660
+ nm = rep.player_models[0][2]
661
+ rep.notes.append(f"you play as {nm} (non-Zidane) -- fork with --verbatim: it ships the donor player "
662
+ f"rig + anim packs + the field's own party/cutscene setup whole (proven faithful on "
663
+ f"Vivi/field 100). --graft-player-funcs would drop {nm}'s funcs (wrong-rig clips)")
664
+ elif models:
665
+ names = ", ".join(n for _, _, n in rep.player_models)
666
+ rep.non_zidane = not zidane_present # no Zidane among the PCs -> genuinely non-Zidane control
667
+ if rep.non_zidane:
668
+ # compute WHICH non-Zidane PC binds control (the last DefinePlayerCharacter executed). This is
669
+ # in-game proven for fixed-SID fields (the lane); see controlled_player. A Zidane-present field is
670
+ # NOT computed -- control may route through a party slot to the live leader (left as the hedge below).
671
+ ce, conf = controlled_player(eb)
672
+ rep.controlled_entry, rep.control_confidence = ce, conf
673
+ if ce is not None:
674
+ rep.controlled_name = player_name(_eventscan._player_model(eb, ce))
675
+ hedge = "" if conf == "high" else " (likely -- ambiguous spawn/gating)"
676
+ who = rep.controlled_name or "a non-Zidane character"
677
+ rep.notes.append(f"you control {who}{hedge} -- the last DefinePlayerCharacter executed of the "
678
+ f"{len(models)} PCs ({names}); the rest are co-defined companions. Fork --verbatim "
679
+ f"(the player rig + anim packs ship whole). In-game proven on the Treno Dagger/Steiner room.")
680
+ else:
681
+ rep.notes.append(f"the field defines {len(models)} player characters ({names}) -- you most likely "
682
+ f"control the Zidane party-leader; the rest are co-actors. The exact bind in a fork "
683
+ f"is untested")
684
+
685
+ # Party-membership ops the field runs (a verbatim fork executes them -> the fork changes your party).
686
+ party = scan_party_ops(data)
687
+ rep.party_adds = [party_char_name(i) for i in party["adds"]]
688
+ rep.party_removes = [party_char_name(i) for i in party["removes"]]
689
+ rep.party_reset, rep.party_recruit, rep.party_menu = party["reset"], party["recruit"], party["menu"]
690
+
691
+ # Item / treasure / shop ops the field runs (a verbatim fork carries them; a plain/synthesize fork DROPS
692
+ # them -- no item scanner). The shop STOCK is parasitic on the base ShopItems.csv (a fork can't change it).
693
+ itm = scan_item_ops(data)
694
+ rep.item_gives = itm["gives"]
695
+ rep.item_gil_max = itm["gil_max"]
696
+ rep.item_gil_any = itm["gil_any"]
697
+ rep.item_shops = itm["shops"]
698
+ rep.item_removes = itm["removes"]
699
+ rep.item_var_give = itm["var_give"]
700
+ rep.item_var_shop = itm["var_shop"]
701
+
702
+ gates = scenario_gates(data)
703
+ rep.sc_gates = [(v, _flags.nearest_milestone(v)) for v in gates]
704
+ # earliest gate ~= when the field's story content first appears = its natural "home" beat. (A rotating
705
+ # field also gates at later beats; the author picks which one -- the list shows them all.)
706
+ rep.suggested_scenario = gates[0] if gates else None
707
+ # #13 rotating-cast preview: which carried objects the director spawns at each beat (empty unless it varies)
708
+ try:
709
+ rep.beat_roster = roster_by_beat(eb, data, set(rep.directors))
710
+ except Exception: # a preview must never crash on an odd field
711
+ rep.beat_roster = []
712
+
713
+ rotating = len(gates) >= _ROTATING_GATE_COUNT
714
+ rep.roster_class = "story-event" if (rep.directors or rotating) else "static-roster"
715
+ if rep.directors:
716
+ rep.notes.append(f"{len(rep.directors)} carried object(s) are cutscene DIRECTORS (Field()/phase-switch "
717
+ f"in their LOOP) -- forking runs that logic against the asserted beat (gap #13)")
718
+ if rotating:
719
+ rep.notes.append(f"content gates on {len(gates)} story beats -- this field ROTATES its cast/content; "
720
+ f"a fork shows one beat (pick it with [startup] scenario)")
721
+ if rep.stacked:
722
+ rep.notes.append(f"{len(rep.stacked)} object(s) are multi-instanced -- watch for one-spot stacking")
723
+ # LOST ON A MINT: every user-visible engine behavior keyed on the real fldMapNo a fork loses on its custom
724
+ # id (walkmesh hotfix / narrow-map letterbox / Chocobo HUD / intro FMV) -- the taxonomy's "impossible" axis,
725
+ # per field. Pure baked data (no install), so it's fine on the install-free analyze_eb path.
726
+ from . import idgated as _idg
727
+ rep.lost_on_mint = _idg.lost_on_mint(field_id)
728
+ return rep
729
+
730
+
731
+ # --- rendering --------------------------------------------------------------------------------------
732
+ def _verdict_line(rep: ForkReport) -> str:
733
+ if rep.roster_class == "static-roster":
734
+ head = "a CLEAN static-roster field -- a native fork renders the cast faithfully"
735
+ else:
736
+ head = "a STORY-EVENT field -- a fork is a high-fidelity diorama, not a faithful slice (rotating cast / cutscene actors)"
737
+ if rep.n_talkable:
738
+ # numerator = CLEAN NPCs only (n_interactive), never the props-inclusive safety['clean']; the
739
+ # "render-only" tail only when there's a real render-only NPC remainder (not a refused prop).
740
+ inter = f"{rep.n_interactive} of {rep.n_talkable} NPC(s) keep their interactions"
741
+ if rep.n_talkable > rep.n_interactive:
742
+ inter += "; the rest render-only (re-author their dialogue)"
743
+ else:
744
+ inter = "no talkable NPCs"
745
+ parts = [f"{head}; {inter}."]
746
+ # The synthesized BOTTOM LINE across every axis: which fork MODE, and why. --verbatim is the faithful mode
747
+ # whenever the field has story-bound state a synth rebuild drops (gated cast/logic, a non-Zidane player,
748
+ # party/item grants, per-door arrival); otherwise --native is a clean diorama.
749
+ why = []
750
+ if rep.roster_class != "static-roster" or rep.sc_gates:
751
+ why.append("story-gated cast/logic")
752
+ if rep.non_zidane:
753
+ why.append("non-Zidane player")
754
+ if rep.party_adds or rep.party_removes or rep.party_reset or rep.party_recruit:
755
+ why.append("party changes")
756
+ if rep.item_gives or rep.item_var_give or rep.item_gil_any or rep.item_shops or rep.item_var_shop:
757
+ why.append("item/shop grants")
758
+ if rep.arrival_spots > 1:
759
+ why.append("per-door arrival")
760
+ if why:
761
+ reco = f"--verbatim (carries {', '.join(why[:3])}{'...' if len(why) > 3 else ''})"
762
+ if rep.sc_gates or rep.roster_class != "static-roster":
763
+ reco += " + a [startup] beat (else it boots scenario-zero)"
764
+ else:
765
+ reco = "--native (a faithful diorama; nothing story-bound to carry)"
766
+ parts.append(f"Recommended: {reco}.")
767
+ # The lost-on-a-mint steer -- only the NON-reproduced losses are fork-in-place-worthy ("auto-reproduced on
768
+ # fork" via a toggle prepend, or "reproduced by the engine fork-donor remap", both mean it's NOT lost).
769
+ losses = [lbl for lbl, det in rep.lost_on_mint if "reproduced" not in det]
770
+ if losses:
771
+ parts.append(f"Loses {', '.join(losses)} on a custom id -- fork IN-PLACE on the real id to keep (see Lost on mint).")
772
+ return " ".join(parts)
773
+
774
+
775
+ def _party_line(rep: ForkReport) -> str:
776
+ """The 'Party' axis: what a verbatim fork will do to your party. Empty when the field is party-neutral."""
777
+ if not (rep.party_adds or rep.party_removes or rep.party_reset or rep.party_recruit or rep.party_menu):
778
+ return ""
779
+ bits = []
780
+ if rep.party_adds:
781
+ shown = ", ".join(rep.party_adds[:6]) + (f" +{len(rep.party_adds) - 6}" if len(rep.party_adds) > 6 else "")
782
+ bits.append(f"adds {shown}")
783
+ if rep.party_reset:
784
+ bits.append("rebuilds the roster (story reset)" if rep.party_removes else "sets the recruitable roster")
785
+ elif rep.party_removes:
786
+ shown = ", ".join(rep.party_removes[:6]) + (f" +{len(rep.party_removes) - 6}" if len(rep.party_removes) > 6 else "")
787
+ bits.append(f"removes {shown}")
788
+ if rep.party_menu:
789
+ bits.append("opens the change-members menu")
790
+ tail = " (a --verbatim fork RUNS this; a plain fork inherits your current party)"
791
+ return f" Party : {'; '.join(bits)}{tail}"
792
+
793
+
794
+ def _items_line(rep: ForkReport) -> str:
795
+ """The 'Items' axis: the treasure / gil / shops the field grants. A --verbatim fork RUNS these (carries
796
+ them byte-identically); a plain/synthesize fork DROPS them (no item scanner). Empty when nothing is granted."""
797
+ if not (rep.item_gives or rep.item_var_give or rep.item_gil_any or rep.item_shops or rep.item_var_shop):
798
+ return ""
799
+ bits = []
800
+ if rep.item_gives:
801
+ shown = ", ".join(item_label(i) + (" x%d" % c if c not in (None, 1) else "")
802
+ for i, c in rep.item_gives[:6])
803
+ more = f" +{len(rep.item_gives) - 6}" if len(rep.item_gives) > 6 else ""
804
+ bits.append(f"grants {shown}{more}")
805
+ if rep.item_var_give:
806
+ bits.append("computed-id item(s)")
807
+ if rep.item_gil_max:
808
+ bits.append(f"up to {rep.item_gil_max} gil")
809
+ elif rep.item_gil_any:
810
+ bits.append("gil (scripted)")
811
+ if rep.item_shops:
812
+ ids = ", ".join("#%d" % s for s in rep.item_shops[:6])
813
+ more = f" +{len(rep.item_shops) - 6}" if len(rep.item_shops) > 6 else ""
814
+ bits.append(f"opens shop(s) {ids}{more}" + (" + a story-gated shop" if rep.item_var_shop else ""))
815
+ elif rep.item_var_shop:
816
+ bits.append("opens a story-gated shop")
817
+ tail = " (--verbatim carries these; a plain/synthesize fork DROPS them"
818
+ tail += "; shop stock = base ShopItems.csv)" if (rep.item_shops or rep.item_var_shop) else ")"
819
+ return f" Items : {'; '.join(bits)}{tail}"
820
+
821
+
822
+ def _camera_line(rep: ForkReport) -> str:
823
+ """The Camera axis: a close/medium/wide feel + the raw pitch/FOV (the lens the fork plays through).
824
+ Empty when the camera wasn't read (the pure .eb-only path / no install) -- so the report degrades."""
825
+ if rep.cam_pitch is None:
826
+ return ""
827
+ fov = rep.cam_fov
828
+ if fov is None:
829
+ feel = "unknown-fov"
830
+ elif fov < 10:
831
+ feel = "distant" # a sub-10 "FOV" is a far telephoto (FF9 projection is orthographic-like,
832
+ # so a tiny FOV = zoomed FAR OUT, model is a speck) -- NOT an intimate room
833
+ elif fov < 35:
834
+ feel = "close" # an intimate room (e.g. ac_rst_x ~29.5) -- a good --swap/demo test room
835
+ elif fov < 50:
836
+ feel = "medium"
837
+ else:
838
+ feel = "wide" # an establishing/scrolling shot (e.g. the Hangar ~61) -- details are tiny
839
+ bits = ([f"FOV {fov:g} deg"] if fov is not None else []) + [f"pitch {rep.cam_pitch:g} deg"]
840
+ extra = []
841
+ if rep.cam_scrolling:
842
+ extra.append("scrolling")
843
+ if rep.cam_count > 1:
844
+ extra.append(f"{rep.cam_count} cameras")
845
+ tail = ("; " + ", ".join(extra)) if extra else ""
846
+ return f" Camera : {feel} ({', '.join(bits)}){tail}"
847
+
848
+
849
+ def _entry_settle_line(rep: ForkReport) -> str:
850
+ """The entry-camera-settle advisory (coarse flag). The engine's smooth-camera follower eases onto the
851
+ spawn on a warp-in; a SYNTH fork (--native/BG-borrow) reveals immediately, so on a SCROLLING field that
852
+ ease is VISIBLE as a drift (worst on an F6/hard warp; the bigger the spawn-to-centre delta, the longer it
853
+ drifts). Empty for a fixed-camera field (no center-on-player motion) or when the camera wasn't read. A
854
+ --verbatim fork carries the donor's real entry sequence, which hides it. (content/entry_settle.py.)"""
855
+ if not rep.cam_scrolling:
856
+ return ""
857
+ return (" Entry settle : scrolling camera -> a SYNTH (--native/BG-borrow) fork may show the camera ease "
858
+ "onto the spawn on warp-in (worst on an F6/hard warp; a big spawn-to-centre delta drifts longer). "
859
+ "Add `[camera] entry_settle = 45` to hide it behind the load fade; a --verbatim fork carries the "
860
+ "real entry sequence and doesn't need it.")
861
+
862
+
863
+ def format_report(rep: ForkReport) -> str:
864
+ title = rep.fbg_name or f"field {rep.field_id}"
865
+ lines = [f"fork-report: {title} (field {rep.field_id}{', ' + rep.event_name if rep.event_name else ''})", ""]
866
+ if not rep.has_script:
867
+ lines.append(" " + (rep.notes[0] if rep.notes else "no event script"))
868
+ return "\n".join(lines)
869
+
870
+ if rep.player_models:
871
+ if rep.multi_pc:
872
+ names = ", ".join(n for _, _, n in rep.player_models)
873
+ if rep.non_zidane and rep.controlled_name:
874
+ q = "" if rep.control_confidence == "high" else "?"
875
+ pc = f"controls {rep.controlled_name}{q} of [{names}] [MULTI-PC non-Zidane -> --verbatim]"
876
+ else:
877
+ pc = f"{len(rep.player_models)} PCs: {names} [MULTI-PC; likely Zidane party-leader]"
878
+ else:
879
+ pc = rep.player_models[0][2] + (" [non-Zidane -> --verbatim]" if rep.non_zidane else "")
880
+ # swap-friendliness tag: is this a good `--swap-player` target? (the gestures glitch on a cutscene field)
881
+ swap = ("swap-clean" if rep.swap_gesture_count == 0
882
+ else f"swap: {rep.swap_gesture_count} gesture(s) glitch")
883
+ lines.append(f" Player : {pc} ({swap})")
884
+ if rep.arrival_spots > 1:
885
+ lines.append(f" Arrival : {rep.arrival_spots} per-door spawn points (#9) -- a SYNTH fork uses one "
886
+ f"[player] spawn (you arrive at the same spot via every door); --verbatim ships the real table")
887
+ cam_line = _camera_line(rep)
888
+ if cam_line:
889
+ lines.append(cam_line)
890
+ settle_line = _entry_settle_line(rep)
891
+ if settle_line:
892
+ lines.append(settle_line)
893
+ if rep.area_title:
894
+ a, b = rep.area_title
895
+ lines.append(f" Area title : this field shows an area-title CARD (overlays {a}-{b}) -- donor identity: "
896
+ f"kept on --verbatim (real show+fade), auto-hidden on a synth/BG-borrow fork (DROP on reuse)")
897
+ if rep.lost_on_mint:
898
+ lines.append(" Lost on mint : engine behavior(s) keyed on the real field id a fork loses on a custom id "
899
+ "(fork IN-PLACE to keep, unless noted auto-reproduced):")
900
+ for label, detail in rep.lost_on_mint:
901
+ lines.append(f" - {label}: {detail}")
902
+ s = rep.safety
903
+ dirs = f"{len(rep.directors)} director(s)" if rep.directors else "0 directors"
904
+ stack = f", {len(rep.stacked)} multi-instance" if rep.stacked else ""
905
+ lines.append(f" Roster : {rep.n_objects} carried object(s) ({rep.n_talkable} NPC, {rep.n_props} prop) "
906
+ f"- {dirs}{stack} -> {rep.roster_class.upper()}")
907
+ lines.append(f" Interactions : {s.get('clean', 0)} fully interactive, {s.get('init_only', 0)} render-only, "
908
+ f"{s.get('refuse', 0)} stub (faithful carry = --graft-player-funcs --carry-text)")
909
+ if rep.n_speaking:
910
+ lines.append(f" Dialogue : {rep.n_speaking} NPC(s) speak {rep.n_dialogue_lines} line(s) -- "
911
+ f"--carry-text (or --verbatim) ships them; else they render WRONG text (lint #5)")
912
+ if rep.sc_gates:
913
+ beats = ", ".join(f"{v} ({nm[1] if nm else '?'})" for v, nm in rep.sc_gates)
914
+ lines.append(f" Story gating : {rep.gated_doors} gated door(s); ScenarioCounter gates at {beats}")
915
+ else:
916
+ lines.append(f" Story gating : {rep.gated_doors} gated door(s); no ScenarioCounter gates (beat-agnostic)")
917
+ if rep.suggested_scenario is not None:
918
+ nm = _flags.nearest_milestone(rep.suggested_scenario)
919
+ beat = f' "{nm[1]}"' if nm else ""
920
+ lines.append(f" Home beat : suggested [startup] scenario = {rep.suggested_scenario}{beat} "
921
+ f"(the earliest gate -- adjust to the beat you're forking)")
922
+ if rep.beat_roster:
923
+ lines.append(" Roster by beat: which carried NPCs/actors the director spawns at each beat "
924
+ "(set [startup] scenario to one):")
925
+ has_dir = False
926
+ for bv, nm, entries in rep.beat_roster:
927
+ label = (nm[1] if nm else "?")
928
+ if entries:
929
+ names = ", ".join((n[4:] if n.startswith("GEO_") else n) + ("*" if d else "")
930
+ for _s, n, d in entries)
931
+ has_dir = has_dir or any(d for _s, _n, d in entries)
932
+ else:
933
+ names = "(no carried cast)"
934
+ base = " <- scenario-zero baseline" if bv == 0 else ""
935
+ lines.append(f" {bv:>6} {label:<20}: {names}{base}")
936
+ lines.append(" (approximate -- a guide, confirm in-game: flag-gated content is assumed present; "
937
+ "compound/looping ScenarioCounter logic is run once)")
938
+ if has_dir:
939
+ lines.append(" (* = a director; its OWN model may further vary by beat inside its loop -- not traced)")
940
+ party_line = _party_line(rep)
941
+ if party_line:
942
+ lines.append(party_line)
943
+ items_line = _items_line(rep)
944
+ if items_line:
945
+ lines.append(items_line)
946
+ lines += ["", " Verdict: " + _verdict_line(rep)]
947
+ if rep.notes:
948
+ lines.append("")
949
+ for n in rep.notes:
950
+ lines.append(f" - {n}")
951
+ # suggested authoring -- a non-Zidane player forks faithfully only via --verbatim (it ships the donor
952
+ # player rig + anim packs + the field's own party/cutscene setup whole; the graft path drops them).
953
+ fbg = rep.fbg_name or str(rep.field_id)
954
+ lines += ["", " Suggested authoring:"]
955
+ if rep.non_zidane:
956
+ who = "the non-Zidane PC(s)" if rep.multi_pc else rep.player_models[0][2]
957
+ lines.append(f" ff9mapkit import {fbg} --verbatim"
958
+ f" # ships {who} + rig/anim/party-setup whole (non-Zidane)")
959
+ else:
960
+ lines.append(f" ff9mapkit import {fbg} --native --graft-player-funcs --carry-text")
961
+ if rep.suggested_scenario is not None:
962
+ nm = _flags.nearest_milestone(rep.suggested_scenario)
963
+ lines += [" [startup]",
964
+ f" scenario = {rep.suggested_scenario}" + (f" # {nm[1]}" if nm else "")]
965
+ return "\n".join(lines)
966
+
967
+
968
+ # ============================================================================================
969
+ # Room finder -- sweep ALL forkable fields for the best swap/demo TEST ROOMS.
970
+ #
971
+ # A "good room" = a place to walk as a swapped character (`--swap-player`) or stage a visual test
972
+ # where the model's DETAIL is actually visible. Grounded in a 676-field calibration sweep:
973
+ # FOV ALONE is not a detail proxy (FF9's projection is orthographic-like, k~0.93 -- a tiny "FOV" is
974
+ # zoomed FAR OUT, not close), so a room is the AND of (single-PC) + (swap-clean) + a CLOSE 3/4
975
+ # single-screen camera = bounded FOV AND a 3/4 pitch band AND a near-one-screen RANGE AND not a
976
+ # `_CS_` cutscene-staging field. Two-phase for speed (~45s): a cheap .eb-only prefilter, then the
977
+ # expensive per-field camera read only on the survivors. (memory project-ff9-non-zidane-donors.)
978
+ # ============================================================================================
979
+ _REAL_FBG = re.compile(r"^fbg_n\d+_")
980
+
981
+ # Calibration constants (validated against the sweep; the proven anchor is field 1200 ac_rst_x,
982
+ # FOV 29.5 / pitch 28.8 / range_h 336). Good rooms cluster FOV ~22-42, pitch ~8-48, range_h 224-368.
983
+ ROOM_MIN_FOV = 10.0 # below this is a degenerate telephoto/orthographic camera (model is a speck), not a room
984
+ ROOM_MAX_FOV = 45.0 # above this is a wide establishing lens
985
+ ROOM_MIN_PITCH = 6.0 # below this is a flat/side-on view (no 3/4 detail; the proven anchors sit >= 8.7)
986
+ ROOM_MAX_PITCH = 48.0 # above this is near-top-down -- you see the head, not the face/body
987
+ ROOM_MAX_RANGE_H = 420 # camera visible height; intimate rooms sit 224-368, distant/wide shots 448-592
988
+ ROOM_IDEAL_FOV = 30.0 # the proven anchor sits here
989
+ ROOM_IDEAL_PITCH = 28.0 # ...and pitch ~28 -- the classic 3/4 detail view
990
+ ROOM_SCROLL_DEMERIT = 15.0 # a wide-pan field is not a tight single-screen stage (rank down, don't exclude)
991
+
992
+
993
+ def _is_real_fbg(fbg: str) -> bool:
994
+ """True for a genuine field background name (fbg_nNN_...) -- filters placeholders like 'invalidfieldmapid'."""
995
+ return bool(_REAL_FBG.match(fbg or ""))
996
+
997
+
998
+ def room_score(rep: ForkReport, *, max_fov: float = ROOM_MAX_FOV) -> float | None:
999
+ """A swap/demo test-room rank key (LOWER = tighter on the model), or None if ``rep`` fails a HARD
1000
+ filter. Assumes ``rep`` already passed the .eb prefilter (single-PC + swap-clean) and has its camera
1001
+ fields populated. FOV alone is not a detail proxy, so this ANDs FOV + pitch + the visible range + a
1002
+ cutscene-name guard -- the combination the calibration sweep validated."""
1003
+ fov, pitch, rh = rep.cam_fov, rep.cam_pitch, rep.cam_range_h
1004
+ if fov is None or pitch is None:
1005
+ return None # no readable camera -> un-rankable
1006
+ if "_CS_" in (rep.event_name or "").upper():
1007
+ return None # a cutscene-staging field, definitionally not a room
1008
+ if not (ROOM_MIN_FOV <= fov <= max_fov):
1009
+ return None # degenerate telephoto / wide establishing lens
1010
+ if not (ROOM_MIN_PITCH <= pitch <= ROOM_MAX_PITCH):
1011
+ return None # flat/side-on (no 3/4 detail) OR near-top-down
1012
+ if rh and rh > ROOM_MAX_RANGE_H:
1013
+ return None # camera too far back (a distant/wide view)
1014
+ key = abs(fov - ROOM_IDEAL_FOV) + 0.3 * abs(pitch - ROOM_IDEAL_PITCH)
1015
+ if rep.cam_scrolling:
1016
+ key += ROOM_SCROLL_DEMERIT
1017
+ return key
1018
+
1019
+
1020
+ @dataclass
1021
+ class RoomSweep:
1022
+ rooms: list = _dc_field(default_factory=list) # list[ForkReport], best-first
1023
+ scanned: int = 0 # real fields examined (cheap .eb pass)
1024
+ swap_clean: int = 0 # passed the single-PC + swap-clean prefilter
1025
+
1026
+
1027
+ def find_rooms(*, game=None, limit: int = 20, max_fov: float = ROOM_MAX_FOV,
1028
+ ids=None, bundle=None) -> RoomSweep:
1029
+ """Sweep every forkable field and return the best swap/demo TEST ROOMS, best-first. Two-phase for
1030
+ speed: a cheap .eb-only prefilter (ONE EventBundle, no per-field scene load) keeps single-PC +
1031
+ swap-clean fields, then the expensive per-field camera read (``field_camera_info``) runs ONLY on those
1032
+ survivors and scores them (``room_score``). Pass ``ids`` to restrict the sweep to a candidate set (also
1033
+ keeps it fast). Read-only. Needs the install (reads the scene cameras)."""
1034
+ from .extract import EventBundle, ID_TO_FBG, ID_TO_EVT, field_camera_info # lazy: UnityPy only when used
1035
+ b = bundle or EventBundle(game)
1036
+ items = [(i, ID_TO_FBG.get(i, "")) for i in ids] if ids is not None else list(ID_TO_FBG.items())
1037
+ # phase 1 (cheap, ~30s): the .eb-only filter -- a real field, a real single player, swap-clean.
1038
+ survivors = []
1039
+ scanned = 0
1040
+ for fid, fbg in items:
1041
+ if not _is_real_fbg(fbg):
1042
+ continue
1043
+ scanned += 1
1044
+ rep = analyze_eb(b.eb_for_id(fid), field_id=fid, fbg_name=fbg, event_name=ID_TO_EVT.get(fid, ""))
1045
+ # single-PC, swap-clean, a PLAYABLE controller (not a submarine/monster rig), and a STATIC roster
1046
+ # (a story-event field rotates its cast/spawns by beat -> forks as a diorama, not a clean room).
1047
+ if (rep.has_script and rep.player_models and not rep.multi_pc and rep.swap_gesture_count == 0
1048
+ and rep.roster_class == "static-roster"
1049
+ and rep.player_models[0][1] in PLAYABLE_NAMES):
1050
+ survivors.append(rep)
1051
+ # phase 2 (expensive, ~15s): read the camera ONLY for survivors, then hard-filter + score.
1052
+ scored = []
1053
+ for rep in survivors:
1054
+ ci = field_camera_info(rep.fbg_name, game=game)
1055
+ if not ci:
1056
+ continue # no readable scene -> skip (never rank a None camera)
1057
+ rep.cam_pitch, rep.cam_fov = ci["pitch"], ci["fov"]
1058
+ rep.cam_scrolling, rep.cam_count = ci["scrolling"], ci["count"]
1059
+ rep.cam_range_h = ci.get("range_h", 0)
1060
+ key = room_score(rep, max_fov=max_fov)
1061
+ if key is not None:
1062
+ scored.append((key, rep))
1063
+ scored.sort(key=lambda kr: (kr[0], kr[1].field_id))
1064
+ n = limit if (limit and limit > 0) else len(scored) # a non-positive limit -> show all (never the slice bug)
1065
+ return RoomSweep(rooms=[r for _, r in scored[:n]], scanned=scanned, swap_clean=len(survivors))
1066
+
1067
+
1068
+ def format_room_table(sweep: RoomSweep) -> str:
1069
+ """Render a RoomSweep as a ranked ASCII table (cp1252-safe)."""
1070
+ lines = ["swap/demo test rooms -- single-PC, swap-clean, a close 3/4 single-screen camera",
1071
+ "(walk as a swapped character, or stage a visual test where the model's detail is visible)", ""]
1072
+ if not sweep.rooms:
1073
+ lines.append(" no rooms matched -- try a wider --max-fov, or --limit")
1074
+ return "\n".join(lines)
1075
+ lines.append(f" {len(sweep.rooms)} room(s) (swept {sweep.scanned} fields; "
1076
+ f"{sweep.swap_clean} single-PC + swap-clean). best-first:")
1077
+ lines += ["", f" {'#':>2} {'field':>5} {'fbg':<36} {'player':<12} camera"]
1078
+ for i, rep in enumerate(sweep.rooms, 1):
1079
+ fbg = (rep.fbg_name or "")[:36]
1080
+ who = (rep.controlled_name or (rep.player_models[0][2] if rep.player_models else "?"))[:11]
1081
+ if rep.non_zidane:
1082
+ who += "*"
1083
+ cam = (f"FOV {rep.cam_fov:g}, pitch {rep.cam_pitch:g}"
1084
+ if rep.cam_fov is not None and rep.cam_pitch is not None else "(no camera)")
1085
+ flags = (["scroll"] if rep.cam_scrolling else []) + ([f"{rep.cam_count}cam"] if rep.cam_count > 1 else [])
1086
+ tail = (" " + " ".join(flags)) if flags else ""
1087
+ lines.append(f" {i:>2} {rep.field_id:>5} {fbg:<36} {who:<12} {cam}{tail}")
1088
+ lines += ["", " * = non-Zidane player (forks via --verbatim). Fork a room:",
1089
+ " ff9mapkit import <fbg> --verbatim --swap-player <char>"]
1090
+ return "\n".join(lines)
1091
+
1092
+
1093
+ # ============================================================================================
1094
+ # "Who do you play as" listing -- enrich a field list with the controlled player, so the
1095
+ # non-Zidane donors are discoverable WITHOUT forking each. Id-centric (a player is a property of
1096
+ # the .eb, so an alternate event script on a shared background is its OWN row -- more complete than
1097
+ # the folder-centric `list-fields`). Reuses analyze_eb's in-game-proven player resolution.
1098
+ # ============================================================================================
1099
+ @dataclass
1100
+ class FieldPlayer:
1101
+ field_id: int
1102
+ fbg: str
1103
+ event_name: str
1104
+ player: str # the compact "who you control" label
1105
+ non_zidane: bool
1106
+ multi_pc: bool
1107
+ playable: bool = True # the controlled model is a named cast member (vs a GEO_SUB cutscene-driver)
1108
+
1109
+
1110
+ def _player_is_playable(rep: ForkReport) -> bool:
1111
+ """True if you control a named playable cast member (not a GEO_SUB/GEO_ACC cutscene-driver model).
1112
+ Mirrors player_label's character choice so the flag matches the displayed name."""
1113
+ if not rep.player_models:
1114
+ return False
1115
+ if rep.multi_pc:
1116
+ if not rep.non_zidane:
1117
+ return True # Zidane-present multi-PC -> you control Zidane (playable)
1118
+ m = None
1119
+ if rep.controlled_entry is not None:
1120
+ m = next((mm for pe, mm, _ in rep.player_models if pe == rep.controlled_entry), None)
1121
+ return (m if m is not None else rep.player_models[0][1]) in PLAYABLE_NAMES
1122
+ return rep.player_models[0][1] in PLAYABLE_NAMES
1123
+
1124
+
1125
+ def player_label(rep: ForkReport) -> tuple:
1126
+ """(compact 'who you control' label, is_non_zidane) for a browse/list view. For a Zidane-PRESENT
1127
+ multi-PC field control most likely routes to the Zidane party-leader (not the first entry), so it
1128
+ labels 'Zidane'; a no-Zidane multi-PC field names the computed binder (`controlled_name`)."""
1129
+ if not rep.player_models:
1130
+ return ("(no player)", False)
1131
+ if rep.multi_pc:
1132
+ co = len(rep.player_models) - 1
1133
+ if rep.non_zidane: # keep the flag even if the binder name is blank
1134
+ name = rep.controlled_name or rep.player_models[0][2]
1135
+ return (f"{name} +{co}", True) # the non-Zidane control binder
1136
+ return (f"Zidane +{co}", False) # Zidane-present multi-PC: likely the leader
1137
+ return (rep.player_models[0][2], rep.non_zidane)
1138
+
1139
+
1140
+ def field_players(*, game=None, pattern=None, non_zidane_only=False, bundle=None):
1141
+ """Sweep fields and resolve WHO you control in each (the model behind ``DefinePlayerCharacter``).
1142
+ Filter by an FBG substring (``pattern``) and/or ``non_zidane_only``. Returns ``(rows, scanned)``
1143
+ (rows = FieldPlayer, sorted by fbg then id). Reuses analyze_eb (eb-only, ONE EventBundle). A full
1144
+ no-pattern sweep reads ~675 scripts (~30s); a pattern narrows it. Read-only. Needs UnityPy."""
1145
+ from .extract import EventBundle, ID_TO_FBG, ID_TO_EVT # lazy: UnityPy only when used
1146
+ b = bundle or EventBundle(game)
1147
+ pat = pattern.lower() if pattern else None
1148
+ rows = []
1149
+ scanned = 0
1150
+ for fid, fbg in sorted(ID_TO_FBG.items(), key=lambda kv: (kv[1], kv[0])):
1151
+ if not _is_real_fbg(fbg):
1152
+ continue
1153
+ if pat and pat not in fbg.lower():
1154
+ continue
1155
+ scanned += 1
1156
+ rep = analyze_eb(b.eb_for_id(fid), field_id=fid, fbg_name=fbg, event_name=ID_TO_EVT.get(fid, ""))
1157
+ label, nz = player_label(rep)
1158
+ if non_zidane_only and not nz:
1159
+ continue
1160
+ rows.append(FieldPlayer(fid, fbg, ID_TO_EVT.get(fid, "") or "", label, nz, rep.multi_pc,
1161
+ _player_is_playable(rep)))
1162
+ return rows, scanned
1163
+
1164
+
1165
+ # --- fork-report --explain: decode a field's NPC interactions into readable English -------------------
1166
+ # The antidote to "staring at bits": for every carried NPC, trace its tag-3 talk handler into plain
1167
+ # steps (real dialogue text + items/gil/menus + cross-refs), INLINING the funcs it RunScripts -- the
1168
+ # Main_Init shared logic (uid 0), the player sequences (uid 250 / a player entry), a sibling object --
1169
+ # so a multi-NPC sidequest reads as one quest. This is also WHY a render-only NPC is render-only: you
1170
+ # SEE that its talk routine is the field's own quest logic, not a graftable gesture (-> use --verbatim).
1171
+ # Pure structure is .eb-only; dialogue TEXT enriches it when the install's .mes is available. Read-only;
1172
+ # reuses the disassembler + item-pool decode + dialogue.parse_mes (no carry/graft logic of its own).
1173
+ _EXPLAIN_WIN = {0x1F: 2, 0x95: 3, 0x20: 2, 0x96: 3} # window op -> txid arg index (mirrors dialogue.WINDOW_OPS)
1174
+ _RUNSCRIPT_OPS = (0x10, 0x12, 0x14) # RunScript[Async|Sync](level, uid, tag)
1175
+ SAVE_MENU_ID = 4 # Menu(4, 0) = the save point (memory project-ff9-savepoint)
1176
+ _VERDICT = {"clean": "interactive", "init_only": "render-only", "refuse": "not carried"}
1177
+
1178
+
1179
+ @dataclass
1180
+ class NpcExplain:
1181
+ slot: int
1182
+ model: str
1183
+ verdict: str # interactive | render-only | not carried
1184
+ reason: str = "" # why, in English (render-only / not-carried only)
1185
+ steps: list = _dc_field(default_factory=list) # [(depth, kind, text)] -- kind: say|give|gil|menu|call
1186
+
1187
+
1188
+ @dataclass
1189
+ class ExplainReport:
1190
+ field_id: int
1191
+ fbg_name: str = ""
1192
+ event_name: str = ""
1193
+ npcs: list = _dc_field(default_factory=list) # NpcExplain, in spawn order
1194
+ n_props: int = 0 # non-talkable set-dressing (carried, no interaction)
1195
+ has_text: bool = False # the .mes was resolved (dialogue is real text vs <line N>)
1196
+
1197
+
1198
+ def _resolve_line(entries, txid, *, width=72) -> str:
1199
+ """A window's txid -> its (tag-stripped, one-line, truncated) text, or a ``<line N>`` placeholder when
1200
+ no ``.mes`` is loaded / the id is absent / the operand is computed."""
1201
+ if txid is None:
1202
+ return "(text chosen at runtime)"
1203
+ if not entries:
1204
+ return f"<line {txid}>"
1205
+ e = entries.get(int(txid))
1206
+ if e is None:
1207
+ return f"<line {txid}: not in this field's text>"
1208
+ from . import dialogue as _d
1209
+ s = _d.strip_tags(e.text).replace("\n", " / ").strip()
1210
+ s = " ".join(s.split()) # collapse runs of whitespace from the join
1211
+ return (s[:width] + "...") if len(s) > width else (s or "(blank line)")
1212
+
1213
+
1214
+ def _explain_call(eb, current_entry, uid, tag, pents):
1215
+ """Label a ``RunScript(uid, tag)`` in English + the entry index(es) to INLINE its body from.
1216
+ The uid->entry convention lives in :func:`eventscan.resolve_uid` (the single source of truth); this is
1217
+ the English-label layer over it."""
1218
+ from . import eventscan
1219
+ kind, targets = eventscan.resolve_uid(uid, current_entry, pents, eb.entry_count)
1220
+ label = {
1221
+ "self": f"runs its own routine #{tag}",
1222
+ "player": f"directs the player (sequence #{tag})",
1223
+ "party": f"calls a party member (routine #{tag})",
1224
+ "main": f"runs shared field logic (Main_Init routine #{tag})",
1225
+ "object": f"drives object #{uid} (routine #{tag})",
1226
+ }.get(kind, f"calls uid {uid} (routine #{tag})")
1227
+ return label, targets
1228
+
1229
+
1230
+ def _trace_interaction(eb, entry_idx, tag, entries, *, depth, visited, steps, pents):
1231
+ """Walk one function into readable steps, recursing (depth-capped, cycle-guarded) into the player /
1232
+ Main_Init / sibling routines it RunScripts -- so a sidequest split across helpers reads as one flow."""
1233
+ key = (entry_idx, tag)
1234
+ if depth > 2 or key in visited:
1235
+ return
1236
+ visited.add(key)
1237
+ e = eb.entry(entry_idx) if 0 <= entry_idx < eb.entry_count else None
1238
+ f = e.func_by_tag(tag) if (e is not None and not e.empty) else None
1239
+ if f is None:
1240
+ return
1241
+ for ins in eb.instrs(f):
1242
+ op = ins.op
1243
+ if op in _EXPLAIN_WIN:
1244
+ steps.append((depth, "say", _resolve_line(entries, ins.imm(_EXPLAIN_WIN[op]))))
1245
+ elif op == ADD_ITEM_OP:
1246
+ iid = ins.imm(0)
1247
+ if iid is None:
1248
+ steps.append((depth, "give", "an item (chosen at runtime)"))
1249
+ elif iid != NO_ITEM and not item_inert(iid):
1250
+ cnt = ins.imm(1)
1251
+ steps.append((depth, "give", item_label(iid) + (f" x{cnt}" if cnt and cnt != 1 else "")))
1252
+ elif op == ADD_GIL_OP:
1253
+ steps.append((depth, "gil", "gil"))
1254
+ elif op == MENU_OP:
1255
+ mid = ins.imm(0)
1256
+ steps.append((depth, "menu", "a shop" if mid == SHOP_MENU_ID
1257
+ else ("the save menu" if mid == SAVE_MENU_ID else f"menu #{mid}")))
1258
+ elif op in _RUNSCRIPT_OPS:
1259
+ uid, t = ins.imm(1), ins.imm(2)
1260
+ if uid is None or t is None:
1261
+ continue
1262
+ label, inline_from = _explain_call(eb, entry_idx, uid, t, pents)
1263
+ steps.append((depth, "call", label))
1264
+ for cand in inline_from: # inline the FIRST candidate that actually defines the tag
1265
+ ce = eb.entry(cand) if 0 <= cand < eb.entry_count else None
1266
+ if ce is not None and not ce.empty and ce.func_by_tag(t) is not None:
1267
+ _trace_interaction(eb, cand, t, entries, depth=depth + 1,
1268
+ visited=visited, steps=steps, pents=pents)
1269
+ break
1270
+
1271
+
1272
+ def _explain_reason(spec) -> str:
1273
+ """Why a non-clean NPC's talk handler can't be carried as-is, in English (from its classified refs)."""
1274
+ cats = []
1275
+ for r in spec["refs"]:
1276
+ k = r["klass"]
1277
+ if k in ("self", "sibling"):
1278
+ continue
1279
+ if k == "player" and r.get("tag") is None: # TurnTowardObject(player) etc. -- already safe
1280
+ continue
1281
+ if k == "player" and r.get("tag") is not None:
1282
+ cats.append("a scripted player sequence")
1283
+ elif r["op"] == 0x43: # RunSharedScript (STARTSEQ) -- a background script
1284
+ cats.append("a background script")
1285
+ elif k == "uncarried" and r.get("value") == 0 and r.get("tag") is not None:
1286
+ cats.append("shared field logic (Main_Init)")
1287
+ elif k == "uncarried" and r.get("tag") is not None:
1288
+ cats.append("another object that isn't carried")
1289
+ elif k == "expr":
1290
+ cats.append("a runtime-computed reference")
1291
+ elif k == "party":
1292
+ cats.append("a party member")
1293
+ seen = []
1294
+ for c in cats:
1295
+ if c not in seen:
1296
+ seen.append(c)
1297
+ if not seen:
1298
+ return "its talk routine references something the carry can't resolve"
1299
+ if len(seen) == 1:
1300
+ body = seen[0]
1301
+ else:
1302
+ body = ", ".join(seen[:-1]) + " and " + seen[-1]
1303
+ return "its talk routine depends on " + body
1304
+
1305
+
1306
+ def explain_eb(eb_bytes, *, field_id: int = 0, fbg_name: str = "", event_name: str = "",
1307
+ entries=None) -> ExplainReport:
1308
+ """Decode a field's NPC interactions to an :class:`ExplainReport` from its ``.eb`` bytes (pure;
1309
+ ``entries`` = a parsed ``.mes`` ``{txid: MesEntry}`` enriches the windows with real text -- omit it
1310
+ for the structure-only view). Reuses ``scan_objects_verbatim`` with FULL grafting on, so a verdict of
1311
+ ``render-only`` means the NPC stays render-only even with every graft -- i.e. genuinely field logic."""
1312
+ from . import eventscan # lazy (keeps import cost off the core path)
1313
+ rep = ExplainReport(field_id=field_id, fbg_name=fbg_name, event_name=event_name, has_text=bool(entries))
1314
+ if not eb_bytes:
1315
+ return rep
1316
+ eb = EbScript.from_bytes(bytes(eb_bytes))
1317
+ pents = set(eventscan.resolve_player_entries(eb))
1318
+ specs = eventscan.scan_objects_verbatim(bytes(eb_bytes), graft_player_funcs=True,
1319
+ carry_text=True, graft_seq_helpers=True)
1320
+ for s in specs:
1321
+ if s["kind"] != "npc":
1322
+ rep.n_props += 1
1323
+ continue
1324
+ steps: list = []
1325
+ _trace_interaction(eb, s["donor_idx"], 3, entries or {}, depth=0,
1326
+ visited=set(), steps=steps, pents=pents)
1327
+ verdict = _VERDICT.get(s["graft_safety"], s["graft_safety"])
1328
+ reason = _explain_reason(s) if s["graft_safety"] != "clean" else ""
1329
+ rep.npcs.append(NpcExplain(s["donor_idx"], s["model"], verdict, reason, steps))
1330
+ return rep
1331
+
1332
+
1333
+ def explain(field_id: int, *, game=None, bundle=None, lang: str = "us") -> ExplainReport:
1334
+ """The id->bytes loader over :func:`explain_eb` -- resolves the field's ``.mes`` (install needed for
1335
+ real dialogue text; degrades to ``<line N>`` placeholders without it). Read-only."""
1336
+ from .extract import EventBundle, ID_TO_FBG, ID_TO_EVT # lazy: UnityPy only when used
1337
+ b = bundle or EventBundle(game)
1338
+ data = b.eb_for_id(field_id)
1339
+ entries = None
1340
+ try:
1341
+ from . import dialogue as _d
1342
+ mes = _d.extract_field_mes(str(field_id), lang=lang, game=game)
1343
+ if mes:
1344
+ entries = _d.parse_mes(mes)
1345
+ except Exception: # no install / no UnityPy / no text block -> structure-only
1346
+ entries = None
1347
+ return explain_eb(data, field_id=field_id, fbg_name=ID_TO_FBG.get(field_id, ""),
1348
+ event_name=ID_TO_EVT.get(field_id, ""), entries=entries)
1349
+
1350
+
1351
+ _STEP_GLYPH = {"say": '"{}"', "give": "gives {}", "gil": "gives gil", "menu": "opens {}", "call": "-> {}"}
1352
+
1353
+
1354
+ def format_explain(rep: ExplainReport) -> str:
1355
+ """Render an :class:`ExplainReport` as a readable cast-interaction transcript."""
1356
+ head = rep.fbg_name or f"field {rep.field_id}"
1357
+ suffix = f" (field {rep.field_id}{', ' + rep.event_name if rep.event_name else ''})"
1358
+ out = [f"fork-report --explain: {head}{suffix}", ""]
1359
+ n_int = sum(1 for n in rep.npcs if n.verdict == "interactive")
1360
+ n_ro = sum(1 for n in rep.npcs if n.verdict == "render-only")
1361
+ out.append(f" {len(rep.npcs)} NPC(s): {n_int} interactive, {n_ro} render-only"
1362
+ f"{f', {rep.n_props} prop(s)' if rep.n_props else ''}.")
1363
+ out.append(" Each NPC's talk routine is decoded below (dialogue + items + the funcs it runs).")
1364
+ if n_ro:
1365
+ out.append(" A render-only NPC's routine IS field logic (shared/player/quest), not a graftable")
1366
+ out.append(" gesture -- fork with --verbatim to keep it interactive (or re-author the interaction).")
1367
+ if not rep.has_text:
1368
+ out.append(" (no install / text block -> dialogue shown as <line N>; run with the game present for words.)")
1369
+ out.append("")
1370
+ if not rep.npcs:
1371
+ out.append(" (no carried NPCs -- nothing to explain.)")
1372
+ return "\n".join(out)
1373
+ for n in rep.npcs:
1374
+ glyph = "*" if n.verdict == "interactive" else "o"
1375
+ tag = f"[{n.verdict}{' -- ' + n.reason if n.reason else ''}]"
1376
+ out.append(f" {glyph} {n.model} (slot {n.slot}) {tag}")
1377
+ if not n.steps:
1378
+ out.append(" (no talk routine -- silent NPC)")
1379
+ for depth, kind, text in n.steps:
1380
+ pad = " " + " " * depth
1381
+ out.append(pad + _STEP_GLYPH.get(kind, "{}").format(text))
1382
+ out.append("")
1383
+ return "\n".join(out).rstrip()