ff9mapkit 1.0.0b3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ff9mapkit/__init__.py +18 -0
- ff9mapkit/__main__.py +36 -0
- ff9mapkit/_animdb.py +2994 -0
- ff9mapkit/_animdb_all.py +14125 -0
- ff9mapkit/_fieldtable.py +1516 -0
- ff9mapkit/_fieldtext.py +845 -0
- ff9mapkit/_held_poses.py +44 -0
- ff9mapkit/_itemdb.py +65 -0
- ff9mapkit/_modeldb.py +725 -0
- ff9mapkit/_narrowmap_data.py +10 -0
- ff9mapkit/_npcparams.py +634 -0
- ff9mapkit/_regen_animdb.py +72 -0
- ff9mapkit/_regen_animdb_all.py +66 -0
- ff9mapkit/_regen_fieldtable.py +95 -0
- ff9mapkit/_regen_fieldtext.py +66 -0
- ff9mapkit/_regen_modeldb.py +67 -0
- ff9mapkit/_regen_npcparams.py +123 -0
- ff9mapkit/_regen_scenedb.py +57 -0
- ff9mapkit/_scenedb.py +869 -0
- ff9mapkit/abilities.py +225 -0
- ff9mapkit/animations.py +120 -0
- ff9mapkit/archetypes.py +218 -0
- ff9mapkit/areatitle.py +76 -0
- ff9mapkit/battle/__init__.py +21 -0
- ff9mapkit/battle/abilityfeatures.py +294 -0
- ff9mapkit/battle/actiondelta.py +441 -0
- ff9mapkit/battle/aiauthor.py +305 -0
- ff9mapkit/battle/ailint.py +140 -0
- ff9mapkit/battle/aipatch.py +175 -0
- ff9mapkit/battle/battleai.py +148 -0
- ff9mapkit/battle/battlecsv.py +390 -0
- ff9mapkit/battle/battlepatch.py +395 -0
- ff9mapkit/battle/build.py +558 -0
- ff9mapkit/battle/camera_codec.py +332 -0
- ff9mapkit/battle/camera_data.py +128 -0
- ff9mapkit/battle/characterdelta.py +789 -0
- ff9mapkit/battle/event_data.py +72 -0
- ff9mapkit/battle/extract.py +540 -0
- ff9mapkit/battle/fbx.py +223 -0
- ff9mapkit/battle/reskin.py +149 -0
- ff9mapkit/battle/scene_codec.py +314 -0
- ff9mapkit/battle/scene_data.py +369 -0
- ff9mapkit/battle/scenelint.py +125 -0
- ff9mapkit/battle/seqasm.py +131 -0
- ff9mapkit/battle/seqauthor.py +220 -0
- ff9mapkit/battle/seqcodec.py +300 -0
- ff9mapkit/battle/seqdis.py +106 -0
- ff9mapkit/battle/seqpatch.py +137 -0
- ff9mapkit/battle_bgm.py +133 -0
- ff9mapkit/binutils.py +60 -0
- ff9mapkit/build.py +5445 -0
- ff9mapkit/campaign.py +1276 -0
- ff9mapkit/catalog.py +316 -0
- ff9mapkit/chain.py +358 -0
- ff9mapkit/cli.py +3114 -0
- ff9mapkit/config.py +360 -0
- ff9mapkit/content/__init__.py +13 -0
- ff9mapkit/content/areatitle.py +36 -0
- ff9mapkit/content/ate.py +118 -0
- ff9mapkit/content/camera.py +123 -0
- ff9mapkit/content/chest.py +186 -0
- ff9mapkit/content/choice.py +163 -0
- ff9mapkit/content/conductor.py +217 -0
- ff9mapkit/content/cutscene.py +290 -0
- ff9mapkit/content/encounter.py +41 -0
- ff9mapkit/content/entry_settle.py +50 -0
- ff9mapkit/content/equipment.py +93 -0
- ff9mapkit/content/event.py +191 -0
- ff9mapkit/content/gateway.py +101 -0
- ff9mapkit/content/inventory.py +59 -0
- ff9mapkit/content/itemdata.py +644 -0
- ff9mapkit/content/itemtext.py +168 -0
- ff9mapkit/content/jump.py +114 -0
- ff9mapkit/content/ladder.py +633 -0
- ff9mapkit/content/movement.py +53 -0
- ff9mapkit/content/music.py +97 -0
- ff9mapkit/content/npc.py +348 -0
- ff9mapkit/content/object.py +340 -0
- ff9mapkit/content/onentry.py +135 -0
- ff9mapkit/content/party.py +111 -0
- ff9mapkit/content/pathfind.py +138 -0
- ff9mapkit/content/platform.py +314 -0
- ff9mapkit/content/player.py +168 -0
- ff9mapkit/content/prop.py +75 -0
- ff9mapkit/content/region.py +340 -0
- ff9mapkit/content/reinit.py +59 -0
- ff9mapkit/content/savepoint.py +90 -0
- ff9mapkit/content/shop.py +178 -0
- ff9mapkit/content/sps_trigger.py +66 -0
- ff9mapkit/content/startup.py +71 -0
- ff9mapkit/content/synthesis.py +106 -0
- ff9mapkit/content/text.py +183 -0
- ff9mapkit/content/textcarry.py +290 -0
- ff9mapkit/content/verbatim.py +86 -0
- ff9mapkit/content/walkmesh_hotfix.py +38 -0
- ff9mapkit/data/__init__.py +48 -0
- ff9mapkit/data/_regen_provenance.py +142 -0
- ff9mapkit/data/provenance/blank.es.patch +1 -0
- ff9mapkit/data/provenance/blank.fr.patch +1 -0
- ff9mapkit/data/provenance/blank.gr.patch +1 -0
- ff9mapkit/data/provenance/blank.it.patch +1 -0
- ff9mapkit/data/provenance/blank.jp.patch +1 -0
- ff9mapkit/data/provenance/blank.uk.patch +1 -0
- ff9mapkit/data/provenance/blank.us.patch +1 -0
- ff9mapkit/data/provenance/manifest.json +65 -0
- ff9mapkit/data/provenance/region_template.patch +1 -0
- ff9mapkit/data/reference_arcs.toml +89 -0
- ff9mapkit/data/region_catalog.toml +593 -0
- ff9mapkit/deploystack.py +358 -0
- ff9mapkit/dialogue.py +803 -0
- ff9mapkit/eb/__init__.py +12 -0
- ff9mapkit/eb/_exprtable.py +59 -0
- ff9mapkit/eb/_membertable.py +38 -0
- ff9mapkit/eb/_optables.py +537 -0
- ff9mapkit/eb/_regen_optables.py +76 -0
- ff9mapkit/eb/cmdasm.py +323 -0
- ff9mapkit/eb/disasm.py +332 -0
- ff9mapkit/eb/edit.py +439 -0
- ff9mapkit/eb/exprasm.py +158 -0
- ff9mapkit/eb/model.py +178 -0
- ff9mapkit/eb/opcodes.py +463 -0
- ff9mapkit/eblint.py +177 -0
- ff9mapkit/editor/__init__.py +20 -0
- ff9mapkit/editor/app.py +950 -0
- ff9mapkit/editor/battle_forms.py +240 -0
- ff9mapkit/editor/breadcrumb.py +89 -0
- ff9mapkit/editor/dialogs.py +116 -0
- ff9mapkit/editor/feedback.py +208 -0
- ff9mapkit/editor/forms.py +632 -0
- ff9mapkit/editor/graphview.py +350 -0
- ff9mapkit/editor/jobs.py +342 -0
- ff9mapkit/editor/model.py +243 -0
- ff9mapkit/editor/picker.py +120 -0
- ff9mapkit/editor/theme.py +212 -0
- ff9mapkit/eventscan.py +1441 -0
- ff9mapkit/extract.py +2279 -0
- ff9mapkit/flags.py +693 -0
- ff9mapkit/forkreport.py +1383 -0
- ff9mapkit/hub.py +477 -0
- ff9mapkit/idgated.py +101 -0
- ff9mapkit/infohub.py +580 -0
- ff9mapkit/items.py +63 -0
- ff9mapkit/itemstats.py +346 -0
- ff9mapkit/journey.py +1902 -0
- ff9mapkit/keyitems.py +93 -0
- ff9mapkit/logic_add.py +632 -0
- ff9mapkit/logic_edit.py +728 -0
- ff9mapkit/logic_map.py +526 -0
- ff9mapkit/pack.py +175 -0
- ff9mapkit/playerswap.py +231 -0
- ff9mapkit/prop_archetypes.py +228 -0
- ff9mapkit/provision.py +282 -0
- ff9mapkit/refarc.py +825 -0
- ff9mapkit/save.py +337 -0
- ff9mapkit/save_items.py +1673 -0
- ff9mapkit/scene/__init__.py +11 -0
- ff9mapkit/scene/arena.py +63 -0
- ff9mapkit/scene/bgart.py +140 -0
- ff9mapkit/scene/bgi.py +732 -0
- ff9mapkit/scene/bgs.py +174 -0
- ff9mapkit/scene/bgx.py +185 -0
- ff9mapkit/scene/cam.py +345 -0
- ff9mapkit/scene/guide.py +311 -0
- ff9mapkit/scene/paint.py +506 -0
- ff9mapkit/scene/placeholder.py +107 -0
- ff9mapkit/sjbinary.py +285 -0
- ff9mapkit/sps/__init__.py +17 -0
- ff9mapkit/sps/author.py +294 -0
- ff9mapkit/sps/catalog.py +88 -0
- ff9mapkit/sps/codec.py +264 -0
- ff9mapkit/sps/edit.py +184 -0
- ff9mapkit/sps/lint.py +58 -0
- ff9mapkit/sps/render.py +116 -0
- ff9mapkit/sps/templates.py +47 -0
- ff9mapkit/sps/texture.py +131 -0
- ff9mapkit/walkmesh_hotfixes.py +163 -0
- ff9mapkit/workspace/__init__.py +18 -0
- ff9mapkit/workspace/battledoc.py +985 -0
- ff9mapkit/workspace/builddoc.py +607 -0
- ff9mapkit/workspace/forms_qt.py +586 -0
- ff9mapkit/workspace/importdoc.py +665 -0
- ff9mapkit/workspace/mapview.py +131 -0
- ff9mapkit/workspace/palette.py +85 -0
- ff9mapkit/workspace/savedoc.py +664 -0
- ff9mapkit/workspace/shell.py +6907 -0
- ff9mapkit/workspace/style.py +105 -0
- ff9mapkit/workspace/tuningdialog.py +223 -0
- ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
- ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
- ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
- ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
- ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
- ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
ff9mapkit/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"
|