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/logic_map.py ADDED
@@ -0,0 +1,526 @@
1
+ """``logic-map`` -- a read-only, legible VIEW of a field's WHOLE event script (``.eb``).
2
+
3
+ A ``--verbatim`` fork ships the donor's whole compiled ``.eb`` (entry-0 + every object + every gateway +
4
+ every shared subroutine), so the declarative ``[[npc]]``/``[[gateway]]`` model is empty -- the content is
5
+ raw bytecode (docs/FORK_FIDELITY.md, CLAUDE.md section 8 #14). You can't EXTRACT an NPC's talk handler into a
6
+ portable block (it RunScripts into Main_Init shared helpers, drives siblings, puppeteers the player -- proven
7
+ 0-of-55 tractable). But you CAN make the entanglement *legible*: this module aggregates the scanners the kit
8
+ already has into ONE per-field graph --
9
+
10
+ * NODES = every ``(entry, function/tag)`` (the byte-exact skeleton from :mod:`ff9mapkit.eb.model`),
11
+ classified by role (Main_Init / a player sequence / an NPC talk handler / a gateway region / a shared
12
+ routine / set-dressing);
13
+ * EDGES = the resolved call graph -- every ``RunScript[Sync|Async](uid, tag)`` resolved to the entry it
14
+ dispatches into via :func:`ff9mapkit.eventscan.resolve_uid` (the one ``GetObjUID`` convention), plus
15
+ ``Field()``/``WorldMap()`` warps and ``StartSeq`` launches;
16
+ * per-node SIDE EFFECTS -- the dialogue lines (``Window*`` txids), item/gil/shop grants, and GLOB story-flag
17
+ reads/writes each routine performs.
18
+
19
+ It is **read-only and derived** -- the inverse of nothing; it never edits or regenerates the ``.eb``. It is
20
+ the data behind Phase 0 of the field-logic-map plan: the foundation the GUI surfaces (so a verbatim fork's
21
+ empty tree fills with its real, inspectable content) and a future in-place edit layer keys off.
22
+
23
+ THE FIDELITY CEILING (honest, permanent): an operand chosen at runtime (an expression-computed uid / Field id
24
+ / item / flag) or a ``REPLY*`` dynamic-caller (``0x16/0x18/0x1A``) cannot be resolved offline -- those are
25
+ MARKED in ``unresolved`` but not drawn as edges. The map is high-fidelity-WITH-HOLES, not exhaustive.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ from dataclasses import dataclass, field as _dc_field
31
+
32
+ from .eb.model import EbScript
33
+
34
+ # --- opcode constants (the side-effect + edge surface) ----------------------------------------------
35
+ WINDOW_OPS = {0x1F: 2, 0x20: 2, 0x95: 3, 0x96: 3} # WindowSync/Async[Ex] -> txid arg index (dialogue.WINDOW_OPS)
36
+ ADD_ITEM_OP = 0x48 # AddItem(item_id, count)
37
+ REMOVE_ITEM_OP = 0x49 # RemoveItem(item_id, count)
38
+ ADD_GIL_OP = 0xCE # AddGil(amount)
39
+ REMOVE_GIL_OP = 0xCF # RemoveGil(amount)
40
+ MENU_OP = 0x75 # Menu(menu_id, sub_id); 2 = shop, 4 = save, 1 = name, 5 = chocograph
41
+ SHOP_MENU_ID = 2
42
+ SAVE_MENU_ID = 4
43
+ FIELD_OP = 0x2B # Field(dest) -- a field-to-field warp
44
+ WORLDMAP_OP = 0xB6 # WorldMap(loc) -- leave to the overworld (loc = a LOCATION id, not a field)
45
+ RUNSCRIPT_OPS = (0x10, 0x12, 0x14) # RunScript[Async|Sync](level, uid, tag)
46
+ RUNSCRIPT_NAMES = {0x10: "RunScriptAsync", 0x12: "RunScript", 0x14: "RunScriptSync"}
47
+ REPLY_OPS = (0x16, 0x18, 0x1A) # Reply[Async|Sync] -- dispatch to the DYNAMIC caller (unresolvable)
48
+ STARTSEQ_OP = 0x43 # RunSharedScript / STARTSEQ(entry) -- launch a concurrent Seq by ENTRY index
49
+ EXPR_STMT_OP = 0x05 # an expression statement (flag reads/writes ride here)
50
+ INIT_OBJECT_OP = 0x09 # InitObject(slot, arg) in Main_Init -- spawns/activates an object entry
51
+ DEFINE_PC_OP = 0x2C # DefinePlayerCharacter
52
+
53
+ # GLOB story-flag expression tokens (shared with eventscan's raw-byte scanners) -----------------------
54
+ from .eventscan import (_glob_var_token, _PUSH_CONST16, _T_ASSIGN, _T_OR_ASSIGN, # noqa: E402
55
+ _T_NOT, _T_END, _JMP_FALSE, _JMP_TRUE)
56
+
57
+
58
+ # --- data model -------------------------------------------------------------------------------------
59
+ @dataclass
60
+ class Call:
61
+ """A resolved (or unresolvable) dispatch edge out of a routine."""
62
+ op: str # RunScript / RunScriptSync / RunScriptAsync / StartSeq
63
+ uid: int | None
64
+ tag: int | None
65
+ target_kind: str # self | player | party | main | object | seq | unknown
66
+ targets: list # candidate entry index(es) it dispatches into ([] = unresolved offline)
67
+ off: int
68
+ label: str = ""
69
+
70
+
71
+ @dataclass
72
+ class Node:
73
+ """One ``(entry, function/tag)`` with everything it does, attributed per-routine."""
74
+ entry: int
75
+ tag: int
76
+ kind: str # main_init / shared_routine / player_seq / npc_talk / object_loop / ...
77
+ abs_start: int
78
+ abs_end: int
79
+ says: list = _dc_field(default_factory=list) # [{txid, text}]
80
+ gives: list = _dc_field(default_factory=list) # [{kind, ...}] item/gil/shop/save/menu/remove_*
81
+ flags_set: list = _dc_field(default_factory=list) # [{index, mode}] mode = set | or
82
+ flags_read: list = _dc_field(default_factory=list) # [{index, require_set}]
83
+ warps: list = _dc_field(default_factory=list) # [{op, to}] Field / WorldMap
84
+ calls: list = _dc_field(default_factory=list) # [Call]
85
+ branches: list = _dc_field(default_factory=list) # [{op, base, edges:[{value, target, is_default}]}] switch tables
86
+ unresolved: list = _dc_field(default_factory=list) # [{op, reason, off}] runtime-computed / dynamic
87
+
88
+ @property
89
+ def empty(self) -> bool:
90
+ return not (self.says or self.gives or self.flags_set or self.flags_read
91
+ or self.warps or self.calls or self.branches or self.unresolved)
92
+
93
+
94
+ @dataclass
95
+ class EntryInfo:
96
+ """One entry-table slot, classified."""
97
+ index: int
98
+ role: str # main | player | npc | object | gateway | logic | empty
99
+ model_id: int | None = None
100
+ model_name: str | None = None
101
+ talkable: bool = False
102
+ spawns: int = 0 # how many times Main_Init InitObject()s this entry (0 = not spawned)
103
+ tags: list = _dc_field(default_factory=list)
104
+
105
+
106
+ @dataclass
107
+ class LogicMap:
108
+ field_id: int = 0
109
+ fbg_name: str = ""
110
+ event_name: str = ""
111
+ has_text: bool = False
112
+ sha256: str = ""
113
+ entries: list = _dc_field(default_factory=list) # EntryInfo
114
+ nodes: list = _dc_field(default_factory=list) # Node
115
+
116
+
117
+ # --- per-routine attribution helpers ----------------------------------------------------------------
118
+ def _line_text(entries, txid, width: int = 60):
119
+ """A readable one-line rendering of dialogue txid (or None if no .mes / not found)."""
120
+ if txid is None or not entries:
121
+ return None
122
+ e = entries.get(int(txid))
123
+ if e is None:
124
+ return None
125
+ from .dialogue import strip_tags
126
+ s = strip_tags(e.text).replace("\n", " / ").strip()
127
+ s = " ".join(s.split())
128
+ return (s[:width] + "...") if len(s) > width else (s or "(blank line)")
129
+
130
+
131
+ def _flag_write_at(d: bytes, off: int):
132
+ """If the 0x05 expression at ``off`` is a GLOB flag WRITE (``05 <glob> 7D <i16> 2C|3F 7F``), return
133
+ ``(idx, mode)`` (mode = 'set'|'or'); else None. Mirrors :func:`eventscan.scan_flags_set` per-instr."""
134
+ tok = _glob_var_token(d, off + 1)
135
+ if tok is None:
136
+ return None
137
+ idx, vlen = tok
138
+ p = off + 1 + vlen
139
+ 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:
140
+ return (idx, "set" if d[p + 3] == _T_ASSIGN else "or")
141
+ return None
142
+
143
+
144
+ def _flag_read_at(d: bytes, off: int):
145
+ """If the 0x05 expression at ``off`` is a GLOB flag READ driving a conditional jump, return
146
+ ``(idx, require_set)``; else None. Mirrors :func:`eventscan.scan_required_flags` per-instr."""
147
+ tok = _glob_var_token(d, off + 1)
148
+ if tok is None:
149
+ return None
150
+ idx, vlen = tok
151
+ p = off + 1 + vlen
152
+ negated = p < len(d) and d[p] == _T_NOT
153
+ if negated:
154
+ p += 1
155
+ if p + 1 >= len(d) or d[p] != _T_END:
156
+ return None
157
+ jmp = d[p + 1]
158
+ if jmp not in (_JMP_FALSE, _JMP_TRUE):
159
+ return None
160
+ require_set = (jmp == _JMP_TRUE and not negated) or (jmp == _JMP_FALSE and negated)
161
+ return (idx, require_set)
162
+
163
+
164
+ def _func_kind(role: str, tag: int) -> str:
165
+ """A readable kind for a (role, tag) -- the engine's func-tag conventions."""
166
+ if role == "main":
167
+ return {0: "main_init", 10: "main_reinit"}.get(tag, "shared_routine")
168
+ if role == "player":
169
+ return {0: "player_init", 1: "player_loop"}.get(tag, "player_seq")
170
+ if role == "gateway":
171
+ return {2: "gateway_tread", 3: "gateway_press", 10: "gateway_reinit"}.get(tag, "gateway_routine")
172
+ if role in ("npc", "object"):
173
+ return {0: "object_init", 1: "object_loop", 2: "object_tread", 3: "npc_talk",
174
+ 10: "object_reinit"}.get(tag, "object_routine")
175
+ return {0: "init", 2: "tread", 3: "press", 10: "reinit"}.get(tag, "routine")
176
+
177
+
178
+ # --- the builder ------------------------------------------------------------------------------------
179
+ def build_logic_map(eb_bytes, *, entries=None, field_id: int = 0, fbg_name: str = "",
180
+ event_name: str = "") -> LogicMap:
181
+ """Build a :class:`LogicMap` from a field's ``.eb`` bytes (pure; ``entries`` = a parsed ``.mes``
182
+ ``{txid: MesEntry}`` to enrich dialogue with real text -- omit for the structure-only view)."""
183
+ from . import eventscan
184
+ from . import forkreport as FR
185
+ from ._modeldb import MODELS
186
+
187
+ lm = LogicMap(field_id=field_id, fbg_name=fbg_name, event_name=event_name, has_text=bool(entries))
188
+ if not eb_bytes:
189
+ return lm
190
+ data = bytes(eb_bytes)
191
+ lm.sha256 = hashlib.sha256(data).hexdigest()
192
+ eb = EbScript.from_bytes(data)
193
+
194
+ player_entries = eventscan.resolve_player_entries(eb)
195
+ gateway_entries = {g["entry_idx"] for g in eventscan.scan_gateway_entries(data)}
196
+
197
+ # count Main_Init InitObject() spawns per entry (0 = the entry is defined but never activated)
198
+ spawns: dict = {}
199
+ e0 = next((e for e in eb.entries if not e.empty and e.index == 0), None)
200
+ f0 = e0.func_by_tag(0) if e0 else None
201
+ if f0 is not None:
202
+ for ins in eb.instrs(f0):
203
+ if ins.op == INIT_OBJECT_OP and ins.args and isinstance(ins.args[0], int):
204
+ spawns[int(ins.args[0])] = spawns.get(int(ins.args[0]), 0) + 1
205
+
206
+ for e in eb.entries:
207
+ if e.empty:
208
+ lm.entries.append(EntryInfo(e.index, "empty"))
209
+ continue
210
+ rd = eventscan._read_object_init(eb, e.func_by_tag(0)) if e.func_by_tag(0) else {}
211
+ model_id = rd.get("model")
212
+ model_name = MODELS.get(model_id) if model_id is not None else None
213
+ talkable = e.func_by_tag(3) is not None
214
+ if e.index == 0:
215
+ role = "main"
216
+ elif e.index in player_entries or rd.get("player"):
217
+ role = "player"
218
+ elif e.index in gateway_entries:
219
+ role = "gateway"
220
+ elif model_id is not None:
221
+ role = "npc" if talkable else "object"
222
+ else:
223
+ role = "logic"
224
+ lm.entries.append(EntryInfo(e.index, role, model_id, model_name, talkable,
225
+ spawns.get(e.index, 0), [f.tag for f in e.funcs]))
226
+
227
+ for f in e.funcs:
228
+ node = Node(e.index, f.tag, _func_kind(role, f.tag), f.abs_start, f.abs_end)
229
+ for ins in eb.instrs(f):
230
+ op = ins.op
231
+ if op in WINDOW_OPS:
232
+ txid = ins.imm(WINDOW_OPS[op])
233
+ if txid is None:
234
+ node.unresolved.append({"op": ins.name, "reason": "text chosen at runtime", "off": ins.off})
235
+ else:
236
+ node.says.append({"txid": int(txid), "text": _line_text(entries, txid)})
237
+ elif op == ADD_ITEM_OP:
238
+ iid = ins.imm(0)
239
+ if iid is None:
240
+ node.unresolved.append({"op": ins.name, "reason": "item chosen at runtime", "off": ins.off})
241
+ elif iid != FR.NO_ITEM and not FR.item_inert(iid): # skip the engine no-op grants (as scan_item_ops)
242
+ node.gives.append({"kind": "item", "id": int(iid), "count": ins.imm(1),
243
+ "label": FR.item_label(iid)})
244
+ elif op == REMOVE_ITEM_OP:
245
+ node.gives.append({"kind": "remove_item", "id": ins.imm(0), "count": ins.imm(1)})
246
+ elif op == ADD_GIL_OP:
247
+ node.gives.append({"kind": "gil", "amount": ins.imm(0)})
248
+ elif op == REMOVE_GIL_OP:
249
+ node.gives.append({"kind": "remove_gil", "amount": ins.imm(0)})
250
+ elif op == MENU_OP:
251
+ mid = ins.imm(0)
252
+ if mid == SHOP_MENU_ID:
253
+ node.gives.append({"kind": "shop", "id": ins.imm(1)})
254
+ elif mid == SAVE_MENU_ID:
255
+ node.gives.append({"kind": "save_menu"})
256
+ elif mid is not None:
257
+ node.gives.append({"kind": "menu", "id": int(mid)})
258
+ elif op == FIELD_OP:
259
+ to = ins.imm(0)
260
+ if to is None:
261
+ node.unresolved.append({"op": ins.name, "reason": "warp target computed", "off": ins.off})
262
+ else:
263
+ node.warps.append({"op": "Field", "to": int(to)})
264
+ elif op == WORLDMAP_OP:
265
+ loc = ins.imm(0)
266
+ node.warps.append({"op": "WorldMap", "to": int(loc) if loc is not None else None})
267
+ elif op in RUNSCRIPT_OPS:
268
+ uid, t = ins.imm(1), ins.imm(2)
269
+ if uid is None or t is None:
270
+ node.unresolved.append({"op": ins.name, "reason": "call target computed", "off": ins.off})
271
+ else:
272
+ kind, targets = eventscan.resolve_uid(uid, e.index, player_entries, eb.entry_count)
273
+ node.calls.append(Call(RUNSCRIPT_NAMES.get(op, ins.name), int(uid), int(t),
274
+ kind, targets, ins.off, _call_label(kind, uid, t)))
275
+ elif op == STARTSEQ_OP:
276
+ slot = ins.imm(0)
277
+ if slot is None:
278
+ node.unresolved.append({"op": ins.name, "reason": "seq target computed", "off": ins.off})
279
+ else:
280
+ node.calls.append(Call("StartSeq", None, None, "seq", [int(slot)], ins.off,
281
+ f"starts concurrent seq (entry #{slot})"))
282
+ elif op in REPLY_OPS:
283
+ node.unresolved.append({"op": ins.name, "reason": "dispatches to the dynamic caller",
284
+ "off": ins.off})
285
+ elif ins.is_switch:
286
+ sw = ins.switch()
287
+ if sw is None:
288
+ node.unresolved.append({"op": ins.name, "reason": "switch operands computed", "off": ins.off})
289
+ else:
290
+ node.branches.append({"op": ins.name, "base": sw.base,
291
+ "edges": [{"value": e.value, "target": e.target,
292
+ "is_default": e.is_default} for e in sw.edges]})
293
+ elif op == EXPR_STMT_OP:
294
+ w = _flag_write_at(data, ins.off)
295
+ if w is not None:
296
+ node.flags_set.append({"index": w[0], "mode": w[1]})
297
+ else:
298
+ r = _flag_read_at(data, ins.off)
299
+ if r is not None:
300
+ node.flags_read.append({"index": r[0], "require_set": r[1]})
301
+ lm.nodes.append(node)
302
+ return lm
303
+
304
+
305
+ def _call_label(kind: str, uid, tag) -> str:
306
+ return {
307
+ "self": f"runs its own routine #{tag}",
308
+ "player": f"directs the player (sequence #{tag})",
309
+ "party": f"calls a party member (routine #{tag})",
310
+ "main": f"runs shared field logic (Main_Init routine #{tag})",
311
+ "object": f"drives object #{uid} (routine #{tag})",
312
+ }.get(kind, f"calls uid {uid} (routine #{tag})")
313
+
314
+
315
+ # --- id -> bytes loader (the install-backed entry point) --------------------------------------------
316
+ def logic_map(field_id: int, *, game=None, bundle=None, lang: str = "us") -> LogicMap:
317
+ """Load a real field's ``.eb`` (+ ``.mes`` for dialogue text) and build its :class:`LogicMap`.
318
+ Read-only; degrades to ``<line N>`` placeholders without an install. Mirrors ``forkreport.explain``."""
319
+ from .extract import EventBundle, ID_TO_FBG, ID_TO_EVT
320
+ b = bundle or EventBundle(game)
321
+ data = b.eb_for_id(field_id)
322
+ entries = None
323
+ try:
324
+ from . import dialogue as _d
325
+ mes = _d.extract_field_mes(str(field_id), lang=lang, game=game)
326
+ if mes:
327
+ entries = _d.parse_mes(mes)
328
+ except Exception:
329
+ entries = None
330
+ return build_logic_map(data, entries=entries, field_id=field_id,
331
+ fbg_name=ID_TO_FBG.get(field_id, ""), event_name=ID_TO_EVT.get(field_id, ""))
332
+
333
+
334
+ # --- serialization ----------------------------------------------------------------------------------
335
+ def to_dict(lm: LogicMap) -> dict:
336
+ """A JSON-serializable dict of the map (the generated read-only VIEW)."""
337
+ return {
338
+ "field_id": lm.field_id, "fbg_name": lm.fbg_name, "event_name": lm.event_name,
339
+ "has_text": lm.has_text, "generated_from_sha256": lm.sha256,
340
+ "entries": [vars(e) for e in lm.entries],
341
+ "nodes": [{**{k: v for k, v in vars(n).items() if k != "calls"},
342
+ "calls": [vars(c) for c in n.calls]} for n in lm.nodes],
343
+ }
344
+
345
+
346
+ # --- readable rendering -----------------------------------------------------------------------------
347
+ _ROLE_GLYPH = {"main": "*", "player": "@", "npc": "o", "object": ".", "gateway": ">", "logic": "-"}
348
+
349
+
350
+ def _fmt_node_lines(n: Node, indent: str = " ") -> list:
351
+ out = []
352
+ for s in n.says:
353
+ t = s["text"] if s["text"] else f"<line {s['txid']}>"
354
+ out.append(f'{indent}"{t}"')
355
+ for g in n.gives:
356
+ if g["kind"] == "item":
357
+ out.append(f"{indent}gives {g['label']}" + (f" x{g['count']}" if g.get("count") not in (None, 1) else ""))
358
+ elif g["kind"] == "gil":
359
+ out.append(f"{indent}gives gil" + (f" ({g['amount']})" if g.get("amount") is not None else ""))
360
+ elif g["kind"] == "shop":
361
+ out.append(f"{indent}opens shop #{g.get('id')}")
362
+ elif g["kind"] == "save_menu":
363
+ out.append(f"{indent}opens the save menu")
364
+ elif g["kind"] == "menu":
365
+ out.append(f"{indent}opens menu #{g.get('id')}")
366
+ elif g["kind"] == "remove_item":
367
+ out.append(f"{indent}takes item #{g.get('id')}")
368
+ elif g["kind"] == "remove_gil":
369
+ out.append(f"{indent}takes gil")
370
+ for fr in n.flags_read:
371
+ out.append(f"{indent}reads flag {fr['index']} (needs {'set' if fr['require_set'] else 'clear'})")
372
+ for fs in n.flags_set:
373
+ out.append(f"{indent}{'sets' if fs['mode'] == 'set' else 'or-sets'} flag {fs['index']}")
374
+ for w in n.warps:
375
+ out.append(f"{indent}{w['op']}({w.get('to')})")
376
+ for c in n.calls:
377
+ tgt = f" -> entry {c.targets}" if c.targets else ""
378
+ out.append(f"{indent}-> {c.label}{tgt}")
379
+ for b in n.branches:
380
+ ncases = sum(1 for e in b["edges"] if not e["is_default"])
381
+ arms = [("default" if e["is_default"] else str(e["value"])) + f"->@{e['target']}" for e in b["edges"]]
382
+ shown = ", ".join(arms[:6]) + (f", ... (+{len(arms) - 6} more)" if len(arms) > 6 else "")
383
+ out.append(f"{indent}switch ({ncases} cases): {shown}")
384
+ for u in n.unresolved:
385
+ out.append(f"{indent}? {u['op']}: {u['reason']}")
386
+ return out
387
+
388
+
389
+ def node_summary(n: Node) -> str:
390
+ """A terse ONE-LINE 'what this routine does' from the per-routine attribution (calls / dialogue / rewards /
391
+ flags / warps / branches) -- context for the GUI edit panel + tooling. ``''`` for an empty routine. This is
392
+ a SUMMARY, not the full transcript (:func:`_fmt_node_lines` lists each item)."""
393
+ parts = []
394
+ if n.calls:
395
+ tags = sorted({c.tag for c in n.calls if c.tag is not None})
396
+ parts.append(f"runs tag {tags[0]}" if (len(n.calls) == 1 and len(tags) == 1)
397
+ else f"calls {len(n.calls)} routines")
398
+ if n.says:
399
+ parts.append(f"says {len(n.says)} line" + ("s" if len(n.says) != 1 else ""))
400
+ gkinds = [g["kind"] for g in n.gives]
401
+ reward = sum(1 for k in gkinds if k in ("item", "gil"))
402
+ if reward:
403
+ parts.append(f"gives {reward} reward" + ("s" if reward != 1 else ""))
404
+ for kind, phrase in (("shop", "opens a shop"), ("save_menu", "opens the save menu"),
405
+ ("menu", "opens a menu"), ("remove_item", "takes an item"), ("remove_gil", "takes gil")):
406
+ c = gkinds.count(kind)
407
+ if c:
408
+ parts.append(phrase + (f" ×{c}" if c > 1 else ""))
409
+ if n.flags_read:
410
+ parts.append(f"reads flag {n.flags_read[0]['index']}" if len(n.flags_read) == 1
411
+ else f"reads {len(n.flags_read)} flags")
412
+ if n.flags_set:
413
+ parts.append(f"sets flag {n.flags_set[0]['index']}" if len(n.flags_set) == 1
414
+ else f"sets {len(n.flags_set)} flags")
415
+ if n.warps:
416
+ parts.append(f"{len(n.warps)} warp" + ("s" if len(n.warps) != 1 else ""))
417
+ if n.branches:
418
+ parts.append(f"{len(n.branches)} switch" + ("es" if len(n.branches) != 1 else ""))
419
+ if n.unresolved:
420
+ parts.append(f"{len(n.unresolved)} runtime-computed")
421
+ return " · ".join(parts)
422
+
423
+
424
+ def node_report(n: Node) -> list:
425
+ """A FRIENDLY, human-readable per-routine transcript (for the GUI 'What this routine does' block). Less
426
+ cryptic than :func:`_fmt_node_lines`: dialogue shows its text, flag READS read as run-conditions, flag
427
+ WRITES say 'sets story flag N', warps say 'warps to field N' / 'exits to the world map', and switch arms
428
+ show their CASE VALUES (the scenario / menu-row numbers) instead of raw byte offsets. The inherent
429
+ crypticness that remains -- raw story-flag indices + routine #tags -- has no friendlier source (FF9 story
430
+ flags + function tags are unnamed). Empty list for an empty routine."""
431
+ out = []
432
+ for s in n.says:
433
+ out.append(f'Says: "{s["text"] or ("line " + str(s["txid"]))}"')
434
+ for g in n.gives:
435
+ k = g["kind"]
436
+ if k == "item":
437
+ out.append("Gives the player " + g["label"]
438
+ + (f" ×{g['count']}" if g.get("count") not in (None, 1) else ""))
439
+ elif k == "gil":
440
+ out.append(f"Gives {g['amount']} gil" if g.get("amount") is not None else "Gives gil")
441
+ elif k == "shop":
442
+ out.append("Opens a shop")
443
+ elif k == "save_menu":
444
+ out.append("Opens the save-point menu")
445
+ elif k == "menu":
446
+ out.append(f"Opens a menu (#{g.get('id')})")
447
+ elif k == "remove_item":
448
+ out.append(f"Takes an item (#{g.get('id')})")
449
+ elif k == "remove_gil":
450
+ out.append("Takes gil")
451
+ for fr in n.flags_read:
452
+ out.append(f"Runs only if story flag {fr['index']} is " + ("SET" if fr["require_set"] else "CLEAR"))
453
+ for fs in n.flags_set:
454
+ out.append(("Sets" if fs["mode"] == "set" else "Sets (OR into)") + f" story flag {fs['index']}")
455
+ for w in n.warps:
456
+ op = str(w["op"])
457
+ if op.lower().startswith("field"):
458
+ out.append(f"Warps to field {w.get('to')}")
459
+ elif "world" in op.lower():
460
+ out.append("Exits to the world map")
461
+ else:
462
+ out.append(f"{op}({w.get('to')})")
463
+ for c in n.calls:
464
+ lbl = c.label or f"calls routine #{c.tag}"
465
+ tgt = f" [→ entry {', '.join(str(t) for t in c.targets)}]" if c.targets else ""
466
+ out.append(lbl[:1].upper() + lbl[1:] + tgt)
467
+ for b in n.branches:
468
+ vals = [str(e["value"]) for e in b["edges"] if not e["is_default"]]
469
+ if vals:
470
+ shown = ", ".join(vals[:8]) + (f", +{len(vals) - 8} more" if len(vals) > 8 else "")
471
+ out.append(f"Branches on a value → cases {shown} (else a default path)")
472
+ else:
473
+ out.append("Branches (a default path only)")
474
+ for u in n.unresolved:
475
+ out.append(f"Calls a routine chosen at runtime — {u['reason']}")
476
+ return out
477
+
478
+
479
+ def node_hint(n: Node) -> str:
480
+ """A SHORT, high-confidence tree-label suffix -- emitted ONLY when the routine has a SINGLE kind of action
481
+ (so the hint can't mislead). A mixed routine returns ``''`` and stays plain (its detail is in the panel
482
+ summary / :func:`_fmt_node_lines`)."""
483
+ cats = (bool(n.calls), bool(n.says), bool(n.gives), bool(n.warps), bool(n.flags_set), bool(n.branches))
484
+ if sum(cats) != 1:
485
+ return ""
486
+ if n.calls:
487
+ tags = sorted({c.tag for c in n.calls if c.tag is not None})
488
+ return f" → tag {tags[0]}" if (len(n.calls) == 1 and len(tags) == 1) else f" → {len(n.calls)} calls"
489
+ if n.warps:
490
+ return " → warp"
491
+ if n.says:
492
+ return " · dialogue"
493
+ if n.gives:
494
+ return " · reward"
495
+ if n.flags_set:
496
+ return " · sets flag"
497
+ return " · switch" # the only remaining single category (branches)
498
+
499
+
500
+ def format_logic_map(lm: LogicMap) -> str:
501
+ """Render a :class:`LogicMap` as a readable per-entry transcript of the whole script."""
502
+ head = lm.fbg_name or f"field {lm.field_id}"
503
+ suffix = f" (field {lm.field_id}{', ' + lm.event_name if lm.event_name else ''})"
504
+ out = [f"logic-map: {head}{suffix}", ""]
505
+ used = [e for e in lm.entries if e.role != "empty"]
506
+ out.append(f" {len(used)} entries, {len(lm.nodes)} routines"
507
+ f"{'' if lm.has_text else ' (no install/.mes -> dialogue as <line N>)'}")
508
+ out.append("")
509
+ by_entry: dict = {}
510
+ for n in lm.nodes:
511
+ by_entry.setdefault(n.entry, []).append(n)
512
+ for e in lm.entries:
513
+ if e.role == "empty":
514
+ continue
515
+ glyph = _ROLE_GLYPH.get(e.role, " ")
516
+ model = f" {e.model_name or ('model ' + str(e.model_id))}" if e.model_id is not None else ""
517
+ spawn = "" if e.role in ("main", "player") or e.spawns else " (defined, not spawned)"
518
+ out.append(f" {glyph} entry {e.index}: {e.role}{model}{spawn}")
519
+ for n in by_entry.get(e.index, []):
520
+ lines = _fmt_node_lines(n)
521
+ if not lines:
522
+ continue
523
+ out.append(f" [{n.kind} / tag {n.tag}]")
524
+ out.extend(lines)
525
+ out.append("")
526
+ return "\n".join(out).rstrip()