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
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""``[[shop]]`` -- author a custom shop: its INVENTORY (a ``ShopItems.csv`` delta) + an OPENER.
|
|
2
|
+
|
|
3
|
+
Two channels, like every item feature on this branch:
|
|
4
|
+
|
|
5
|
+
* **Inventory (data).** A custom shop's stock is a row in ``<mod>/StreamingAssets/Data/Items/ShopItems.csv``
|
|
6
|
+
-- ``Comment;Id;item1, item2, ...``. The engine **MERGES** these by id low->high (``ff9buy.LoadShopItems``
|
|
7
|
+
via ``EnumerateCsvFromLowToHigh`` -> ``result[shop.Id] = shop``), so a PARTIAL delta (just the custom shops)
|
|
8
|
+
works: the base file supplies shops 0-31 (the engine's ``>= 32`` guard is satisfied by the base), and our
|
|
9
|
+
delta adds the new ids. ★ A custom shop id must be **>= 32** (0-31 are the base shops; a clash OVERRIDES the
|
|
10
|
+
vanilla shop -- allowed but lint-warned). The id is also the ``Menu`` sub-id (one byte), so it is **<= 255**.
|
|
11
|
+
|
|
12
|
+
* **Opener (.eb).** A shop opens with ``Menu(2, shopId)`` (0x75 -> ``EventService.FF9Menu_Command`` case 2u ->
|
|
13
|
+
``OpenShopMenu``) -- the same opcode family as the save point (``Menu(4, 0)``). Two shapes:
|
|
14
|
+
|
|
15
|
+
- a **shopkeeper NPC** -- ``[[npc]] opens_shop = N`` builds a tag-3 talk body that (optionally greets, then)
|
|
16
|
+
opens the shop; reuses :mod:`content.npc`'s ``speak_body`` slot. ``opens_shop`` may point at a VANILLA shop
|
|
17
|
+
(0-31) too (e.g. open Dali's weapon shop). This is the authentic "talk to the merchant" UX.
|
|
18
|
+
- a **standalone press-region** -- ``[[shop]] zone = [...]`` mints a press-to-interact region (the save-point
|
|
19
|
+
shape: Init ``SetRegion`` / tread ``Bubble`` / action ``DisableMove; Menu(2, id); EnableMove``) so you can
|
|
20
|
+
walk up to a counter and open the shop with no NPC. Place a cosmetic ``[[npc]]``/``[[prop]]`` over it for the
|
|
21
|
+
visible merchant, exactly like the save moogle.
|
|
22
|
+
|
|
23
|
+
The inventory CSV is mod-global (one ``ShopItems.csv`` per mod, collected across every built field's ``[[shop]]``
|
|
24
|
+
blocks); the opener is per-field ``.eb``. (memory project-ff9-items-equipment / project-ff9-branch-lanes.)
|
|
25
|
+
|
|
26
|
+
[[shop]]
|
|
27
|
+
id = 40
|
|
28
|
+
comment = "Hut Item Shop"
|
|
29
|
+
sells = ["Potion", "Hi-Potion", "Phoenix Down", "Tent", "Ether"]
|
|
30
|
+
# optional standalone opener (else open it from an NPC):
|
|
31
|
+
zone = [[-400, -900], [400, -900], [400, -500], [-400, -500]]
|
|
32
|
+
|
|
33
|
+
[[npc]]
|
|
34
|
+
name = "Shopkeeper"
|
|
35
|
+
pos = [0, -700]
|
|
36
|
+
dialogue = "Welcome! Care to buy something?"
|
|
37
|
+
opens_shop = 40
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import struct
|
|
42
|
+
|
|
43
|
+
from .. import items as _items
|
|
44
|
+
from ..eb import EbScript, edit, opcodes
|
|
45
|
+
from . import region as _region
|
|
46
|
+
|
|
47
|
+
SHOP_MENU_ID = 2 # FF9Menu_Command case 2u -> EventService.OpenShopMenu
|
|
48
|
+
FIRST_CUSTOM_SHOP = 32 # base game ships shops 0-31; a custom shop must be >= 32 (0-31 are vanilla)
|
|
49
|
+
MAX_SHOP_ID = 255 # the Menu sub-id is a single byte, so a shop id is <= 255
|
|
50
|
+
NO_ITEM = 255 # terminates a shop's item list (ShopItems.ParseEntry stops at NoItem)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# --- opener bodies ---------------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def open_shop(shop_id: int) -> bytes:
|
|
56
|
+
"""The single op that opens shop ``shop_id``: ``Menu(2, shop_id)``."""
|
|
57
|
+
return opcodes.menu(SHOP_MENU_ID, int(shop_id))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def shop_speak_body(shop_id: int, *, greeting_txid: int | None = None) -> bytes:
|
|
61
|
+
"""A shopkeeper NPC's tag-3 talk body: (optional greeting window ->) open the shop -> RETURN. The talk
|
|
62
|
+
already halts the player, so -- unlike the press-region -- no ``DisableMove`` bracket is needed."""
|
|
63
|
+
body = b""
|
|
64
|
+
if greeting_txid is not None:
|
|
65
|
+
body += opcodes.window_sync(1, 128, int(greeting_txid))
|
|
66
|
+
return body + open_shop(shop_id) + opcodes.RETURN
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def shop_dispatch(shop_id: int) -> bytes:
|
|
70
|
+
"""The press-region action body: ``DisableMove; Menu(2, id); EnableMove; RETURN`` -- locks control while
|
|
71
|
+
the shop UI is up (so the player can't walk out from under it) and restores it after. Mirrors
|
|
72
|
+
:func:`content.savepoint.save_dispatch`, with the shop menu in place of the save menu."""
|
|
73
|
+
return (opcodes.DISABLE_MOVE
|
|
74
|
+
+ open_shop(shop_id)
|
|
75
|
+
+ opcodes.ENABLE_MOVE + opcodes.RETURN)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _assemble_entry(funcs) -> bytes:
|
|
79
|
+
"""Assemble a type-1 (region) entry from ``[(tag, body), ...]`` -- the func table (``<tag:u16><fpos:u16>``
|
|
80
|
+
each) then the concatenated bodies. Same layout as :func:`content.savepoint._assemble_entry`."""
|
|
81
|
+
table = b""
|
|
82
|
+
pos = len(funcs) * 4
|
|
83
|
+
for tag, body in funcs:
|
|
84
|
+
table += struct.pack("<HH", tag, pos)
|
|
85
|
+
pos += len(body)
|
|
86
|
+
return bytes([_region.REGION_ENTRY_TYPE, len(funcs)]) + table + b"".join(b for _, b in funcs)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def shop_region(zone, shop_id: int, *, bubble: bool = True) -> bytes:
|
|
90
|
+
"""A type-1 region entry that opens a shop: Init ``SetRegion(zone)`` / tread (tag 2) ``Bubble(1)`` (the
|
|
91
|
+
floating "!" prompt, if ``bubble``) / action (tag 3) :func:`shop_dispatch`. Both trigger funcs are gated
|
|
92
|
+
by :data:`content.region.MOVEMENT_GATE` (fire only while ``usercontrol == 1``), like every real region."""
|
|
93
|
+
init = _region.set_region([tuple(p) for p in zone]) + opcodes.RETURN
|
|
94
|
+
tread = _region.MOVEMENT_GATE + (opcodes.bubble(1) if bubble else b"") + opcodes.RETURN
|
|
95
|
+
action = _region.MOVEMENT_GATE + shop_dispatch(shop_id)
|
|
96
|
+
funcs = [(0, init), (_region.RANGE_TAG, tread), (_region.INTERACT_TAG, action)]
|
|
97
|
+
return _assemble_entry(funcs)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def inject_shop_region(data, zone, shop_id: int, *, bubble: bool = True, activate: bool = True):
|
|
101
|
+
"""Inject one standalone shop opener: append a shop region at the next free slot and arm it
|
|
102
|
+
(``InitRegion`` in Main_Init). Returns ``(new_bytes, region_slot)``."""
|
|
103
|
+
eb = EbScript.from_bytes(data)
|
|
104
|
+
slot = eb.first_free_slot()
|
|
105
|
+
data = edit.append_entry(data, slot, shop_region(zone, shop_id, bubble=bubble))
|
|
106
|
+
if activate:
|
|
107
|
+
data = edit.activate(data, opcodes.init_region(slot, 0))
|
|
108
|
+
return data, slot
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def inject_shop_regions(data, shops, *, activate: bool = True):
|
|
112
|
+
"""Inject a standalone press-region for every ``[[shop]]`` that carries a ``zone``. Returns
|
|
113
|
+
``(new_bytes, [slot, ...])``. Shops without a ``zone`` (opened from an NPC instead) are skipped."""
|
|
114
|
+
slots = []
|
|
115
|
+
for sh in shops:
|
|
116
|
+
if not sh.get("zone"):
|
|
117
|
+
continue
|
|
118
|
+
data, slot = inject_shop_region(data, sh["zone"], int(sh["id"]),
|
|
119
|
+
bubble=bool(sh.get("bubble", True)), activate=activate)
|
|
120
|
+
slots.append(slot)
|
|
121
|
+
return data, slots
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# --- inventory CSV ---------------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def safe_comment(text: str, sid: int) -> str:
|
|
127
|
+
"""Make the CSV column-0 label delimiter-safe. The comment is free author text but column 0 of a
|
|
128
|
+
semicolon-delimited row whose Id is column 1, so a stray delimiter mis-parses or DROPS the shop:
|
|
129
|
+
a ``;`` splits the row (the engine reads the wrong Id column), a leading ``#`` makes the engine's
|
|
130
|
+
CsvReader skip the whole line (the shop silently never loads), and a newline breaks the row. We
|
|
131
|
+
neutralise all three (``;`` -> ``,``, newlines -> space, strip a leading ``#``); the label is cosmetic
|
|
132
|
+
(the shop is keyed by Id), so this is lossless to behaviour. Empty -> the default ``Shop NNNN`` label."""
|
|
133
|
+
text = str(text).replace("\r", " ").replace("\n", " ").replace(";", ",").strip().lstrip("#").strip()
|
|
134
|
+
return text or f"Shop {sid:04d}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def shop_rows(shops) -> list:
|
|
138
|
+
"""``[{id, sells, comment}, ...]`` -> ``[(id, [item_id, ...], comment), ...]`` sorted by id.
|
|
139
|
+
|
|
140
|
+
Item names/ids resolved via :func:`items.resolve`; ``NoItem`` (255) dropped (it would terminate the
|
|
141
|
+
shop's list); duplicate items within a shop collapsed (order-preserving, first wins). A duplicate SHOP id
|
|
142
|
+
keeps the last definition (last wins -- the engine's own merge rule); the build lints the duplicate."""
|
|
143
|
+
by_id: dict = {}
|
|
144
|
+
for sh in shops:
|
|
145
|
+
sid = int(sh["id"])
|
|
146
|
+
seen, ids = set(), []
|
|
147
|
+
for entry in sh.get("sells", []):
|
|
148
|
+
iid = _items.resolve(entry)
|
|
149
|
+
if iid == NO_ITEM or iid in seen:
|
|
150
|
+
continue
|
|
151
|
+
seen.add(iid)
|
|
152
|
+
ids.append(iid)
|
|
153
|
+
by_id[sid] = (ids, safe_comment(sh.get("comment") or "", sid))
|
|
154
|
+
return [(sid, ids, comment) for sid, (ids, comment) in sorted(by_id.items())]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def render_shop_items(shops) -> str:
|
|
158
|
+
"""The ``ShopItems.csv`` delta text (legend + one ``Comment;Id;items;# names`` row per custom shop). A
|
|
159
|
+
partial delta -- the engine merges it over the base by id, so only the custom shops are listed."""
|
|
160
|
+
lines = [
|
|
161
|
+
"# ff9mapkit [[shop]] -- custom shop inventories (ShopItems.csv delta; the engine MERGES by id over the",
|
|
162
|
+
"# base, which supplies shops 0-31). Custom shop ids are >= 32.",
|
|
163
|
+
"# Comment;Id;Items",
|
|
164
|
+
"# ;Int32;Int32[]",
|
|
165
|
+
]
|
|
166
|
+
for sid, ids, comment in shop_rows(shops):
|
|
167
|
+
names = ", ".join(_items.name_of(i) or str(i) for i in ids)
|
|
168
|
+
items_csv = ", ".join(str(i) for i in ids)
|
|
169
|
+
lines.append(f"{comment};{sid};{items_csv}" + (f";# {names}" if names else ""))
|
|
170
|
+
return "\n".join(lines) + "\n"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def write_shop_items(layout, shops) -> None:
|
|
174
|
+
"""Pure writer: emit the shop-inventory delta into ``layout``'s mod root
|
|
175
|
+
(``Data/Items/ShopItems.csv``)."""
|
|
176
|
+
path = layout.shop_items_csv
|
|
177
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
path.write_text(render_shop_items(shops), encoding="utf-8", newline="\n")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Emit the ``RunSPSCode`` create+place trigger for an authored ``[[sps]]`` effect (Tier 2 creator).
|
|
2
|
+
|
|
3
|
+
A new effect's ``<id>.sps.bytes`` is inert until the field's ``.eb`` fires ``RunSPSCode(slot, 130, id, 0, 0)``
|
|
4
|
+
to LOAD it, then positions + animates it. This injects that sequence as a standalone ``InitCode`` code-entry
|
|
5
|
+
in Main_Init -- the SAME proven arming as :func:`ff9mapkit.content.onentry.inject_on_entries`, so the effect
|
|
6
|
+
spawns at field load (post-fade) on BOTH the synthesize and verbatim-fork paths.
|
|
7
|
+
|
|
8
|
+
Op sequence per effect (confirmed against ``CommonSPSSystem.SetObjParm``):
|
|
9
|
+
``RunSPSCode(slot, 130, id, 0, 0)`` LOAD (Arg1==0 -> per-scene FieldMaps/<scene>/<id>.sps + the in-VRAM tcb)
|
|
10
|
+
``RunSPSCode(slot, 131, 3, 1, 0)`` ATTR set VISIBLE|UPDATE_ANY_FRAME
|
|
11
|
+
``RunSPSCode(slot, 135, x, y, z)`` POS -- the engine NEGATES Arg1 (Y) itself, so emit raw +Y
|
|
12
|
+
``RunSPSCode(slot, 156, abr, 0, 0)`` ABR blend (optional) 0=50%add 1=add 2=sub 3=25%add
|
|
13
|
+
``RunSPSCode(slot, 160, rate, 0, 0)`` FRAMERATE (optional) 16 = 1x
|
|
14
|
+
``RunSPSCode(slot, 145, scale, 0, 0)``SCALE (optional) 4096 = 1x
|
|
15
|
+
|
|
16
|
+
Opcode ``0xB3 RunSPSCode`` (operand widths ``[1,1,2,2,2]`` from ``eb/_optables.py``). Byte-identical when
|
|
17
|
+
no specs. -> [[project-ff9-sps-authoring]], docs/SPS.md.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import struct
|
|
22
|
+
|
|
23
|
+
from ..eb import EbScript, edit, opcodes
|
|
24
|
+
|
|
25
|
+
# parmType constants (Global/SPS/SPSConst.cs)
|
|
26
|
+
SPS_LOAD = 130
|
|
27
|
+
SPS_ATTR = 131
|
|
28
|
+
SPS_POS = 135
|
|
29
|
+
SPS_SCALE = 145
|
|
30
|
+
SPS_ABR = 156
|
|
31
|
+
SPS_FRAMERATE = 160
|
|
32
|
+
ATTR_VISIBLE_UPDATE = 3 # ATTR_VISIBLE(1) | ATTR_UPDATE_ANY_FRAME(2)
|
|
33
|
+
_RUN_SPS = 0xB3
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def sps_trigger_ops(*, slot: int, sps_id: int, pos=(0, 0, 0),
|
|
37
|
+
abr: int | None = None, framerate: int | None = None, scale: int | None = None) -> bytes:
|
|
38
|
+
"""The create+place op sequence for one effect (no entry/RETURN wrapper)."""
|
|
39
|
+
x, y, z = pos
|
|
40
|
+
ops = opcodes.encode(_RUN_SPS, slot, SPS_LOAD, sps_id, 0, 0)
|
|
41
|
+
ops += opcodes.encode(_RUN_SPS, slot, SPS_ATTR, ATTR_VISIBLE_UPDATE, 1, 0)
|
|
42
|
+
ops += opcodes.encode(_RUN_SPS, slot, SPS_POS, x, y, z) # engine negates Y -> emit raw +Y
|
|
43
|
+
if scale is not None:
|
|
44
|
+
ops += opcodes.encode(_RUN_SPS, slot, SPS_SCALE, scale, 0, 0)
|
|
45
|
+
if abr is not None:
|
|
46
|
+
ops += opcodes.encode(_RUN_SPS, slot, SPS_ABR, abr, 0, 0)
|
|
47
|
+
if framerate is not None:
|
|
48
|
+
ops += opcodes.encode(_RUN_SPS, slot, SPS_FRAMERATE, framerate, 0, 0)
|
|
49
|
+
return ops
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def inject_sps_triggers(data, specs, *, spawn_wait_n: int = 2, spawn_wait_occurrence: int = 0):
|
|
53
|
+
"""Inject the create+place triggers for every authored effect as ONE standalone ``InitCode`` code-entry
|
|
54
|
+
(all specs share one Main_Init arm -- one ``Wait`` filler consumed, vs one per effect). ``specs`` is a list
|
|
55
|
+
of :func:`sps_trigger_ops` kwarg dicts. Returns new ``.eb`` bytes; a no-op when ``specs`` is empty."""
|
|
56
|
+
specs = list(specs)
|
|
57
|
+
out = data if isinstance(data, (bytes, bytearray)) else data.to_bytes()
|
|
58
|
+
if not specs:
|
|
59
|
+
return out
|
|
60
|
+
body = b"".join(sps_trigger_ops(**s) for s in specs) + opcodes.RETURN
|
|
61
|
+
entry = bytes([0x00, 0x01]) + struct.pack("<HH", 0, 4) + body
|
|
62
|
+
entry_slot = EbScript.from_bytes(out).first_free_slot()
|
|
63
|
+
out = edit.append_entry(out, entry_slot, entry)
|
|
64
|
+
out = edit.activate(out, opcodes.init_code(entry_slot, 0),
|
|
65
|
+
spawn_wait_n=spawn_wait_n, spawn_wait_occurrence=spawn_wait_occurrence)
|
|
66
|
+
return out
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Field-entry STORY-STATE presets -- the ``[startup]`` block.
|
|
2
|
+
|
|
3
|
+
A forked field boots with a **zero ``gEventGlobal``**, so every story-gated NPC / door / event / dialogue
|
|
4
|
+
takes the not-yet-happened branch and the field plays in its scenario-zero state. ``[startup]`` lets the
|
|
5
|
+
author **assert the story beat the forked field represents**: set the ScenarioCounter and/or specific
|
|
6
|
+
``gEventGlobal`` story bits, unconditionally, at field load. It is the first lever toward "fork a real story
|
|
7
|
+
field and have it boot in the right beat" (see ``docs/FORK_FIDELITY.md`` #1).
|
|
8
|
+
|
|
9
|
+
The presets run **first in Main_Init** (prepended to entry-0 tag-0) so every gate evaluated afterwards --
|
|
10
|
+
region triggers, gated NPCs/doors, conditional content -- sees the asserted state. They re-assert on **every
|
|
11
|
+
field entry** (idempotent beat assertion): right for a fork that stands for one beat. For a chain, put
|
|
12
|
+
``[startup]`` on the ENTRY field only and advance the story with gateway-side writes (a separate feature).
|
|
13
|
+
|
|
14
|
+
Grounded entirely in :mod:`ff9mapkit.content.region`'s byte-for-byte primitives: a story bit is
|
|
15
|
+
``set_var(GLOB_BOOL, idx, 0|1)``; the ScenarioCounter is the save-backed UInt16 at ``gEventGlobal`` byte 0
|
|
16
|
+
(the engine's ``SC_COUNTER`` token ``0xDC``), set via ``set_var(GLOB_UINT16, 0, value)``. Author-side only --
|
|
17
|
+
no extraction; the author asserts the beat (they have the game knowledge).
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from . import region as _region
|
|
22
|
+
from ..eb import edit
|
|
23
|
+
|
|
24
|
+
SCENARIO_BYTE = 0 # ScenarioCounter = the save-backed UInt16 at gEventGlobal byte 0 (token 0xDC)
|
|
25
|
+
SCENARIO_MAX = 32767 # set_var packs a signed int16; every real beat (<= 12000) fits with margin
|
|
26
|
+
WORD_BYTE_MAX = 2046 # a UInt16 word at byte N spans gEventGlobal[N..N+1]; the heap is 2048 bytes
|
|
27
|
+
WORD_VALUE_MAX = 0xFFFF
|
|
28
|
+
BYTE_BYTE_MAX = 2047 # a single byte at byte N occupies gEventGlobal[N] only; the heap is 2048 bytes
|
|
29
|
+
BYTE_VALUE_MAX = 0xFF
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def startup_body(presets, scenario=None, words=(), byte_writes=()) -> bytes:
|
|
33
|
+
"""The Main_Init preset sequence (the bare bytecode, no entry/return wrapper -- it is prepended INTO
|
|
34
|
+
Main_Init). ``scenario`` (int, or None) sets the ScenarioCounter; ``presets`` is an iterable of
|
|
35
|
+
``(bit_index, value)`` story-bit pairs (truthy -> set, falsy -> clear). Two width-distinct word levers:
|
|
36
|
+
|
|
37
|
+
- ``words``: ``(byte_index, value)`` pairs writing a save-backed **UInt16** to ``gEventGlobal[byte_index]``
|
|
38
|
+
-- a 16-bit value spanning bytes ``[N, N+1]`` (the lever for a 16-bit mask the scenario counter doesn't
|
|
39
|
+
cover, e.g. the **ATE-availability bitmask at byte 236**; see docs/ATE_SYSTEM.md). ⚠ Because it is two
|
|
40
|
+
bytes, a UInt16 write to ``N`` also sets byte ``N+1`` (to ``value >> 8``) -- so ``value < 256`` ZEROES
|
|
41
|
+
the neighbour. To set a single byte without touching its neighbour, use ``byte_writes``.
|
|
42
|
+
- ``byte_writes``: ``(byte_index, value)`` pairs writing a save-backed **single byte** (0..255) to
|
|
43
|
+
``gEventGlobal[byte_index]`` ONLY -- no neighbour clobber. The right lever for adjacent independent
|
|
44
|
+
config bytes (e.g. the Pandemonium lift pair byte361=4 + byte362=6).
|
|
45
|
+
|
|
46
|
+
Writes run scenario -> words -> byte_writes -> bits, so a later, narrower write refines an earlier wider
|
|
47
|
+
one (a ``byte`` can fix one byte of a seeded ``word``; a ``flag`` can refine one bit). Returns ``b""`` when
|
|
48
|
+
there is nothing to preset (so a field with no ``[startup]`` stays byte-identical)."""
|
|
49
|
+
out = b""
|
|
50
|
+
if scenario is not None:
|
|
51
|
+
out += _region.set_var(_region.GLOB_UINT16, SCENARIO_BYTE, int(scenario))
|
|
52
|
+
for byte_idx, value in words:
|
|
53
|
+
out += _region.set_var(_region.GLOB_UINT16, int(byte_idx), int(value) & WORD_VALUE_MAX)
|
|
54
|
+
for byte_idx, value in byte_writes:
|
|
55
|
+
out += _region.set_var(_region.GLOB_BYTE, int(byte_idx), int(value) & BYTE_VALUE_MAX)
|
|
56
|
+
for idx, val in presets:
|
|
57
|
+
out += _region.set_var(_region.GLOB_BOOL, int(idx), 1 if val else 0)
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def inject_startup(eb, presets, scenario=None, words=(), byte_writes=()) -> bytes:
|
|
62
|
+
"""Prepend the preset sequence to **Main_Init** (entry 0, tag 0) so it runs first at field load.
|
|
63
|
+
|
|
64
|
+
Byte-safe: inserting at function offset 0 can never be straddled by one of the function's own jumps,
|
|
65
|
+
and :func:`ff9mapkit.eb.edit.insert_in_function` fixes every entry/func table offset. A no-op (returns
|
|
66
|
+
the input bytes unchanged) when there is nothing to preset -- so a field without ``[startup]`` builds
|
|
67
|
+
byte-for-byte as before."""
|
|
68
|
+
body = startup_body(presets, scenario, words, byte_writes)
|
|
69
|
+
if not body:
|
|
70
|
+
return bytes(eb) if isinstance(eb, (bytes, bytearray)) else eb.to_bytes()
|
|
71
|
+
return edit.insert_in_function(eb, 0, 0, 0, body)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""``[[synthesis]]`` -- author a custom SYNTHESIS shop: recipes (a ``Synthesis.csv`` delta) + the SAME
|
|
2
|
+
``Menu(2, id)`` opener as :mod:`content.shop`.
|
|
3
|
+
|
|
4
|
+
A synthesis shop combines INGREDIENT items + gil -> a RESULT item. Two engine facts make it a pure data patch
|
|
5
|
+
(no DLL), grounded in the Memoria source:
|
|
6
|
+
|
|
7
|
+
* **A shop id opens as a SYNTHESIS shop iff it is NOT present in ``ShopItems.csv``** (``ff9buy.FF9Buy_GetType``:
|
|
8
|
+
a missing id returns ``ShopType.Synthesis``; an id in ``ShopItems`` is a Buy shop). So a synth shop reuses the
|
|
9
|
+
``[[shop]]`` opener VERBATIM -- ``Menu(2, id)`` (``EventService.OpenShopMenu``) -- and the synth shop id must
|
|
10
|
+
NOT also be a ``[[shop]]`` id (that would add it to ``ShopItems.csv`` and flip it to a BUY shop).
|
|
11
|
+
* **A shop's recipes = every ``Synthesis.csv`` row whose ``Shops`` list contains the shop id**
|
|
12
|
+
(``ShopUI.InitializeMixList``). ``Synthesis.csv`` (``FF9MIX_DATA``) = ``Comment;Id;Shops;Price;Result;Ingredients``
|
|
13
|
+
with ``#! UseShopList`` (so ``Shops`` parses as an ``Int32[]``); MERGED by Id low->high, whole-row
|
|
14
|
+
(``ff9mix.LoadSynthesis`` via ``EnumerateCsvFromLowToHigh``). The kit MINTS recipe ids ABOVE the base max (63)
|
|
15
|
+
so a delta only ADDS recipes, never clobbers a base one. Base rows are read LIVE from the install (cp1252),
|
|
16
|
+
delta generated at build time -> the repo commits NO game data (same stance as :mod:`content.itemdata`).
|
|
17
|
+
|
|
18
|
+
The recipe CSV is mod-global (one ``Synthesis.csv`` per mod, recipes collected across every built field's
|
|
19
|
+
``[[synthesis]]`` blocks); the opener is per-field ``.eb`` (reused from :mod:`content.shop` -- ``Menu(2, id)`` is
|
|
20
|
+
byte-identical to a buy shop's, the engine decides Buy-vs-Synthesis from the id alone).
|
|
21
|
+
|
|
22
|
+
[[synthesis]]
|
|
23
|
+
shop = 40 # the synth-shop id (NOT a [[shop]] buy id; 32..255)
|
|
24
|
+
recipes = [
|
|
25
|
+
{ result = "Butterfly Sword", ingredients = ["Dagger", "Mage Masher"], price = 300 },
|
|
26
|
+
{ result = "The Ogre", ingredients = ["Mage Masher", "Mage Masher"], price = 700 },
|
|
27
|
+
]
|
|
28
|
+
# optional standalone opener (else open it from an NPC with opens_shop = 40):
|
|
29
|
+
zone = [[-400, -900], [400, -900], [400, -500], [-400, -500]]
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from .. import items as _items
|
|
34
|
+
from . import shop as _shop
|
|
35
|
+
from .itemdata import read_base_csv, _read_text, CSV_ENCODING
|
|
36
|
+
|
|
37
|
+
PRICE_CAP = 9_999_999 # gil cap (UInt32 Price; a cost above the holdable gil cap is pointless)
|
|
38
|
+
NO_ITEM = 255 # NoItem -- meaningless as a result/ingredient (the engine skips it when counting)
|
|
39
|
+
FIRST_SYNTH_SHOP = _shop.FIRST_CUSTOM_SHOP # >= 32: ids 0-31 are base BUY shops (in ShopItems) -> never Synthesis
|
|
40
|
+
MAX_SHOP_ID = _shop.MAX_SHOP_ID # <= 255: the Menu(2, id) sub-id is a single byte
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def base_max_id(base_text: str) -> int:
|
|
44
|
+
"""The highest recipe Id in the base ``Synthesis.csv`` (so a mint lands ABOVE every base recipe); -1 if none."""
|
|
45
|
+
_h, _cols, _idc, rows = read_base_csv(base_text)
|
|
46
|
+
return max(rows, default=-1)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def recipe_rows(synth_blocks, base_text) -> list:
|
|
50
|
+
"""``[(id, shop, price, result, [ingredient_id, ...], comment), ...]`` for every recipe across all
|
|
51
|
+
``[[synthesis]]`` blocks -- recipe ids MINTED above the base max (deterministic: block order, then recipe
|
|
52
|
+
order). Result/ingredient names resolved via :func:`items.resolve`; ``NoItem`` dropped from ingredients
|
|
53
|
+
(it is meaningless); a recipe with no real result or no real ingredient is SKIPPED here (lint flags it)."""
|
|
54
|
+
mint = base_max_id(base_text) + 1
|
|
55
|
+
out = []
|
|
56
|
+
for b in synth_blocks:
|
|
57
|
+
shop = int(b["shop"])
|
|
58
|
+
for r in b.get("recipes", []):
|
|
59
|
+
result = _items.resolve(r["result"])
|
|
60
|
+
ingredients = []
|
|
61
|
+
for entry in r.get("ingredients", []):
|
|
62
|
+
iid = _items.resolve(entry)
|
|
63
|
+
if iid != NO_ITEM: # NoItem ingredient = no-op (skip; keep dups -- need N)
|
|
64
|
+
ingredients.append(iid)
|
|
65
|
+
if result == NO_ITEM or not ingredients:
|
|
66
|
+
continue
|
|
67
|
+
price = max(0, min(PRICE_CAP, int(r.get("price", 0))))
|
|
68
|
+
comment = _shop.safe_comment(_items.name_of(result) or f"Recipe {mint}", mint)
|
|
69
|
+
out.append((mint, shop, price, result, ingredients, comment))
|
|
70
|
+
mint += 1
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def render_synthesis(synth_blocks, base_text) -> str:
|
|
75
|
+
"""The ``Synthesis.csv`` delta text: the base header block VERBATIM (so ``#! UseShopList`` + the legend parse
|
|
76
|
+
identically -> ``Shops`` reads as an ``Int32[]``) + one minted recipe row per recipe. A partial delta -- the
|
|
77
|
+
engine merges it over the base by Id, so only the new recipes are listed. ``Comment;Id;Shops;Price;Result;
|
|
78
|
+
Ingredients`` (the Comment cell is the result's name, delimiter-sanitised; no trailing comment -- the base
|
|
79
|
+
rows have none, and a trailing ``#``-cell would be truncated away by ``CsvReader`` (``Array.Resize`` at the
|
|
80
|
+
first ``#``-prefixed cell, before ``ParseEntry`` sees the row), so it is pointless rather than harmful)."""
|
|
81
|
+
header, _cols, _idc, _rows = read_base_csv(base_text)
|
|
82
|
+
banner = ("# ff9mapkit [[synthesis]] -- custom recipes (Synthesis.csv delta; MERGED by id over the base). "
|
|
83
|
+
"Minted ids are above the base max; Shops = the synth-shop id you open with Menu(2, id).")
|
|
84
|
+
lines = [header, banner]
|
|
85
|
+
for rid, shop, price, result, ingredients, comment in recipe_rows(synth_blocks, base_text):
|
|
86
|
+
ingr = ", ".join(str(i) for i in ingredients)
|
|
87
|
+
lines.append(f"{comment};{rid};{shop};{price};{result};{ingr}")
|
|
88
|
+
return "\n".join(lines) + "\n"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def write_synthesis(layout, synth_blocks, *, game=None) -> None:
|
|
92
|
+
"""Emit the synthesis-recipe delta into ``layout``'s mod root (``Data/Items/Synthesis.csv``). Reads the base
|
|
93
|
+
rows from the install (raises a clear ValueError if it isn't reachable -- the delta needs the base header +
|
|
94
|
+
max id). No blocks -> nothing written (no base clobber)."""
|
|
95
|
+
if not synth_blocks:
|
|
96
|
+
return
|
|
97
|
+
from ..config import find_game_path, ConfigError
|
|
98
|
+
try: # ConfigError (no resolvable install) is a RuntimeError,
|
|
99
|
+
base = find_game_path(game) / "StreamingAssets" / "Data" / "Items" / "Synthesis.csv" # NOT OSError --
|
|
100
|
+
base_text = _read_text(base) # catch both so build.py's `except ValueError` warns+skips
|
|
101
|
+
except (OSError, ConfigError) as e:
|
|
102
|
+
raise ValueError("synthesis recipes ([[synthesis]]) need your FF9 install to read the base "
|
|
103
|
+
f"Synthesis.csv (header + recipe ids): {e}") from e
|
|
104
|
+
path = layout.synthesis_csv
|
|
105
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
path.write_text(render_synthesis(synth_blocks, base_text), encoding=CSV_ENCODING, newline="\n")
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Author field dialogue text (``.mes``).
|
|
2
|
+
|
|
3
|
+
Memoria loads field text cumulatively across mods: a mod's
|
|
4
|
+
``FF9_Data/embeddedasset/text/<lang>/field/<mesId>.mes`` is merged over the base block, and
|
|
5
|
+
explicit ``[TXID=n]`` indices let a mod *add* entries without disturbing the base text — as
|
|
6
|
+
long as you use indices the base block doesn't occupy (a high TXID like 500+). That is the
|
|
7
|
+
trick proven in Session 9: drop a ``<mesId>.mes`` with our line at a high index; the base
|
|
8
|
+
text is untouched, our entry is added, and an NPC's WindowSync(... , txid) resolves to it.
|
|
9
|
+
|
|
10
|
+
Format of one entry::
|
|
11
|
+
|
|
12
|
+
_[TXID=500][STRT=10,1][TAIL=UPR]I miss you Zidane[ENDN]
|
|
13
|
+
|
|
14
|
+
The leading ``_`` (any non-``[STRT=`` character) is required so the parser treats ``[TXID=]``
|
|
15
|
+
as a re-index rather than the start of entry 0.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
# A safe starting index for mod-added dialogue (base field blocks don't use these).
|
|
23
|
+
DEFAULT_BASE_TXID = 500
|
|
24
|
+
|
|
25
|
+
# The dialogue window's TAIL — the little pointer that aims at the speaker (FF9's "who's talking"
|
|
26
|
+
# cue; there's no separate name-box). Codes map to Dialog.TailPosition (FFIXTextTag.GetTailPosition):
|
|
27
|
+
# UPR/UPL upper-right/left · LOR/LOL lower-right/left · UPC/LOC upper/lower-center
|
|
28
|
+
# ...F variants force that corner · DEFT = engine default/auto-position
|
|
29
|
+
TAIL_CODES = {"UPR", "UPL", "LOR", "LOL", "UPC", "LOC",
|
|
30
|
+
"UPRF", "UPLF", "LORF", "LOLF", "DEFT"}
|
|
31
|
+
DEFAULT_TAIL = "UPR"
|
|
32
|
+
|
|
33
|
+
# How a `speaker` name is prefixed onto a line. FF9 has no name-box, so a name is just part of the
|
|
34
|
+
# text; ": " is the common readable form. Use a name-variable tag in the speaker for a renameable
|
|
35
|
+
# party member, e.g. speaker = "[VIVI]" -> renders the player's chosen name for Vivi.
|
|
36
|
+
SPEAKER_SEP = ": "
|
|
37
|
+
|
|
38
|
+
# Dialogue CHOICE text (one entry holds the prompt + the selectable rows). After the prompt, [CHOO]
|
|
39
|
+
# starts the option list and each subsequent newline is one selectable row; [MOVE=18,0] indents each
|
|
40
|
+
# row so the selection cursor has room (FF9's exact convention -- see Memoria FFIXTextTagCode). So a
|
|
41
|
+
# choice entry's text is: prompt + CHOICE_OPEN + ("\n" + CHOICE_INDENT).join(options).
|
|
42
|
+
CHOICE_INDENT = "[MOVE=18,0]"
|
|
43
|
+
CHOICE_OPEN = "\n[CHOO]" + CHOICE_INDENT
|
|
44
|
+
# [IMME] = IMMEDIATE display: the window pops fully drawn with NO character-by-character type-on. FF9's own
|
|
45
|
+
# shop/menu choices use it (e.g. the Treno Weapon Shop's "What can I do for you?" Buy/Sell menu ends in
|
|
46
|
+
# [IMME]) so a SELECTOR feels instant, while story dialogue types out. Appended to a choice entry when the
|
|
47
|
+
# [[choice]] sets `instant = true` (the World Hub journey menu turns it on).
|
|
48
|
+
CHOICE_IMME = "[IMME]"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def with_speaker(speaker, text: str) -> str:
|
|
52
|
+
"""Prefix ``speaker`` onto a dialogue line (``"Vivi: ...">``), or return ``text`` unchanged when no
|
|
53
|
+
speaker. ``speaker`` may be a plain name or an FF9 name tag like ``[ZDNE]`` / ``[VIVI]``."""
|
|
54
|
+
return f"{speaker}{SPEAKER_SEP}{text}" if speaker else text
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def mes_entry(text: str, txid: int, *, strt: tuple = (10, 1), tail: str = DEFAULT_TAIL) -> str:
|
|
58
|
+
"""One ``.mes`` entry line that ADDS dialogue at ``txid`` without touching base text."""
|
|
59
|
+
strt_s = ",".join(str(v) for v in strt)
|
|
60
|
+
return f"_[TXID={txid}][STRT={strt_s}][TAIL={tail}]{text}[ENDN]"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build_mes(lines, *, start_txid: int = DEFAULT_BASE_TXID, tails=None, strts=None) -> tuple[str, dict]:
|
|
64
|
+
"""Build a ``.mes`` file body from an ordered list of dialogue strings.
|
|
65
|
+
|
|
66
|
+
Returns ``(text, mapping)`` where ``mapping[i]`` is the TXID assigned to ``lines[i]`` (so a
|
|
67
|
+
caller can point each NPC's WindowSync at the right id). TXIDs are ``start_txid + i``.
|
|
68
|
+
``tails`` (optional) is a per-line list of TAIL codes; ``None``/missing entries use
|
|
69
|
+
:data:`DEFAULT_TAIL`. ``strts`` (optional) is a per-line ``(x, y)`` window geometry; ``None``/missing
|
|
70
|
+
entries use ``mes_entry``'s default ``(10, 1)`` -- so existing callers stay byte-identical. (A FF9
|
|
71
|
+
*system* window like the chest's item-get box auto-CENTERS from its ``[STRT=width,lines]``, so it must
|
|
72
|
+
pass its real geometry, not the dialogue default.)
|
|
73
|
+
"""
|
|
74
|
+
entries = []
|
|
75
|
+
mapping = {}
|
|
76
|
+
for i, line in enumerate(lines):
|
|
77
|
+
txid = start_txid + i
|
|
78
|
+
mapping[i] = txid
|
|
79
|
+
tail = (tails[i] if tails and i < len(tails) and tails[i] else DEFAULT_TAIL)
|
|
80
|
+
strt = (strts[i] if strts and i < len(strts) and strts[i] else (10, 1))
|
|
81
|
+
entries.append(mes_entry(line, txid, strt=strt, tail=tail))
|
|
82
|
+
return "\n".join(entries) + "\n", mapping
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --------------------------------------------------------------------------- proportional auto-wrap
|
|
86
|
+
# FF9 field dialogue does NOT auto-wrap: the window grows to fit the widest line, so an un-broken long
|
|
87
|
+
# line runs off the screen. The original game hand-breaks every line; we reproduce that at build time.
|
|
88
|
+
#
|
|
89
|
+
# Why this is PROPORTIONAL and not pixel-exact (from Memoria source): the field dialogue font is a
|
|
90
|
+
# *runtime dynamic TrueType* font -- EncryptFontManager.InitializeFont ->
|
|
91
|
+
# Font.CreateDynamicFontFromOSFont(Configuration.Font.Names, ...), default the bundled "TBUDGoStd-Bold",
|
|
92
|
+
# overridable in Memoria.ini [Font]. Glyph widths come from Unity's TTF rasterizer at a configurable
|
|
93
|
+
# size, per language (NGUIText.GetGlyphWidth -> mTempChar.advance). So there is NO fixed pixel-width
|
|
94
|
+
# table to ship and exact-per-install wrapping is impossible offline. Instead we model RELATIVE glyph
|
|
95
|
+
# widths for a bold proportional sans ('W'/'m' ~3x 'i'/'l') and wrap at a conservative width budget --
|
|
96
|
+
# accurate where it matters (it respects glyph widths) and erring toward wrapping a hair early so it
|
|
97
|
+
# never overflows. Tune `wrap` per field for fuller lines (one in-game check finds your true max).
|
|
98
|
+
|
|
99
|
+
# max rendered line width, in "width units" (~ average characters). Conservative by default.
|
|
100
|
+
DEFAULT_WRAP_WIDTH = 28.0
|
|
101
|
+
_DEFAULT_GLYPH_W = 1.0
|
|
102
|
+
|
|
103
|
+
# relative advances for a bold proportional sans (em-ish; a typical letter ~0.9). Approximate by design.
|
|
104
|
+
_GLYPH_W = {
|
|
105
|
+
" ": 0.5,
|
|
106
|
+
"'": 0.3, "|": 0.3, "`": 0.3, ".": 0.4, ",": 0.4, ";": 0.4, ":": 0.4,
|
|
107
|
+
"!": 0.45, "i": 0.45, "j": 0.45, "l": 0.45, "I": 0.5,
|
|
108
|
+
"(": 0.5, ")": 0.5, "[": 0.5, "]": 0.5, "/": 0.5, "\\": 0.5,
|
|
109
|
+
"f": 0.55, "t": 0.55, '"': 0.6, "-": 0.6, "r": 0.6,
|
|
110
|
+
"s": 0.75, "J": 0.75, "?": 0.9,
|
|
111
|
+
"m": 1.45, "w": 1.4, "M": 1.6, "W": 1.6, "@": 1.6, "&": 1.25,
|
|
112
|
+
}
|
|
113
|
+
for _c in "abcdeghknopquvxyz":
|
|
114
|
+
_GLYPH_W.setdefault(_c, 0.9)
|
|
115
|
+
for _c in "ABCDEFGHKLNOPQRSTUVXYZ":
|
|
116
|
+
_GLYPH_W.setdefault(_c, 1.15)
|
|
117
|
+
for _c in "0123456789":
|
|
118
|
+
_GLYPH_W.setdefault(_c, 0.95)
|
|
119
|
+
|
|
120
|
+
_TAG_RE = re.compile(r"\[[^\]]*\]")
|
|
121
|
+
# tags render nothing (color/format/control) EXCEPT name/variable tags, which render text at runtime.
|
|
122
|
+
_NAME_TAGS = {"ZDNE", "VIVI", "DGGR", "STNR", "FRYA", "QUIN", "EIKO", "AMRT",
|
|
123
|
+
"PTY1", "PTY2", "PTY3", "PTY4"}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _tag_render_width(tag: str) -> float:
|
|
127
|
+
code = tag[1:-1].split("=", 1)[0].strip().upper() # "[VIVI]" -> "VIVI"; "[ICON=5]" -> "ICON"
|
|
128
|
+
if code in _NAME_TAGS:
|
|
129
|
+
return 6.0 # a (renameable) party name; ~6 characters
|
|
130
|
+
if code in ("TEXT", "NUMB", "ITEM", "ICON"):
|
|
131
|
+
return 4.0 # an inserted variable / item name / icon; rough
|
|
132
|
+
return 0.0 # color / format / page / control tag -> no glyphs
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def measure(text: str) -> float:
|
|
136
|
+
"""Approximate rendered width of a dialogue line in width units (~average characters). Literal
|
|
137
|
+
``[...]`` tag brackets are not counted; their *rendered* content is (a name tag ~ a name, a color
|
|
138
|
+
tag ~ nothing). Approximate by design -- see the module note on why pixel-exact is impossible."""
|
|
139
|
+
total, i = 0.0, 0
|
|
140
|
+
for m in _TAG_RE.finditer(text):
|
|
141
|
+
total += sum(_GLYPH_W.get(c, _DEFAULT_GLYPH_W) for c in text[i:m.start()])
|
|
142
|
+
total += _tag_render_width(m.group())
|
|
143
|
+
i = m.end()
|
|
144
|
+
total += sum(_GLYPH_W.get(c, _DEFAULT_GLYPH_W) for c in text[i:])
|
|
145
|
+
return total
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def wrap_text(text: str, width: float = DEFAULT_WRAP_WIDTH):
|
|
149
|
+
"""Break ``text`` into lines that each fit within ``width`` units, reproducing FF9's hand-broken
|
|
150
|
+
dialogue. Existing ``\\n`` and ``[PAGE]`` breaks are respected (each page/line wrapped on its own),
|
|
151
|
+
and a segment that already fits is kept BYTE-IDENTICAL (so short lines never change). Returns
|
|
152
|
+
``(wrapped, overflow)`` where ``overflow`` lists single words too wide to fit on a line alone."""
|
|
153
|
+
overflow = []
|
|
154
|
+
out_pages = []
|
|
155
|
+
for page in text.split("[PAGE]"):
|
|
156
|
+
out_lines = []
|
|
157
|
+
for seg in page.split("\n"):
|
|
158
|
+
if measure(seg) <= width:
|
|
159
|
+
out_lines.append(seg) # already fits -> verbatim
|
|
160
|
+
continue
|
|
161
|
+
cur = ""
|
|
162
|
+
for word in seg.split(" "):
|
|
163
|
+
if measure(word) > width:
|
|
164
|
+
overflow.append(word) # an unbreakable, over-wide single word
|
|
165
|
+
cand = f"{cur} {word}" if cur else word
|
|
166
|
+
if cur and measure(cand) > width:
|
|
167
|
+
out_lines.append(cur)
|
|
168
|
+
cur = word
|
|
169
|
+
else:
|
|
170
|
+
cur = cand
|
|
171
|
+
out_lines.append(cur)
|
|
172
|
+
out_pages.append("\n".join(out_lines))
|
|
173
|
+
return "[PAGE]".join(out_pages), overflow
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def overflow_lines(text: str, width: float = DEFAULT_WRAP_WIDTH):
|
|
177
|
+
"""Final wrapped lines that STILL exceed ``width`` -- i.e. an unbreakable over-wide word (a long
|
|
178
|
+
name/URL). Empty list = everything fits after wrapping. Used to warn at build time."""
|
|
179
|
+
wrapped, _ = wrap_text(text, width)
|
|
180
|
+
bad = []
|
|
181
|
+
for page in wrapped.split("[PAGE]"):
|
|
182
|
+
bad.extend(ln for ln in page.split("\n") if measure(ln) > width)
|
|
183
|
+
return bad
|