ff9mapkit 1.0.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. ff9mapkit/__init__.py +18 -0
  2. ff9mapkit/__main__.py +36 -0
  3. ff9mapkit/_animdb.py +2994 -0
  4. ff9mapkit/_animdb_all.py +14125 -0
  5. ff9mapkit/_fieldtable.py +1516 -0
  6. ff9mapkit/_fieldtext.py +845 -0
  7. ff9mapkit/_held_poses.py +44 -0
  8. ff9mapkit/_itemdb.py +65 -0
  9. ff9mapkit/_modeldb.py +725 -0
  10. ff9mapkit/_narrowmap_data.py +10 -0
  11. ff9mapkit/_npcparams.py +634 -0
  12. ff9mapkit/_regen_animdb.py +72 -0
  13. ff9mapkit/_regen_animdb_all.py +66 -0
  14. ff9mapkit/_regen_fieldtable.py +95 -0
  15. ff9mapkit/_regen_fieldtext.py +66 -0
  16. ff9mapkit/_regen_modeldb.py +67 -0
  17. ff9mapkit/_regen_npcparams.py +123 -0
  18. ff9mapkit/_regen_scenedb.py +57 -0
  19. ff9mapkit/_scenedb.py +869 -0
  20. ff9mapkit/abilities.py +225 -0
  21. ff9mapkit/animations.py +120 -0
  22. ff9mapkit/archetypes.py +218 -0
  23. ff9mapkit/areatitle.py +76 -0
  24. ff9mapkit/battle/__init__.py +21 -0
  25. ff9mapkit/battle/abilityfeatures.py +294 -0
  26. ff9mapkit/battle/actiondelta.py +441 -0
  27. ff9mapkit/battle/aiauthor.py +305 -0
  28. ff9mapkit/battle/ailint.py +140 -0
  29. ff9mapkit/battle/aipatch.py +175 -0
  30. ff9mapkit/battle/battleai.py +148 -0
  31. ff9mapkit/battle/battlecsv.py +390 -0
  32. ff9mapkit/battle/battlepatch.py +395 -0
  33. ff9mapkit/battle/build.py +558 -0
  34. ff9mapkit/battle/camera_codec.py +332 -0
  35. ff9mapkit/battle/camera_data.py +128 -0
  36. ff9mapkit/battle/characterdelta.py +789 -0
  37. ff9mapkit/battle/event_data.py +72 -0
  38. ff9mapkit/battle/extract.py +540 -0
  39. ff9mapkit/battle/fbx.py +223 -0
  40. ff9mapkit/battle/reskin.py +149 -0
  41. ff9mapkit/battle/scene_codec.py +314 -0
  42. ff9mapkit/battle/scene_data.py +369 -0
  43. ff9mapkit/battle/scenelint.py +125 -0
  44. ff9mapkit/battle/seqasm.py +131 -0
  45. ff9mapkit/battle/seqauthor.py +220 -0
  46. ff9mapkit/battle/seqcodec.py +300 -0
  47. ff9mapkit/battle/seqdis.py +106 -0
  48. ff9mapkit/battle/seqpatch.py +137 -0
  49. ff9mapkit/battle_bgm.py +133 -0
  50. ff9mapkit/binutils.py +60 -0
  51. ff9mapkit/build.py +5445 -0
  52. ff9mapkit/campaign.py +1276 -0
  53. ff9mapkit/catalog.py +316 -0
  54. ff9mapkit/chain.py +358 -0
  55. ff9mapkit/cli.py +3114 -0
  56. ff9mapkit/config.py +360 -0
  57. ff9mapkit/content/__init__.py +13 -0
  58. ff9mapkit/content/areatitle.py +36 -0
  59. ff9mapkit/content/ate.py +118 -0
  60. ff9mapkit/content/camera.py +123 -0
  61. ff9mapkit/content/chest.py +186 -0
  62. ff9mapkit/content/choice.py +163 -0
  63. ff9mapkit/content/conductor.py +217 -0
  64. ff9mapkit/content/cutscene.py +290 -0
  65. ff9mapkit/content/encounter.py +41 -0
  66. ff9mapkit/content/entry_settle.py +50 -0
  67. ff9mapkit/content/equipment.py +93 -0
  68. ff9mapkit/content/event.py +191 -0
  69. ff9mapkit/content/gateway.py +101 -0
  70. ff9mapkit/content/inventory.py +59 -0
  71. ff9mapkit/content/itemdata.py +644 -0
  72. ff9mapkit/content/itemtext.py +168 -0
  73. ff9mapkit/content/jump.py +114 -0
  74. ff9mapkit/content/ladder.py +633 -0
  75. ff9mapkit/content/movement.py +53 -0
  76. ff9mapkit/content/music.py +97 -0
  77. ff9mapkit/content/npc.py +348 -0
  78. ff9mapkit/content/object.py +340 -0
  79. ff9mapkit/content/onentry.py +135 -0
  80. ff9mapkit/content/party.py +111 -0
  81. ff9mapkit/content/pathfind.py +138 -0
  82. ff9mapkit/content/platform.py +314 -0
  83. ff9mapkit/content/player.py +168 -0
  84. ff9mapkit/content/prop.py +75 -0
  85. ff9mapkit/content/region.py +340 -0
  86. ff9mapkit/content/reinit.py +59 -0
  87. ff9mapkit/content/savepoint.py +90 -0
  88. ff9mapkit/content/shop.py +178 -0
  89. ff9mapkit/content/sps_trigger.py +66 -0
  90. ff9mapkit/content/startup.py +71 -0
  91. ff9mapkit/content/synthesis.py +106 -0
  92. ff9mapkit/content/text.py +183 -0
  93. ff9mapkit/content/textcarry.py +290 -0
  94. ff9mapkit/content/verbatim.py +86 -0
  95. ff9mapkit/content/walkmesh_hotfix.py +38 -0
  96. ff9mapkit/data/__init__.py +48 -0
  97. ff9mapkit/data/_regen_provenance.py +142 -0
  98. ff9mapkit/data/provenance/blank.es.patch +1 -0
  99. ff9mapkit/data/provenance/blank.fr.patch +1 -0
  100. ff9mapkit/data/provenance/blank.gr.patch +1 -0
  101. ff9mapkit/data/provenance/blank.it.patch +1 -0
  102. ff9mapkit/data/provenance/blank.jp.patch +1 -0
  103. ff9mapkit/data/provenance/blank.uk.patch +1 -0
  104. ff9mapkit/data/provenance/blank.us.patch +1 -0
  105. ff9mapkit/data/provenance/manifest.json +65 -0
  106. ff9mapkit/data/provenance/region_template.patch +1 -0
  107. ff9mapkit/data/reference_arcs.toml +89 -0
  108. ff9mapkit/data/region_catalog.toml +593 -0
  109. ff9mapkit/deploystack.py +358 -0
  110. ff9mapkit/dialogue.py +803 -0
  111. ff9mapkit/eb/__init__.py +12 -0
  112. ff9mapkit/eb/_exprtable.py +59 -0
  113. ff9mapkit/eb/_membertable.py +38 -0
  114. ff9mapkit/eb/_optables.py +537 -0
  115. ff9mapkit/eb/_regen_optables.py +76 -0
  116. ff9mapkit/eb/cmdasm.py +323 -0
  117. ff9mapkit/eb/disasm.py +332 -0
  118. ff9mapkit/eb/edit.py +439 -0
  119. ff9mapkit/eb/exprasm.py +158 -0
  120. ff9mapkit/eb/model.py +178 -0
  121. ff9mapkit/eb/opcodes.py +463 -0
  122. ff9mapkit/eblint.py +177 -0
  123. ff9mapkit/editor/__init__.py +20 -0
  124. ff9mapkit/editor/app.py +950 -0
  125. ff9mapkit/editor/battle_forms.py +240 -0
  126. ff9mapkit/editor/breadcrumb.py +89 -0
  127. ff9mapkit/editor/dialogs.py +116 -0
  128. ff9mapkit/editor/feedback.py +208 -0
  129. ff9mapkit/editor/forms.py +632 -0
  130. ff9mapkit/editor/graphview.py +350 -0
  131. ff9mapkit/editor/jobs.py +342 -0
  132. ff9mapkit/editor/model.py +243 -0
  133. ff9mapkit/editor/picker.py +120 -0
  134. ff9mapkit/editor/theme.py +212 -0
  135. ff9mapkit/eventscan.py +1441 -0
  136. ff9mapkit/extract.py +2279 -0
  137. ff9mapkit/flags.py +693 -0
  138. ff9mapkit/forkreport.py +1383 -0
  139. ff9mapkit/hub.py +477 -0
  140. ff9mapkit/idgated.py +101 -0
  141. ff9mapkit/infohub.py +580 -0
  142. ff9mapkit/items.py +63 -0
  143. ff9mapkit/itemstats.py +346 -0
  144. ff9mapkit/journey.py +1902 -0
  145. ff9mapkit/keyitems.py +93 -0
  146. ff9mapkit/logic_add.py +632 -0
  147. ff9mapkit/logic_edit.py +728 -0
  148. ff9mapkit/logic_map.py +526 -0
  149. ff9mapkit/pack.py +175 -0
  150. ff9mapkit/playerswap.py +231 -0
  151. ff9mapkit/prop_archetypes.py +228 -0
  152. ff9mapkit/provision.py +282 -0
  153. ff9mapkit/refarc.py +825 -0
  154. ff9mapkit/save.py +337 -0
  155. ff9mapkit/save_items.py +1673 -0
  156. ff9mapkit/scene/__init__.py +11 -0
  157. ff9mapkit/scene/arena.py +63 -0
  158. ff9mapkit/scene/bgart.py +140 -0
  159. ff9mapkit/scene/bgi.py +732 -0
  160. ff9mapkit/scene/bgs.py +174 -0
  161. ff9mapkit/scene/bgx.py +185 -0
  162. ff9mapkit/scene/cam.py +345 -0
  163. ff9mapkit/scene/guide.py +311 -0
  164. ff9mapkit/scene/paint.py +506 -0
  165. ff9mapkit/scene/placeholder.py +107 -0
  166. ff9mapkit/sjbinary.py +285 -0
  167. ff9mapkit/sps/__init__.py +17 -0
  168. ff9mapkit/sps/author.py +294 -0
  169. ff9mapkit/sps/catalog.py +88 -0
  170. ff9mapkit/sps/codec.py +264 -0
  171. ff9mapkit/sps/edit.py +184 -0
  172. ff9mapkit/sps/lint.py +58 -0
  173. ff9mapkit/sps/render.py +116 -0
  174. ff9mapkit/sps/templates.py +47 -0
  175. ff9mapkit/sps/texture.py +131 -0
  176. ff9mapkit/walkmesh_hotfixes.py +163 -0
  177. ff9mapkit/workspace/__init__.py +18 -0
  178. ff9mapkit/workspace/battledoc.py +985 -0
  179. ff9mapkit/workspace/builddoc.py +607 -0
  180. ff9mapkit/workspace/forms_qt.py +586 -0
  181. ff9mapkit/workspace/importdoc.py +665 -0
  182. ff9mapkit/workspace/mapview.py +131 -0
  183. ff9mapkit/workspace/palette.py +85 -0
  184. ff9mapkit/workspace/savedoc.py +664 -0
  185. ff9mapkit/workspace/shell.py +6907 -0
  186. ff9mapkit/workspace/style.py +105 -0
  187. ff9mapkit/workspace/tuningdialog.py +223 -0
  188. ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
  189. ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
  190. ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
  191. ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
  192. ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
  193. ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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