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/dialogue.py ADDED
@@ -0,0 +1,803 @@
1
+ """The dialogue spine -- the READ side of FF9 field text, plus a UI-agnostic view of a field's lines.
2
+
3
+ The kit has always been able to *write* dialogue (an NPC line / event message / choice menu / cutscene
4
+ ``say`` becomes a ``.mes`` entry + a ``WindowSync`` opcode, via :mod:`ff9mapkit.content.text` and
5
+ :func:`ff9mapkit.build.collect_text`). It could never *read* it back. This module closes the loop and is
6
+ the core every dialogue frontend (the CLI ``dialogue`` commands, the standalone editor, a Campaign-Editor
7
+ tab) sits on -- so the data logic is written and tested once, independent of any UI. It is **read-only**
8
+ authoring infrastructure: it never changes the proven write path (:func:`ff9mapkit.content.text.build_mes`
9
+ / ``wrap_text`` stay byte-for-byte identical, so the golden ``.eb`` is untouched).
10
+
11
+ It answers three questions:
12
+
13
+ * ``parse_mes(body)`` -> WHAT text does a ``.mes`` block hold? (txid -> :class:`MesEntry`)
14
+ * ``scan_dialogue(eb)`` -> WHICH lines does a field's ``.eb`` SHOW, and at what txid?
15
+ * ``read_local_dialogue`` / -> JOIN the two: "this NPC says <text>" for a built mod folder, or
16
+ ``read_field_dialogue`` for a real FF9 field read live from the install.
17
+
18
+ Plus :func:`project_dialogue` (the authored lines of a ``field.toml``, for the viewer/editor) and the
19
+ formatting helpers (:func:`wrap_preview` / :func:`overflow` / :func:`format_lines`) that wrap the existing
20
+ :mod:`content.text` wrapper so simple dialogue stays well-formatted.
21
+
22
+ Engine facts this relies on (verified against Memoria source + the kit's own data):
23
+ * ``.mes`` grammar: ``_[TXID=n][STRT=a,b][TAIL=code]<text>[ENDN]`` -- the exact inverse of
24
+ :func:`content.text.mes_entry`; ``<text>`` may span ``\\n`` (wrapped) and hold ``[PAGE]``.
25
+ * a field's text file is ``<fieldZoneId>.mes`` (``FF9TextTool.GetFieldTextFileName`` == the zone id as a
26
+ string); the zone id is the DictionaryPatch FieldScene 6th token (1073 for the hut; the field's own
27
+ text-zone id for a real field). Battle text uses the same ``<id>.mes`` convention in resources.assets.
28
+ * dialogue window opcodes carry the txid as an immediate operand: ``WindowSync``/``WindowAsync``
29
+ (0x1F/0x20) at operand 2, the ``...Ex`` variants (0x95/0x96) at operand 3.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import re
34
+ from dataclasses import dataclass, field as _dc_field
35
+ from pathlib import Path
36
+ from typing import Optional
37
+
38
+ from .content import text as _text
39
+ from .eb import EbScript
40
+
41
+ # dialogue window opcodes -> the operand index that carries the text id (eb/opcodes.py + _optables.py).
42
+ WINDOW_OPS = {0x1F: 2, 0x20: 2, 0x95: 3, 0x96: 3}
43
+ SET_MODEL = 0x2F # SetModel(model:u16, animset:u8) -- the NPC's model lives in operand 0
44
+ TALK_TAG = 3 # an NPC's _SpeakBTN func tag (the "press to talk" handler)
45
+
46
+
47
+ # --------------------------------------------------------------------------- data ---
48
+ @dataclass(frozen=True)
49
+ class MesEntry:
50
+ """One parsed ``.mes`` entry. ``text`` is verbatim (tags intact, may hold ``\\n``/``[PAGE]``)."""
51
+ txid: int
52
+ text: str
53
+ tail: Optional[str] = None
54
+ strt: Optional[str] = None
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class DialogueCall:
59
+ """One dialogue-window call decoded from a field's ``.eb``: which entry/func shows which txid (and,
60
+ best-effort for a kit-built NPC, the model + floor position the entry creates itself at)."""
61
+ entry_idx: int
62
+ func_tag: int
63
+ txid: Optional[int] # None when the text id is an expression (computed at runtime)
64
+ x: Optional[int] = None
65
+ z: Optional[int] = None
66
+ model: Optional[int] = None
67
+ op: int = 0x1F
68
+ flags: Optional[int] = None # the window flags operand; the 0x80 bit marks a real dialogue box
69
+
70
+ @property
71
+ def kind(self) -> str:
72
+ # an NPC's talk handler is func tag 3; everything else (Init / region / code entry) is "scene"
73
+ return "npc" if self.func_tag == TALK_TAG else "scene"
74
+
75
+ @property
76
+ def is_system(self) -> bool:
77
+ # real dialogue carries the 0x80 (text-box) flag; flags==0 windows are system/notification overlays
78
+ # (the field's error guard, the "Received item!" popup) -- not conversation.
79
+ return self.flags is not None and not (self.flags & 0x80)
80
+
81
+
82
+ @dataclass
83
+ class ViewedLine:
84
+ """One joined, human-viewable line -- what a frontend lists. ``text`` is None when the ``.mes`` had no
85
+ entry for this txid (the call was found but its text couldn't be resolved)."""
86
+ source: str # 'npc' / 'event' / 'cutscene' / 'choice' / 'scene' / 'text'
87
+ who: str # a human label (the NPC/event name, or "entry 7")
88
+ txid: Optional[int]
89
+ text: Optional[str]
90
+ tail: Optional[str] = None
91
+ pos: Optional[tuple] = None
92
+ entry: Optional[int] = None # the source .eb entry (for de-duping a line shown from several funcs)
93
+ system: bool = False # a system/notification window (flags lacking the dialogue-box bit), not dialogue
94
+ model: Optional[int] = None # the speaking object's model id (for emitting an editable [[npc]] stub)
95
+
96
+
97
+ # ------------------------------------------------------------------- .mes parse ---
98
+ # .mes layout (mirrors Memoria's FF9TextTool.ExtractSentense): a stream of `[STRT=a,b]...text...[ENDN]`
99
+ # entries whose txid is the entry's 0-based POSITION -- base-game field text carries NO `[TXID=]` tags. An
100
+ # optional `[TXID=n]` marker RE-INDEXES the running id (the kit's mod-add trick emits one per line); ids
101
+ # increment from there. So we split on `[TXID=` (re-index points), then on `[STRT=` (entries), as the engine
102
+ # does. This reads real FF9 field text (index-implicit) AND round-trips the kit's explicit `[TXID=n]` output.
103
+ _TAIL_RE = re.compile(r"\[TAIL=([^\]]*)\]")
104
+
105
+
106
+ def _parse_entry(seg: str):
107
+ """One entry from a ``[STRT=`` split segment (``"a,b]<...>text[ENDN]..."``) -> ``(strt, tail, text)``. The
108
+ text is everything after the STRT (and an optional leading TAIL) tag up to ``[ENDN]`` (verbatim -- keeps
109
+ embedded colour/name tags + ``\\n``/``[PAGE]``); a missing ``[ENDN]`` takes the rest of the segment."""
110
+ bracket = seg.find("]")
111
+ if bracket < 0:
112
+ return None
113
+ strt, rest = seg[:bracket], seg[bracket + 1:]
114
+ tail = None
115
+ mt = _TAIL_RE.match(rest)
116
+ if mt:
117
+ tail, rest = mt.group(1), rest[mt.end():]
118
+ end = rest.find("[ENDN]")
119
+ return strt, tail, (rest[:end] if end >= 0 else rest)
120
+
121
+
122
+ def parse_mes(body: str) -> dict:
123
+ """Parse a ``.mes`` block into ``{txid: MesEntry}`` -- the reader the kit never had. Handles BOTH the
124
+ base game's index-implicit entries (txid = position) and the kit's explicit ``[TXID=n]`` re-index form,
125
+ so it reads real FF9 field text AND round-trips :func:`content.text.build_mes` exactly."""
126
+ out: dict = {}
127
+ for bi, block in enumerate(("" if body is None else body).split("[TXID=")):
128
+ idx = 0
129
+ if bi > 0: # a `[TXID=n]` re-index marker -> `n]` then the entries
130
+ end = block.find("]")
131
+ if end < 0:
132
+ continue
133
+ try:
134
+ idx = int(block[:end])
135
+ except ValueError:
136
+ continue
137
+ block = block[end + 1:]
138
+ for seg in block.split("[STRT=")[1:]: # each `[STRT=` starts one entry; [0] is pre-entry junk
139
+ parsed = _parse_entry(seg)
140
+ if parsed is not None:
141
+ strt, tail, text = parsed
142
+ out[idx] = MesEntry(txid=idx, text=text, tail=tail or None, strt=strt or None)
143
+ idx += 1
144
+ return out
145
+
146
+
147
+ def strip_tags(text: str) -> str:
148
+ """A readable rendering of one line: drop FF9 control tags, render ``[PAGE]`` as a separator, and turn a
149
+ renameable name tag (``[VIVI]``) into its plain code. For a 'clean' preview, not for re-building."""
150
+ if text is None:
151
+ return ""
152
+ t = text.replace("[PAGE]", "\n---\n")
153
+
154
+ def _sub(m):
155
+ code = m.group(0)[1:-1].split("=", 1)[0].strip().upper()
156
+ return code.title() if code in _text._NAME_TAGS else ""
157
+ return _text._TAG_RE.sub(_sub, t)
158
+
159
+
160
+ # ----------------------------------------------------------------- .eb scan ---
161
+ def _signed16(v: int) -> int:
162
+ return v - 0x10000 if v >= 0x8000 else v
163
+
164
+
165
+ def _var_const(body: bytes, var_index: int):
166
+ """The 2-byte signed const a ``SetVar D9(var_index) = const`` assigns, or None. Pattern (the kit's
167
+ player-clone NPC sets its x at D9(0), z at D9(4)): ``05 D9 <var> 7D <lo> <hi>`` -- mirrors
168
+ :func:`content.npc._find_var_const` but never raises."""
169
+ pat = bytes([0x05, 0xD9, var_index & 0xFF, 0x7D])
170
+ i = body.find(pat)
171
+ if i < 0 or i + len(pat) + 1 >= len(body):
172
+ return None
173
+ j = i + len(pat)
174
+ return _signed16(body[j] | (body[j + 1] << 8))
175
+
176
+
177
+ def _entry_pos_model(eb: EbScript, entry):
178
+ """Best-effort ``(x, z, model)`` an entry's Init (tag-0) func creates itself at. Reliable for kit-built
179
+ NPCs (player-object clones); a real-field NPC that positions itself differently just yields Nones."""
180
+ f0 = entry.func_by_tag(0)
181
+ if f0 is None:
182
+ return None, None, None
183
+ body = eb.data[f0.abs_start:f0.abs_end]
184
+ model = None
185
+ for ins in eb.instrs(f0):
186
+ if ins.op == SET_MODEL:
187
+ model = ins.imm(0)
188
+ break
189
+ return _var_const(body, 0), _var_const(body, 4), model
190
+
191
+
192
+ def scan_dialogue(eb) -> list:
193
+ """Every dialogue-window call in a field's ``.eb``, in entry/func/code order -- a list of
194
+ :class:`DialogueCall`. ``eb`` may be raw bytes or an :class:`EbScript`. NPC talk lines are
195
+ ``func_tag == 3`` (kind 'npc'); event/cutscene/region lines are other tags (kind 'scene')."""
196
+ if isinstance(eb, (bytes, bytearray)):
197
+ eb = EbScript.from_bytes(bytes(eb))
198
+ calls: list = []
199
+ for entry in eb.entries:
200
+ if entry.empty:
201
+ continue
202
+ pos_model = None
203
+ for func in entry.funcs:
204
+ for ins in eb.instrs(func):
205
+ opnd = WINDOW_OPS.get(ins.op)
206
+ if opnd is None:
207
+ continue
208
+ if pos_model is None:
209
+ pos_model = _entry_pos_model(eb, entry)
210
+ x, z, model = pos_model
211
+ calls.append(DialogueCall(entry.index, func.tag, ins.imm(opnd), x, z, model, ins.op,
212
+ ins.imm(1))) # operand 1 = window flags (0x80 = dialogue box)
213
+ return calls
214
+
215
+
216
+ # ------------------------------------------------------------------- join ---
217
+ def _who(call: DialogueCall, field_label: str) -> str:
218
+ if call.kind == "npc":
219
+ tail = f", model {call.model}" if call.model is not None else ""
220
+ return f"NPC (entry {call.entry_idx}{tail})"
221
+ return f"{field_label} (entry {call.entry_idx}, func {call.func_tag})"
222
+
223
+
224
+ def join(calls, mes_map: dict, *, field_label: str = "field", trust_positions: bool = True) -> list:
225
+ """JOIN decoded ``.eb`` calls (:func:`scan_dialogue`) with parsed ``.mes`` text (:func:`parse_mes`) on
226
+ txid -> ordered :class:`ViewedLine`s (LOSSLESS -- every call, system windows flagged, no de-dup; use
227
+ :func:`present` for the clean reading view). A call whose txid has no ``.mes`` entry keeps ``text=None``.
228
+ ``trust_positions=False`` drops the ``(x,z)`` -- the position heuristic is the kit player-clone's
229
+ ``D9(0)/D9(4)`` convention, which is meaningless on a real field's own NPCs (set it False for those)."""
230
+ out = []
231
+ for c in calls:
232
+ e = mes_map.get(c.txid) if c.txid is not None else None
233
+ pos = (c.x, c.z) if (trust_positions and c.x is not None and c.z is not None) else None
234
+ out.append(ViewedLine(
235
+ source=c.kind, who=_who(c, field_label), txid=c.txid,
236
+ text=(e.text if e else None), tail=(e.tail if e else None),
237
+ pos=pos, entry=c.entry_idx, system=c.is_system, model=c.model))
238
+ return out
239
+
240
+
241
+ def present(lines, *, show_system: bool = False, dedupe: bool = True) -> list:
242
+ """The clean reading view over the lossless :func:`join` output: hide system/notification windows (the
243
+ ``flags=0`` error/'Received item!' overlays) unless ``show_system``, and collapse a line referenced from
244
+ several funcs of the SAME object to one row (preferring its NPC-talk representation over a scene/init
245
+ one). Distinct objects that share a txid stay separate (two NPCs may speak the same line)."""
246
+ rows = [ln for ln in lines if show_system or not ln.system]
247
+ if not dedupe:
248
+ return rows
249
+ out, seen = [], {}
250
+ for ln in rows:
251
+ key = (ln.entry, ln.txid, ln.text)
252
+ if key not in seen:
253
+ seen[key] = len(out)
254
+ out.append(ln)
255
+ elif out[seen[key]].source != "npc" and ln.source == "npc":
256
+ out[seen[key]] = ln # prefer the NPC-talk row as the representative
257
+ return out
258
+
259
+
260
+ # ---------------------------------------------------------- read: a mod folder (offline) ---
261
+ def _parse_dictionary_patch(path: Path) -> list:
262
+ """The ``FieldScene <id> <area> <mapid> <name> <textid>`` rows of a mod's DictionaryPatch.txt."""
263
+ rows = []
264
+ try:
265
+ lines = Path(path).read_text(encoding="utf-8", errors="replace").splitlines()
266
+ except OSError:
267
+ return rows
268
+ for ln in lines:
269
+ t = ln.split()
270
+ if len(t) >= 6 and t[0] == "FieldScene" and t[1].lstrip("-").isdigit() and t[5].lstrip("-").isdigit():
271
+ rows.append({"id": int(t[1]), "area": t[2], "mapid": t[3], "name": t[4], "textid": int(t[5])})
272
+ return rows
273
+
274
+
275
+ def _match_scene(rows: list, field):
276
+ """Pick the FieldScene row for ``field`` (a numeric id, or a name/mapid substring; case-insensitive)."""
277
+ s = str(field).strip()
278
+ if s.lstrip("-").isdigit():
279
+ fid = int(s)
280
+ return next((r for r in rows if r["id"] == fid), None)
281
+ sl = s.lower()
282
+ exact = [r for r in rows if sl in (r["name"].lower(), r["mapid"].lower())]
283
+ if exact:
284
+ return exact[0]
285
+ sub = [r for r in rows if sl in r["name"].lower() or sl in r["mapid"].lower()]
286
+ return sub[0] if len(sub) == 1 else (None if not sub else sub[0])
287
+
288
+
289
+ def _local_eb_bytes(layout, name: str, lang: str):
290
+ """The ``.eb`` bytes a mod folder holds for a field NAME, trying ``EVT_<NAME>``/``evt_<name>`` then any
291
+ file in the lang dir whose stem contains the name (forked fields keep their original evt name)."""
292
+ d = layout.eventbinary_field_dir / lang
293
+ cands = [f"EVT_{name}.eb.bytes", f"evt_{name.lower()}.eb.bytes"]
294
+ for c in cands:
295
+ p = d / c
296
+ if p.is_file():
297
+ return p.read_bytes()
298
+ if d.is_dir():
299
+ key = name.lower()
300
+ for p in sorted(d.glob("*.eb.bytes")):
301
+ if key in p.name.lower():
302
+ return p.read_bytes()
303
+ return None
304
+
305
+
306
+ def read_local_dialogue(mod_folder, field, lang: str = "us") -> list:
307
+ """Read + join the dialogue of a field in a BUILT mod folder on disk (no game install, no UnityPy) --
308
+ the offline 'view this field's dialogue' path (and the demo: the kit's own ``release/FF9CustomMap`` hut
309
+ shows 'NPC ... -> "I miss you Zidane"'). Resolves the field via the mod's DictionaryPatch."""
310
+ from .config import ModLayout
311
+ layout = ModLayout(Path(mod_folder))
312
+ rows = _parse_dictionary_patch(layout.dictionary_patch)
313
+ if not rows:
314
+ raise FileNotFoundError(f"no FieldScene rows in {layout.dictionary_patch} (is {mod_folder} a built mod?)")
315
+ row = _match_scene(rows, field)
316
+ if row is None:
317
+ names = ", ".join(sorted(r["name"] for r in rows))
318
+ raise FileNotFoundError(f"field {field!r} not in {mod_folder} (have: {names})")
319
+ eb_bytes = _local_eb_bytes(layout, row["name"], lang)
320
+ if eb_bytes is None:
321
+ raise FileNotFoundError(f"no .eb for {row['name']!r} in {layout.eventbinary_field_dir / lang}")
322
+ mes_path = layout.mes_path(lang, row["textid"])
323
+ mes_map = parse_mes(mes_path.read_text(encoding="utf-8", errors="replace")) if mes_path.is_file() else {}
324
+ return join(scan_dialogue(eb_bytes), mes_map, field_label=row["mapid"])
325
+
326
+
327
+ # ------------------------------------------------------- read: a real FF9 field (install) ---
328
+ def _resolve_field_id(field) -> int:
329
+ """A field id from a numeric id or a unique FBG-folder substring (e.g. 'alexandria', 'iccv')."""
330
+ from . import extract
331
+ s = str(field).strip()
332
+ if s.lstrip("-").isdigit():
333
+ return int(s)
334
+ sl = s.lower()
335
+ hits = sorted(fid for fid, folder in extract.ID_TO_FBG.items() if sl in folder)
336
+ if not hits:
337
+ raise FileNotFoundError(f"no field id or FBG folder matches {field!r}. Try: ff9mapkit list-fields {sl}")
338
+ if len(hits) > 1:
339
+ raise ValueError(f"{field!r} matches {len(hits)} fields; pass an id or a more specific name "
340
+ f"(e.g. {hits[:6]}).")
341
+ return hits[0]
342
+
343
+
344
+ _MES_NAME_RE = re.compile(r"^(\d+)\.mes$", re.I)
345
+
346
+
347
+ def _resources_assets(game=None):
348
+ """The resources.assets that holds the base ``<n>.mes`` field/battle text (x64 build, else flat)."""
349
+ from .config import find_game_path
350
+ g = find_game_path(game)
351
+ for cand in (g / "x64" / "FF9_Data" / "resources.assets", g / "FF9_Data" / "resources.assets"):
352
+ if cand.exists():
353
+ return cand
354
+ return None
355
+
356
+
357
+ # Common function words per language -- the reliable signal for picking the requested language among the
358
+ # per-language copies of a `<zone>.mes` (they share entry indices, so coverage can't tell them apart, and
359
+ # resources.assets carries no language in the asset path). Raw letter counts DON'T work (German/French are
360
+ # wordier than English and would win on length); whole-word stopword hits separate them cleanly.
361
+ _STOPWORDS = {
362
+ "en": ("the", "you", "and", "to", "of", "is", "it", "that", "have", "with", "this", "what", "your",
363
+ "are", "for", "but", "was", "not", "they", "here", "there", "will", "don't", "i'm", "we"),
364
+ "fr": ("le", "la", "les", "je", "ne", "pas", "vous", "est", "une", "des", "que", "qui", "pour", "tu",
365
+ "il", "ce", "mais", "c'est", "moi", "tout"),
366
+ "it": ("che", "di", "non", "il", "per", "sono", "una", "gli", "sei", "ho", "ti", "mi", "questo",
367
+ "come", "qui", "ma", "anche", "siamo"),
368
+ "es": ("que", "el", "los", "las", "una", "por", "con", "esto", "eres", "pero", "como", "para", "tu",
369
+ "muy", "aqui", "esta", "soy"),
370
+ "de": ("der", "die", "das", "und", "ist", "nicht", "ein", "ich", "zu", "es", "mit", "du", "war",
371
+ "sein", "wir", "aber", "auch", "hier", "wie"),
372
+ }
373
+ # kit lang code -> stopword set (uk==us==en; gr is German)
374
+ _LANG_ALIAS = {"us": "en", "uk": "en", "gr": "de", "fr": "fr", "it": "it", "es": "es"}
375
+ _WORD_RE = re.compile(r"[a-zàâäçéèêëîïôûùüöñ']+")
376
+
377
+
378
+ def _lang_score(text: str, lang: str) -> int:
379
+ """A 'is this block the requested language' score, to disambiguate the per-language copies of a
380
+ ``<zone>.mes``. ``jp`` = the CJK block; every other language is picked by how many of its common
381
+ function words (the/und/que/...) appear as whole words. Best-effort but reliably separates English from
382
+ German/French/Italian/Spanish on real field text."""
383
+ cjk = sum(1 for c in text if "぀" <= c <= "鿿")
384
+ if lang == "jp":
385
+ return cjk
386
+ sw = set(_STOPWORDS.get(_LANG_ALIAS.get(lang, "en"), _STOPWORDS["en"]))
387
+ hits = sum(1 for w in _WORD_RE.findall(text.lower()) if w in sw)
388
+ return hits - 3 * cjk # a CJK block is never a romance/germanic match
389
+
390
+
391
+ _MES_INDEX: dict = {} # {resources.assets path: {zone_id: [raw .mes body, ...]}} -- scanned ONCE, reused
392
+
393
+
394
+ def _mes_index(game=None) -> dict:
395
+ """``{zone_id: [raw .mes body, ...]}`` from resources.assets, scanned ONCE and cached by path.
396
+
397
+ Reading every TextAsset's typetree out of resources.assets is the dominant cost of a verbatim fork's
398
+ text carry (~half the wall time), and `import-chain` paid it afresh for every language of every member.
399
+ Building this index once turns each later lookup -- any language, any of a chain's ~80 members -- into a
400
+ dict lookup instead of a full UnityPy scan. ``{}`` when the install/UnityPy can't be read; a ``None``
401
+ resources.assets is honored on EVERY call (checked before the cache), so a missing install isn't masked
402
+ by a prior successful build."""
403
+ from . import extract
404
+ ra = _resources_assets(game)
405
+ if ra is None:
406
+ return {}
407
+ key = str(ra)
408
+ cached = _MES_INDEX.get(key)
409
+ if cached is not None:
410
+ return cached
411
+ idx: dict = {}
412
+ try:
413
+ UnityPy = extract._unitypy()
414
+ env = UnityPy.load(key)
415
+ except Exception: # noqa: BLE001 -- missing UnityPy / unreadable asset
416
+ return {}
417
+ for o in env.objects:
418
+ if o.type.name != "TextAsset":
419
+ continue
420
+ try:
421
+ d = o.read()
422
+ m = _MES_NAME_RE.match(getattr(d, "m_Name", "") or "")
423
+ if not m:
424
+ continue
425
+ body = extract._raw_bytes(d)
426
+ idx.setdefault(int(m.group(1)), []).append(body.decode("utf-8", "replace") if body else "")
427
+ except Exception: # noqa: BLE001 -- skip an unreadable block
428
+ continue
429
+ _MES_INDEX[key] = idx
430
+ return idx
431
+
432
+
433
+ def _field_text_blocks(want_txids, lang: str, game=None, zone_id: Optional[int] = None) -> list:
434
+ """Sorted candidate ``.mes`` blocks for a field: ``(coverage, lang_score, raw_body, {txid: MesEntry})``,
435
+ best first. A field's text file is ``<zone-id>.mes`` (named by the field's text-zone id, not its map id).
436
+ With ``zone_id`` it reads that block (picking the requested LANGUAGE among its per-lang copies); otherwise
437
+ it scans every ``<n>.mes`` and keeps the block that best covers ``want_txids`` (a field references a
438
+ contiguous range, so the best-overlap block is its own), tie-broken by language. Reads from the cached
439
+ :func:`_mes_index` (one scan, reused). Returns ``[]`` -- never raises. Shared by :func:`_load_field_text`
440
+ (parsed map) and :func:`extract_field_mes` (raw body)."""
441
+ want = set(t for t in (want_txids or []) if t is not None)
442
+ idx = _mes_index(game)
443
+ if zone_id is not None:
444
+ raws = idx.get(int(zone_id), [])
445
+ else:
446
+ raws = [r for rs in idx.values() for r in rs]
447
+ cands = [] # (coverage, lang_score, raw, parsed)
448
+ for raw in raws:
449
+ parsed = parse_mes(raw)
450
+ cov = len(want & set(parsed)) if want else len(parsed)
451
+ if zone_id is None and want and cov == 0: # auto-detect: ignore blocks that share no txid at all
452
+ continue
453
+ cands.append((cov, _lang_score(raw, lang), raw, parsed))
454
+ cands.sort(key=lambda c: (c[0], c[1]), reverse=True) # best coverage, then best language match
455
+ return cands
456
+
457
+
458
+ def _load_field_text(want_txids, lang: str, game=None, zone_id: Optional[int] = None) -> dict:
459
+ """Best-effort ``{txid: MesEntry}`` for a real field's text, read live from the install. Returns ``{}``
460
+ when nothing resolves, so the caller still shows the decoded calls. See :func:`_field_text_blocks`."""
461
+ cands = _field_text_blocks(want_txids, lang, game=game, zone_id=zone_id)
462
+ return cands[0][3] if cands else {}
463
+
464
+
465
+ def extract_field_mes(field, lang: str = "us", game=None, zone_id: Optional[int] = None) -> Optional[str]:
466
+ """The donor field's WHOLE ``.mes`` text body for ``lang`` -- to ship VERBATIM with a verbatim-`.eb` fork
467
+ (docs/FORK_FIDELITY.md) so its index-based txids resolve into it directly (no remap, unlike `--carry-text`
468
+ which appends to authored text). Picks the block via the engine's field-id -> text-block table
469
+ (``EVENT_ID_TO_MES``). Returns ``None`` -- never raises -- when the install/UnityPy can't read it."""
470
+ from ._fieldtext import EVENT_ID_TO_MES
471
+ fid = _resolve_field_id(field)
472
+ if zone_id is None:
473
+ zone_id = EVENT_ID_TO_MES.get(fid)
474
+ cands = _field_text_blocks(None, lang, game=game, zone_id=zone_id)
475
+ # all candidates are the SAME text block in different languages, so pick by LANGUAGE score -- NOT coverage
476
+ # (the default sort prefers the longest block, which silently handed every language the German variant).
477
+ real = [c for c in cands if len(c[2]) > 1000] or cands # the real per-language blocks, not padding stubs
478
+ return max(real, key=lambda c: c[1])[2] if real else None
479
+
480
+
481
+ def extract_field_mes_all_langs(field, game=None, zone_id: Optional[int] = None) -> dict:
482
+ """``{lang: body}`` for EVERY language in ONE scan -- the verbatim fork's text carry, batched. Equivalent
483
+ to :func:`extract_field_mes` per language (same block, same per-language pick) but it resolves the zone's
484
+ blocks once and re-scores them for each lang, instead of a fresh full resources.assets scan per language.
485
+ That collapses a verbatim fork's 7 text scans into 1; across an import-chain (~80 members) it's the
486
+ single biggest fork-speed win. Returns only the languages that resolve to a non-empty body."""
487
+ from ._fieldtext import EVENT_ID_TO_MES
488
+ from .config import LANGS
489
+ fid = _resolve_field_id(field)
490
+ if zone_id is None:
491
+ zone_id = EVENT_ID_TO_MES.get(fid)
492
+ cands = _field_text_blocks(None, "us", game=game, zone_id=zone_id) # lang-agnostic; re-scored per lang below
493
+ real = [c for c in cands if len(c[2]) > 1000] or cands # the real per-language blocks, not padding stubs
494
+ out: dict = {}
495
+ for L in LANGS:
496
+ best = max(real, key=lambda c: _lang_score(c[2], L), default=None)
497
+ if best is not None and best[2]:
498
+ out[L] = best[2]
499
+ return out
500
+
501
+
502
+ def read_field_dialogue(field, lang: str = "us", game=None, zone_id: Optional[int] = None) -> list:
503
+ """Read + join a REAL FF9 field's dialogue, live from the install (needs UnityPy). Decodes the field's
504
+ ``.eb`` for its dialogue calls and resolves the text block ``<zone_id>.mes`` -- ``zone_id`` defaults to
505
+ the engine's own field-map-id -> text-id table (:data:`_fieldtext.EVENT_ID_TO_MES`, i.e. Memoria's
506
+ ``eventIDToMESID``), so the RIGHT block + language is read (txids are 0-based positions every field's text
507
+ shares, so they can't pick the block). Unresolved text stays ``None`` (the calls + txids still show). This
508
+ is the 'import a real field's dialogue to prove plausibility' path."""
509
+ from . import extract
510
+ from ._fieldtext import EVENT_ID_TO_MES
511
+ fid = _resolve_field_id(field)
512
+ eb_bytes = extract.EventBundle(game=game, lang=lang).eb_for_id(fid)
513
+ if eb_bytes is None:
514
+ raise FileNotFoundError(f"no field event script for {field!r} (id {fid}) -- a world/special field?")
515
+ calls = scan_dialogue(eb_bytes)
516
+ txids = [c.txid for c in calls]
517
+ if zone_id is None: # the AUTHORITATIVE field -> text-block id (the engine's
518
+ zone_id = EVENT_ID_TO_MES.get(fid) # own eventIDToMESID); txids alone can't pick the block
519
+ mes_map = _load_field_text(txids, lang, game=game, zone_id=zone_id)
520
+ folder = extract.ID_TO_FBG.get(fid, str(fid))
521
+ # real fields don't use the kit's D9(0)/D9(4) spawn convention -> the (x,z) heuristic is noise here
522
+ return join(calls, mes_map, field_label=folder, trust_positions=False)
523
+
524
+
525
+ def text_source_status(game=None) -> str:
526
+ """A one-line reason the live ``<zone>.mes`` text source can't be read -- the diagnostic
527
+ ``dialogue-import`` prints when a real field's lines come back unresolved. Distinguishes the two
528
+ install/dependency failure modes that make ALL text unresolvable (``UnityPy`` not installed, or the
529
+ game install / ``resources.assets`` not found) from a healthy source. Returns ``"ok"`` when the source
530
+ looks readable -- in which case unresolved txids just mean the field's own ``.mes`` block didn't cover
531
+ them (try ``--zone-id``). Never raises (so the caller can always print it)."""
532
+ from . import extract
533
+ try:
534
+ extract._unitypy()
535
+ except Exception: # noqa: BLE001 -- ImportError/RuntimeError = not installed
536
+ return "UnityPy is not installed (pip install UnityPy), so live game text can't be read"
537
+ try:
538
+ ra = _resources_assets(game)
539
+ except Exception: # noqa: BLE001 -- find_game_path raises if no install
540
+ ra = None
541
+ if ra is None:
542
+ return "the game install (resources.assets) wasn't found -- pass --game <FF9 folder>"
543
+ return "ok"
544
+
545
+
546
+ # ---------------------------------------------------------- read: an authored field.toml ---
547
+ def _iter_txids(obj, prefix=""):
548
+ """Yield ``(label, txid)`` for every int leaf in a collect_text txid map (dict/list, possibly nested --
549
+ a choice maps its prompt + per-option reply ids)."""
550
+ if isinstance(obj, dict):
551
+ items = obj.items()
552
+ elif isinstance(obj, (list, tuple)):
553
+ items = enumerate(obj)
554
+ else:
555
+ return
556
+ for k, v in items:
557
+ lbl = f"{prefix}{k}"
558
+ if isinstance(v, int):
559
+ yield lbl, v
560
+ else:
561
+ yield from _iter_txids(v, prefix=f"{lbl}.")
562
+
563
+
564
+ def project_dialogue(project) -> list:
565
+ """The authored dialogue of a loaded ``field.toml`` (a ``build.FieldProject``), as ordered
566
+ :class:`ViewedLine`s with the FINAL wrapped text -- so the viewer/editor shows exactly what ships. Built
567
+ by running the unchanged :func:`build.collect_text` and parsing its ``.mes`` back, so it can never drift
568
+ from the real build output."""
569
+ from . import build as _build
570
+ mes_body, npc_txids, ev_txids, cs_txids, ch_txids, oe_txids, _ate_txids, _chest_txids = _build.collect_text(project)
571
+ mes = parse_mes(mes_body)
572
+ raw = getattr(project, "raw", {}) or {}
573
+ npcs, events = raw.get("npc", []), raw.get("event", [])
574
+
575
+ label: dict = {} # txid -> (source, who)
576
+ for k, t in _iter_txids(npc_txids):
577
+ i = int(str(k).split(".")[0]) if str(k).split(".")[0].isdigit() else None
578
+ label.setdefault(t, ("npc", _name(npcs, i, f"NPC #{k}")))
579
+ for k, t in _iter_txids(ev_txids):
580
+ i = int(str(k).split(".")[0]) if str(k).split(".")[0].isdigit() else None
581
+ label.setdefault(t, ("event", _name(events, i, f"event #{k}")))
582
+ for k, t in _iter_txids(cs_txids):
583
+ label.setdefault(t, ("cutscene", f"cutscene say {k}"))
584
+ for k, t in _iter_txids(ch_txids):
585
+ label.setdefault(t, ("choice", f"choice {k}"))
586
+ for k, t in _iter_txids(oe_txids):
587
+ label.setdefault(t, ("on_entry", f"on_entry {k}"))
588
+
589
+ out = []
590
+ for txid in sorted(mes):
591
+ src, who = label.get(txid, ("text", f"txid {txid}"))
592
+ e = mes[txid]
593
+ out.append(ViewedLine(src, who, txid, e.text, e.tail, None))
594
+ return out
595
+
596
+
597
+ def _name(lst, i, fallback):
598
+ if i is not None and isinstance(lst, list) and 0 <= i < len(lst):
599
+ return lst[i].get("name") or fallback
600
+ return fallback
601
+
602
+
603
+ # ----------------------------------------------------- read: a whole campaign ---
604
+ @dataclass
605
+ class FieldDialogue:
606
+ """One member field's authored dialogue, for the campaign-wide review. ``error`` is set (and ``lines``
607
+ is empty) when that member's field.toml couldn't be loaded -- a broken member never aborts the review."""
608
+ label: str
609
+ lines: list = _dc_field(default_factory=list) # ViewedLine
610
+ error: Optional[str] = None
611
+
612
+
613
+ def campaign_dialogue(members) -> list:
614
+ """The authored dialogue of every member of a campaign, in member order. ``members`` is an iterable of
615
+ ``(label, project_or_None, error_or_None)`` -- the caller resolves the campaign.toml to loaded
616
+ ``FieldProject``s (keeping the path/sandbox logic out of the spine); this just runs the unchanged
617
+ :func:`project_dialogue` per field, so the campaign view can never drift from the single-field one.
618
+ Returns one :class:`FieldDialogue` per member (a load failure becomes an ``error`` row, not an abort)."""
619
+ out = []
620
+ for label, project, err in members:
621
+ if err or project is None:
622
+ out.append(FieldDialogue(label, [], err or "could not load"))
623
+ else:
624
+ out.append(FieldDialogue(label, project_dialogue(project)))
625
+ return out
626
+
627
+
628
+ # ----------------------------------------------------- editable refs (for the GUI) ---
629
+ @dataclass(frozen=True)
630
+ class TextRef:
631
+ """A pointer to ONE editable dialogue line in a field.toml's data, so a UI can list + edit every line
632
+ in one place without knowing the section shapes. ``path`` locates the text value; ``speaker_path`` /
633
+ ``tail_path`` (when present) locate its sibling speaker name + window tail. The dialogue editor renders
634
+ one of these per row."""
635
+ section: str # 'npc' / 'event' / 'choice' / 'reply' / 'cutscene'
636
+ label: str
637
+ path: tuple
638
+ speaker_path: Optional[tuple] = None
639
+ tail_path: Optional[tuple] = None
640
+
641
+
642
+ def collect_text_refs(data: dict) -> list:
643
+ """Every editable dialogue line in a field.toml ``data`` dict, in author order -- NPC lines, event
644
+ messages, choice prompts + per-option replies, and cutscene ``say`` steps. The unified list the
645
+ dialogue editor edits (placement/structure stays the Logic Editor's; this owns the WORDS)."""
646
+ refs: list = []
647
+ for i, n in enumerate(data.get("npc", []) or []):
648
+ if "dialogue" in n:
649
+ refs.append(TextRef("npc", f"NPC: {n.get('name') or '#' + str(i)}", ("npc", i, "dialogue"),
650
+ ("npc", i, "speaker"), ("npc", i, "tail")))
651
+ for i, e in enumerate(data.get("event", []) or []):
652
+ if "message" in e:
653
+ refs.append(TextRef("event", f"Event: {e.get('name') or '#' + str(i)}", ("event", i, "message"),
654
+ ("event", i, "speaker"), ("event", i, "tail")))
655
+ for i, c in enumerate(data.get("choice", []) or []):
656
+ who = c.get("npc") or ("zone" if "zone" in c else "#" + str(i))
657
+ if "prompt" in c:
658
+ refs.append(TextRef("choice", f"Choice {who}: prompt", ("choice", i, "prompt"),
659
+ ("choice", i, "speaker"), ("choice", i, "tail")))
660
+ for j, o in enumerate(c.get("options", []) or []):
661
+ refs.append(TextRef("reply", f"Choice {who}: reply to “{o.get('text') or '#' + str(j)}”",
662
+ ("choice", i, "options", j, "reply")))
663
+ cs = data.get("cutscene")
664
+ if isinstance(cs, dict):
665
+ for k, st in enumerate(cs.get("steps", []) or []):
666
+ if "say" in st:
667
+ refs.append(TextRef("cutscene", f"Cutscene: say #{k}", ("cutscene", "steps", k, "say")))
668
+ for k, h in enumerate(data.get("on_entry", []) or []):
669
+ if isinstance(h, dict) and "message" in h:
670
+ refs.append(TextRef("on_entry", f"On-entry beat #{k}", ("on_entry", k, "message"),
671
+ ("on_entry", k, "speaker"), ("on_entry", k, "tail")))
672
+ return refs
673
+
674
+
675
+ def get_text(data: dict, path: tuple):
676
+ """The value at ``path`` (a collect_text_refs path) in ``data``, or None if any step is missing."""
677
+ cur = data
678
+ for k in path:
679
+ if isinstance(cur, dict):
680
+ cur = cur.get(k)
681
+ elif isinstance(cur, list) and isinstance(k, int) and 0 <= k < len(cur):
682
+ cur = cur[k]
683
+ else:
684
+ return None
685
+ return cur
686
+
687
+
688
+ def set_text(data: dict, path: tuple, value) -> bool:
689
+ """Set (or, for an empty/None ``value``, REMOVE) the dict-keyed leaf at ``path``. Intermediate list/dict
690
+ steps must already exist (they do for a collect_text_refs path -- only the final key may be absent, e.g.
691
+ adding a ``speaker``/``reply``). Returns True on success."""
692
+ cur = data
693
+ for k in path[:-1]:
694
+ if isinstance(cur, dict):
695
+ cur = cur.get(k)
696
+ elif isinstance(cur, list) and isinstance(k, int) and 0 <= k < len(cur):
697
+ cur = cur[k]
698
+ else:
699
+ return False
700
+ if cur is None:
701
+ return False
702
+ last = path[-1]
703
+ if not isinstance(cur, dict):
704
+ return False
705
+ if value is None or value == "":
706
+ cur.pop(last, None)
707
+ else:
708
+ cur[last] = value
709
+ return True
710
+
711
+
712
+ # ------------------------------------------------------------------- formatting ---
713
+ def wrap_preview(text: str, width=None) -> str:
714
+ """How a line breaks on the FF9 screen (the proportional approximation -- see content.text). Reuses the
715
+ exact build-time wrapper so the preview matches what ships."""
716
+ return _text.wrap_text(text or "", width if width is not None else _text.DEFAULT_WRAP_WIDTH)[0]
717
+
718
+
719
+ def overflow(text: str, width=None) -> list:
720
+ """Final wrapped lines that still exceed ``width`` -- an unbreakable over-wide word. Empty = it fits."""
721
+ return _text.overflow_lines(text or "", width if width is not None else _text.DEFAULT_WRAP_WIDTH)
722
+
723
+
724
+ def flag_overflow(lines, width=None) -> list:
725
+ """The :class:`ViewedLine`s whose final wrapped text still overflows the window (an unbreakable wide
726
+ word) -- the 'check this in-game' set, shared by the single-field and campaign dialogue reviews."""
727
+ return [ln for ln in lines if ln.text and overflow(ln.text, width)]
728
+
729
+
730
+ def format_lines(lines, *, clean: bool = False, show_system: bool = False, dedupe: bool = True) -> str:
731
+ """Render :class:`ViewedLine`s as a readable block (the CLI viewer's output), through :func:`present`
732
+ (hide system windows + de-dupe by default; ``show_system`` / ``dedupe=False`` give the raw view).
733
+ ``clean=True`` strips FF9 control tags from the text for a plain read; otherwise tags are kept verbatim."""
734
+ out = []
735
+ for ln in present(lines, show_system=show_system, dedupe=dedupe):
736
+ meta = []
737
+ meta.append(f"txid {ln.txid}" if ln.txid is not None else "txid <expr>")
738
+ if ln.tail:
739
+ meta.append(f"tail {ln.tail}")
740
+ if ln.pos:
741
+ meta.append(f"@ {ln.pos[0]}, {ln.pos[1]}")
742
+ if ln.system:
743
+ meta.append("system")
744
+ out.append(f"[{ln.source}] {ln.who} ({', '.join(meta)})")
745
+ if ln.text is None:
746
+ out.append(" (text not resolved)")
747
+ else:
748
+ shown = strip_tags(ln.text) if clean else ln.text
749
+ out.extend(" " + part for part in shown.split("\n"))
750
+ out.append("")
751
+ return "\n".join(out).rstrip() + "\n"
752
+
753
+
754
+ # --------------------------------------------------- editable [[npc]] stubs (import --dialogue) ---
755
+ def _toml_str(s: str) -> str:
756
+ """A TOML basic-string literal (escape backslash + quote; the value is single-line by the time it's here)."""
757
+ return '"' + str(s).replace("\\", "\\\\").replace('"', '\\"') + '"'
758
+
759
+
760
+ def _npc_model_ref(model_id) -> Optional[str]:
761
+ """The GEO model NAME for a real NPC's model id IF it renders as a field NPC (so the kit auto-resolves its
762
+ anims), else None -> the stub falls back to a safe preset. Keeps an emitted stub buildable as-is."""
763
+ if model_id is None:
764
+ return None
765
+ try:
766
+ from . import catalog as _cat
767
+ m = _cat.model(model_id)
768
+ if m and getattr(m, "field", False) and _cat.npc_anims(m.id):
769
+ return m.name
770
+ except Exception: # noqa: BLE001 -- a catalog hiccup -> preset fallback
771
+ pass
772
+ return None
773
+
774
+
775
+ def npc_stub_toml(lines, *, field_ref: str = "this field", commented: bool = True) -> str:
776
+ """Emit a real field's NPC dialogue as editable ``[[npc]]`` blocks for the ``import --dialogue``
777
+ re-authoring workflow: one block per distinct NPC-talk line -- a placeholder name, the real model (by
778
+ GEO name when it renders as a field NPC, else a ``vivi`` preset), the line as clean editable text (tags
779
+ stripped, single line -- the kit re-wraps at build), and a ``pos = [0, 0]`` placeholder.
780
+
781
+ ``commented=True`` (the default the importer uses) prefixes every block line with ``# `` so they do NOT
782
+ duplicate the field's verbatim-carried ``[[object]]`` NPCs nor stack live at the origin -- they're a
783
+ ready-to-use re-authoring reference the author uncomments + edits selectively. ``commented=False`` emits
784
+ live blocks (what the GUI's deliberate 'Insert NPC stubs' uses)."""
785
+ npcs = [ln for ln in present(lines) if ln.source == "npc" and ln.text]
786
+ out = [
787
+ f"# === Dialogue imported from {field_ref} ({'editable [[npc]] stubs, COMMENTED' if commented else 'editable [[npc]] stubs'}) ===",
788
+ "# Each block is one real NPC line as a ready [[npc]] -- a starting point for RE-AUTHORING this",
789
+ "# field's script. They PARALLEL the verbatim-carried [[object]] NPCs (object-carry renders those;",
790
+ "# their original text isn't shipped). To use one: " + ("uncomment its lines, " if commented else "")
791
+ + "reposition (pos), tweak the model/preset, rewrite",
792
+ "# the text -- and remove the matching [[object]] if you're replacing it. Full script (tags intact):",
793
+ f"# ff9mapkit dialogue-import {field_ref}",
794
+ ]
795
+ pre = "# " if commented else ""
796
+ for i, ln in enumerate(npcs):
797
+ txt = " ".join(strip_tags(ln.text).split()) # clean + single-line so the kit re-wraps it
798
+ mref = _npc_model_ref(ln.model)
799
+ out.append("")
800
+ out += [pre + b for b in ("[[npc]]", f'name = "imported_{i}"',
801
+ (f"model = {_toml_str(mref)}" if mref else 'preset = "vivi"'),
802
+ f"dialogue = {_toml_str(txt)}", "pos = [0, 0]")]
803
+ return "\n".join(out) + "\n"