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,632 @@
1
+ """Form specs + parsers for the editor (tk-FREE, fully testable).
2
+
3
+ Each logic section is described by a list of :class:`Field` specs (key, label, kind). The UI renders a
4
+ form generically from a spec; this module converts between the form's raw widget values and the
5
+ entity dict (:func:`build_entity` / :func:`entity_to_values`) and parses the text fields. Keeping all
6
+ parsing/normalization here (not in the Tk layer) means the tricky bits are unit-tested without a
7
+ display, exactly like the Blender ``bridge`` is bpy-free.
8
+
9
+ The contract: ``build_entity(spec, entity_to_values(spec, e)) == e`` for any entity ``e`` whose keys
10
+ are covered by ``spec`` (round-trip), proven in ``tests/test_editor_forms.py``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass
17
+
18
+ from .. import archetypes as _archetypes
19
+
20
+ # field kinds
21
+ STR, INT, OPTINT, BOOL, PRESET, COORD, PAIR, ZONE, ITEMCOUNT, FLAGREF, FLAGPAIR, STRLIST = (
22
+ "str", "int", "optint", "bool", "preset", "coord", "pair", "zone", "itemcount", "flagref", "flagpair",
23
+ "strlist")
24
+ # [startup] kinds: a scenario beat (number or area name), and the two list-of-table levers it carries
25
+ SCENARIOREF, FLAGDICTLIST, BYTEDICTLIST = "scenarioref", "flagdictlist", "bytedictlist"
26
+ FLOAT = "float" # an OPTIONAL float (e.g. battle camera tweak offsets); empty -> None, like OPTINT
27
+ # cutscene-step kinds: a movement target (a name OR "x, z"), a route (list of those), a gesture (name OR id)
28
+ POINT, PATH, ANIM = "point", "path", "anim"
29
+
30
+ PRESETS = _archetypes.names() # built-in archetype names for the combo (also accepts a custom string)
31
+
32
+
33
+ @dataclass
34
+ class Field:
35
+ key: str
36
+ label: str
37
+ kind: str
38
+ help: str = ""
39
+ default: object = None # for BOOL: the value omitted from the file (e.g. once=True)
40
+ catalog: str = None # comma-separated Info Hub kinds -> render a "Browse..." picker button
41
+
42
+
43
+ # --- section specs (the editor's logic vocabulary) ---------------------------------------
44
+ FIELD_SPEC = [
45
+ Field("id", "Field ID", INT, "a unique number for your field (use >= 4000)"),
46
+ Field("name", "Name", STR, "short tag, e.g. MY_ROOM (letters, digits, underscore)"),
47
+ Field("area", "Area", INT, "must be >= 10 (lower areas don't render in-game)"),
48
+ Field("text_block", "Text block", OPTINT, "leave at 1073 unless you know you need another"),
49
+ Field("title", "Title", STR, "a human label for your own notes (optional)"),
50
+ Field("location", "Location", STR, 'the in-game menu place-name (the "LOCATION" card), e.g. "Pimp House"; '
51
+ "blank = a fork inherits its donor's, a new field shows none"),
52
+ Field("borrow_bg", "Borrow BG", STR, "advanced: reuse a real field's art; leave blank otherwise"),
53
+ ]
54
+ NPC_SPEC = [
55
+ Field("name", "Name", STR, "a label (also links this NPC to its Blender marker)"),
56
+ Field("preset", "Preset", PRESET, "who it looks like (any archetype/creature)",
57
+ catalog="archetype,creature"),
58
+ Field("model", "Model id", OPTINT, "advanced: a custom model instead of a preset"),
59
+ Field("animset", "Animset id", OPTINT, "advanced: with a custom model (also add anims in the .toml)"),
60
+ Field("dialogue", "Dialogue", STR, "the line shown when the player talks to it"),
61
+ Field("speaker", "Speaker name", STR, "optional name before the line, e.g. Vivi (or [VIVI] for a renameable party name)"),
62
+ Field("tail", "Window tail", STR, "dialogue pointer corner: UPR/UPL/LOR/LOL/UPC/LOC (default UPR)"),
63
+ Field("pos", "Position (x, z)", COORD, "where it stands on the floor; usually placed in Blender"),
64
+ Field("requires_flag", "Appears when flag set", FLAGREF,
65
+ "story gate: show only after this flag (name or index) is set", catalog="flag"),
66
+ Field("requires_flag_clear", "Appears when flag clear", FLAGREF,
67
+ "show only while this flag (name or index) is unset", catalog="flag"),
68
+ ]
69
+ GATEWAY_SPEC = [
70
+ Field("name", "Name", STR, "a label (links to its Blender marker)"),
71
+ Field("to", "To field id", INT, "the field id to send the player to"),
72
+ Field("entrance", "Entrance", OPTINT, "which entrance to arrive at (default 0)"),
73
+ Field("zone", "Zone (x z; x z; ...)", ZONE, "the trigger quad; usually placed in Blender"),
74
+ Field("requires_flag", "Opens when flag set", FLAGREF, "only usable once this flag (name/idx) is set",
75
+ catalog="flag"),
76
+ Field("requires_flag_clear", "Opens when flag clear", FLAGREF, "only usable while this flag is unset",
77
+ catalog="flag"),
78
+ ]
79
+ EVENT_SPEC = [
80
+ Field("name", "Name", STR, "a label (links to its Blender marker)"),
81
+ Field("message", "Message", STR, "text shown when the player steps in"),
82
+ Field("speaker", "Speaker name", STR, "optional name before the message (blank for an unsigned popup)"),
83
+ Field("tail", "Window tail", STR, "dialogue pointer corner: UPR/UPL/LOR/LOL/UPC/LOC (default UPR)"),
84
+ Field("give_item", "Give item (id, count)", PAIR, "e.g. 232, 1"),
85
+ Field("received", "Item-get window", BOOL, "give_item: show the canonical 'Received <item>!' window",
86
+ default=False),
87
+ Field("require_space", "Skip if bag full", BOOL, "give_item: chest-style -- don't fire if you can't carry it",
88
+ default=False),
89
+ Field("gil", "Gil", OPTINT, "gil to award"),
90
+ Field("set_flag", "Set flag (name/idx, val)", FLAGPAIR, "raise a story flag, e.g. boss_dead, 1 (name or index)"),
91
+ Field("once", "Fire once", BOOL, "off = fires every step you stand in it", default=True),
92
+ Field("zone", "Zone (x z; x z; ...)", ZONE, "the trigger quad; usually placed in Blender"),
93
+ Field("requires_flag", "Fires when flag set", FLAGREF, "only fires after this flag (name/idx) is set",
94
+ catalog="flag"),
95
+ Field("requires_flag_clear", "Fires when flag clear", FLAGREF, "only fires while this flag is unset",
96
+ catalog="flag"),
97
+ ]
98
+ CHEST_SPEC = [
99
+ Field("pos", "Position (x, z)", COORD, "where the chest sits on the floor; usually placed in Blender"),
100
+ Field("model", "Chest model", STR, "treasure-chest model. TWO real looks: F0 (default, wooden) = F2, and "
101
+ "F1 = F3 (the game ships per-zone duplicate ids; F2 only differs by an unused dummy texture). Or a "
102
+ "raw id (75/91/701/702)"),
103
+ Field("item", "Reward item (id/name, count)", ITEMCOUNT, 'the treasure, e.g. "Potion, 1" (set item OR gil)',
104
+ catalog="item"),
105
+ Field("gil", "Reward gil", OPTINT, "give gil instead of an item (set item OR gil)"),
106
+ Field("flag", "Opened-flag", FLAGREF, "REQUIRED save bit that marks it looted (it stays open across "
107
+ "saves) -- a [[flag]] name (recommended) or a safe-band index >= 8512. Not auto-allocated, so it's "
108
+ "resilient to reordering + a player's save state", catalog="flag"),
109
+ Field("requires_flag", "Appears when flag set", FLAGREF,
110
+ "story gate: the chest only appears after this flag (name or index) is set", catalog="flag"),
111
+ Field("requires_flag_clear", "Appears when flag clear", FLAGREF,
112
+ "the chest only appears while this flag (name or index) is unset", catalog="flag"),
113
+ Field("face", "Facing (0-255)", OPTINT, "rotate the chest model (0=south, 64=west, 128=north, 192=east)"),
114
+ Field("message", "Custom box text", STR, "advanced: replace the 'Received <item>!' box (you own the "
115
+ "[WDTH]/codes); blank = the real FF9 box"),
116
+ Field("box", "Box size (width, lines)", PAIR, "advanced: centers a custom message, e.g. 69, 3"),
117
+ Field("tail", "Window tail", STR, "advanced: pointer corner (default DEFT = the centered system box)"),
118
+ ]
119
+ SPS_SPEC = [
120
+ Field("id", "Effect ID", INT, "a unique number for this effect (use >= 5000; must not clash with a "
121
+ "carried donor effect)"),
122
+ Field("template", "Template", STR, 'a named preset -- fire / bonfire / smoke / sparkle / embers / glimmer '
123
+ '(Browse to pick + preview). For a field that does NOT carry its own texture (BG-borrow / synth). On '
124
+ 'a fork that ships its own effects, use "Clone carried effect" instead.', catalog="sps_template"),
125
+ Field("clone_sps", "Clone carried effect", OPTINT, "Browse THIS field's own effects + preview one -- clones "
126
+ "it, reusing the field's texture (the right base on a native/verbatim fork). Use this OR Template.",
127
+ catalog="sps"),
128
+ Field("pos", "Position (x, z)", COORD, "where it sits on the floor; the height is AUTO-GROUNDED from the "
129
+ "walkmesh (place it in OPEN space, not behind a wall, or it's hidden by the scene)"),
130
+ Field("slot", "SPS slot", OPTINT, "0-15; blank = auto-assigned (top-down from 15, to dodge a fork's effects)"),
131
+ Field("abr", "Blend mode", OPTINT, "blank = additive (the right glow for fire/smoke). 0 = 50% add · "
132
+ "1 = add · 2 = subtract · 3 = 25% add · 15 = opaque"),
133
+ Field("framerate", "Frame rate", OPTINT, "16 = 1x (normal ~15 fps loop); smaller = slower; blank = default"),
134
+ ]
135
+ ENCOUNTER_SPEC = [
136
+ Field("scene", "Battle scene id", OPTINT, "e.g. 67 = Evil Forest; blank = no random battles",
137
+ catalog="scene"),
138
+ Field("freq", "Frequency (0-255)", OPTINT, "default 255"),
139
+ Field("battle_music", "Battle music id", OPTINT, "default 0 = battle theme"),
140
+ ]
141
+ MUSIC_SPEC = [
142
+ Field("song", "Field BGM song id", OPTINT, "e.g. 9 = Vivi's Theme; blank = no field music"),
143
+ ]
144
+ PARTY_SPEC = [
145
+ Field("add", "Add members", STRLIST,
146
+ "playable characters to ADD to the party at field load (names or 0-11), e.g. Steiner, Beatrix"),
147
+ Field("remove", "Remove members", STRLIST,
148
+ "playable characters to REMOVE at field load, e.g. Eiko"),
149
+ ]
150
+ STARTUP_SPEC = [
151
+ Field("scenario", "Scenario beat", SCENARIOREF,
152
+ "assert the story beat this field stands for: a number (0-32767) or an area name (e.g. dali)"),
153
+ Field("flags", "Set story flags", FLAGDICTLIST,
154
+ 'story bits to assert at load: "name, 1; other, 0" (name or index; value 0 or 1)'),
155
+ Field("words", "Word writes (advanced)", BYTEDICTLIST,
156
+ 'save-backed 16-bit writes "byte, value; ...", e.g. the ATE mask 236, 65280 (rarely needed)'),
157
+ Field("bytes", "Byte writes (advanced)", BYTEDICTLIST,
158
+ 'save-backed single-byte writes "byte, value; ...", e.g. 361, 4 (rarely needed)'),
159
+ ]
160
+ CUTSCENE_SPEC = [
161
+ Field("actor", "Actor NPC", STR, "an [[npc]] name; blank = narration"),
162
+ Field("once", "Play once", BOOL, "off = replays every visit", default=True),
163
+ Field("warmup", "Warmup frames", OPTINT, "default 30 (let the field settle)"),
164
+ ]
165
+ MARKER_SPEC = [
166
+ Field("name", "Name", STR, "a label; reference it in a cutscene as walk = \"<name>\""),
167
+ Field("pos", "Position (x, z)", COORD, "where it sits on the floor; or place it in Blender"),
168
+ ]
169
+ FLAG_SPEC = [
170
+ Field("name", "Name", STR, "the story-flag name you reference in events / gateways / choices "
171
+ "(set_flag, show-while-unset, …)"),
172
+ Field("index", "gEventGlobal bit", INT, "a save-persistent bit in the custom band [8512, 16320); "
173
+ "Story State labels a set bit with this name"),
174
+ ]
175
+ CHOICE_SPEC = [
176
+ Field("npc", "NPC", STR, "talk-triggered: the [[npc]] name (set npc OR zone, not both)"),
177
+ Field("zone", "Zone (x z; x z; ...)", ZONE, "zone trigger: 4 corners (a lever); or place in Blender"),
178
+ Field("trigger", "Trigger (zone)", STR, "blank = action (press to use, re-usable); 'walk' = auto-pop"),
179
+ Field("once", "Fires once ever", BOOL, "walk-trigger only: on = once ever; off = once per visit", default=True),
180
+ Field("prompt", "Prompt", STR, "the question shown above the options"),
181
+ Field("speaker", "Speaker name", STR, "optional name before the prompt"),
182
+ Field("tail", "Window tail", STR, "UPR/UPL/LOR/LOL/UPC/LOC (default UPR)"),
183
+ Field("default", "Default row", OPTINT, "option index highlighted first (0 = top; default 0)"),
184
+ Field("cancel", "Cancel row", OPTINT, "option index B/Cancel picks (-1 or blank = last row)"),
185
+ ]
186
+ CHOICE_OPTION_SPEC = [
187
+ Field("text", "Option text", STR, "the menu row the player selects (keep it short)"),
188
+ Field("disabled", "Hidden", BOOL, "on = always removed from the menu (cursor can't reach it)",
189
+ default=False),
190
+ Field("requires_flag", "Show if flag set", FLAGREF, "hide this row UNTIL this flag (name/idx) is set",
191
+ catalog="flag"),
192
+ Field("requires_flag_clear", "Show if flag clear", FLAGREF, "hide this row ONCE this flag is set",
193
+ catalog="flag"),
194
+ Field("reply", "Reply", STR, "optional line shown after choosing this option"),
195
+ Field("give_item", "Give item", ITEMCOUNT, 'item + count, e.g. "Potion, 1" (name or id)',
196
+ catalog="item"),
197
+ Field("gil", "Gil", OPTINT, "gil; NEGATIVE charges the player (e.g. -100)"),
198
+ Field("set_flag", "Set flag (name/idx, val)", FLAGPAIR, "raise a story flag, e.g. boss_dead, 1"),
199
+ ]
200
+ DIALOGUE_SPEC = [
201
+ Field("wrap", "Auto-wrap width", OPTINT, "max chars per line (default 28); set 0 to turn wrapping off"),
202
+ ]
203
+
204
+ # one-line purpose for each section, shown at the top of its form (the "what is this" cue).
205
+ SECTION_HELP = {
206
+ "field": "The field's identity: a unique id (>= 4000), a short name, and the area (>= 10).",
207
+ "camera": "Camera / walkmesh / layers / positions are SPATIAL -- author them in Blender. Read-only here.",
208
+ "dialogue": "Text options. Auto-wrap breaks long dialogue lines to fit the screen (FF9 won't).",
209
+ "encounter": "Random battles on this field (battle scene id + frequency + battle music).",
210
+ "music": "The field's background music (a song id, e.g. 9 = Vivi's Theme).",
211
+ "party": "Who's in the party (menu + battle) on this field -- add/remove playable characters at load. "
212
+ "Separate from who you WALK as (an Import option).",
213
+ "startup": "Assert the story beat this field boots in (a forked field starts at scenario zero): set the "
214
+ "scenario and any story flags, unconditionally, at field load.",
215
+ "cutscene": "A scripted scene. Steps run in order with control locked; an 'actor' NPC can walk/emote.",
216
+ "npc": "People who stand in the room: a model (preset), a line of dialogue, optional story gate.",
217
+ "gateway": "An exit zone -> another field (the door the player walks into).",
218
+ "event": "A walk-in trigger: show a message, give an item/gil, or set a story flag.",
219
+ "chest": "An openable, savable treasure chest: a model you PRESS to open -> it gives an item/gil, shows the "
220
+ "centered 'Received' box, and stays open across saves.",
221
+ "marker": "Named points on the floor. A cutscene walk/path can reach them by name (no coords).",
222
+ "choice": "Talk to an NPC -> a menu -> branch. Each option can reply, give item/gil, set a flag.",
223
+ }
224
+
225
+ # cutscene steps: each is a dict with exactly one action key.
226
+ STEP_KIND = {
227
+ "say": STR, "wait": INT, "set_flag": PAIR, # any cutscene
228
+ "walk": POINT, "path": PATH, "teleport": POINT, # actor only (movement)
229
+ "animation": ANIM, "turn": INT, "face_player": BOOL, # actor only (anim/facing)
230
+ }
231
+ STEP_LABEL = {
232
+ "say": "Say (dialogue)", "wait": "Wait (frames)", "set_flag": "Set flag (idx, val)",
233
+ "walk": "Walk to", "path": "Walk a route", "teleport": "Teleport to",
234
+ "animation": "Play animation", "turn": "Turn (angle 0-255)", "face_player": "Face the player",
235
+ }
236
+ # live hint shown for the selected step type (what to type in the Value box).
237
+ STEP_HELP = {
238
+ "say": "dialogue text shown in a window",
239
+ "wait": "frames to pause (30 ≈ 1 second)",
240
+ "set_flag": "story flag as \"index, value\" -- e.g. 8000, 1",
241
+ "walk": "a marker name, @player, or \"x, z\" (auto-routes around obstacles)",
242
+ "path": "a route through waypoints: \"a; b; c\" (names or x z)",
243
+ "teleport": "instantly move to a marker / @player / \"x, z\"",
244
+ "animation": "a gesture name (e.g. glad, angry, nod) or a numeric id",
245
+ "turn": "face an angle 0-255 (0=south, 64=west, 128=north, 192=east)",
246
+ "face_player": "(no value) turn to face the player",
247
+ }
248
+ GLOBAL_STEPS = ("say", "wait", "set_flag")
249
+ ACTOR_STEPS = ("walk", "path", "teleport", "animation", "turn", "face_player")
250
+
251
+
252
+ # --- parsers (raise ValueError with a clear message on bad input) -------------------------
253
+ def _ints(s, n, what):
254
+ parts = [p for p in re.split(r"[ ,]+", str(s).strip()) if p != ""]
255
+ if len(parts) != n:
256
+ raise ValueError(f"{what}: expected {n} number(s), got {len(parts)}")
257
+ try:
258
+ return [int(p) for p in parts]
259
+ except ValueError:
260
+ raise ValueError(f"{what}: must be whole numbers, got {s!r}")
261
+
262
+
263
+ def _str(s):
264
+ return "" if s is None else str(s)
265
+
266
+
267
+ def parse_optint(s):
268
+ s = _str(s).strip()
269
+ if s == "":
270
+ return None
271
+ try:
272
+ return int(s)
273
+ except ValueError:
274
+ raise ValueError(f"expected a whole number, got {s!r}")
275
+
276
+
277
+ def parse_optfloat(s):
278
+ s = _str(s).strip()
279
+ if s == "":
280
+ return None
281
+ try:
282
+ return float(s)
283
+ except ValueError:
284
+ raise ValueError(f"expected a number, got {s!r}")
285
+
286
+
287
+ def parse_coord(s):
288
+ return None if _str(s).strip() == "" else _ints(s, 2, "position")
289
+
290
+
291
+ def parse_pair(s):
292
+ return None if _str(s).strip() == "" else _ints(s, 2, "pair")
293
+
294
+
295
+ def parse_zone(s):
296
+ s = _str(s).strip()
297
+ if s == "":
298
+ return None
299
+ chunks = [c for c in re.split(r"[;\n]+", s) if c.strip()]
300
+ out = [_ints(c, 2, "zone point") for c in chunks]
301
+ if len(out) not in (4, 5):
302
+ raise ValueError(f"zone needs 4 or 5 points (got {len(out)})")
303
+ return out
304
+
305
+
306
+ def format_pair(v):
307
+ return ", ".join(str(int(x)) for x in v)
308
+
309
+
310
+ def format_zone(v):
311
+ return "; ".join(f"{int(x)} {int(z)}" for (x, z) in v)
312
+
313
+
314
+ def parse_itemcount(s):
315
+ """give_item: ``"item, count"`` -> ``[item, count]``. ``item`` is an int when numeric, else a name
316
+ string ("Potion", "236", "Phoenix Down, 3" all work -- split on the FIRST comma so item names may
317
+ contain spaces). ``count`` defaults to 1. Empty -> None."""
318
+ s = _str(s).strip()
319
+ if s == "":
320
+ return None
321
+ item, _, cnt = s.partition(",")
322
+ item = item.strip()
323
+ if item == "":
324
+ raise ValueError("give item: needs an item name or id")
325
+ item_v = int(item) if item.lstrip("-").isdigit() else item
326
+ cnt = cnt.strip()
327
+ try:
328
+ count = int(cnt) if cnt else 1
329
+ except ValueError:
330
+ raise ValueError(f"give item: count must be a whole number, got {cnt!r}")
331
+ return [item_v, count]
332
+
333
+
334
+ def format_itemcount(v):
335
+ return "" if not v else f"{v[0]}, {int(v[1]) if len(v) > 1 else 1}"
336
+
337
+
338
+ def parse_flagref(s):
339
+ """A story-flag gate: a numeric index -> int, a [[flag]] NAME -> the name string, empty -> None.
340
+ Names resolve to indices at build time (flags.resolve_project_flags)."""
341
+ s = _str(s).strip()
342
+ if s == "":
343
+ return None
344
+ return int(s) if s.lstrip("-").isdigit() else s
345
+
346
+
347
+ def parse_flagpair(s):
348
+ """set_flag: ``"flag, value"`` -> ``[flag, value]``. ``flag`` is an int index OR a [[flag]] NAME; the
349
+ value defaults to 1. Empty -> None. Mirrors give_item so a name + value author the same way."""
350
+ s = _str(s).strip()
351
+ if s == "":
352
+ return None
353
+ flag, _, val = s.partition(",")
354
+ flag = flag.strip()
355
+ if flag == "":
356
+ raise ValueError("set flag: needs a flag name or index")
357
+ flag_v = int(flag) if flag.lstrip("-").isdigit() else flag
358
+ val = val.strip()
359
+ try:
360
+ value = int(val) if val else 1
361
+ except ValueError:
362
+ raise ValueError(f"set flag: value must be a whole number, got {val!r}")
363
+ return [flag_v, value]
364
+
365
+
366
+ def format_flagpair(v):
367
+ return "" if not v else f"{v[0]}, {int(v[1]) if len(v) > 1 else 1}"
368
+
369
+
370
+ def parse_strlist(s):
371
+ """A comma/space-separated list of names or ids -> a list (a numeric token -> int, else the name
372
+ string); empty -> None. Round-trips with :func:`format_strlist`. Used by ``[party]`` add/remove --
373
+ each token is a character name or a 0..11 CharacterOldIndex (resolved at build, like FLAGREF)."""
374
+ s = _str(s).strip()
375
+ if s == "":
376
+ return None
377
+ toks = [t for t in re.split(r"[,\s]+", s) if t]
378
+ if not toks:
379
+ return None
380
+ return [int(t) if t.lstrip("-").isdigit() else t for t in toks]
381
+
382
+
383
+ def format_strlist(v):
384
+ # a STRLIST is normally a list, but a hand-authored TOML may give a scalar (a bare name, or a raw-int
385
+ # escape hatch like `flags = 9`) -- show it as-is instead of iterating it (which would split a string into
386
+ # chars / TypeError on an int).
387
+ if not isinstance(v, (list, tuple)):
388
+ return str(v)
389
+ return ", ".join(str(x) for x in v)
390
+
391
+
392
+ def parse_flagdictlist(s):
393
+ """[startup] flags: semicolon/newline rows, each ``"flag, value"`` -> a list of ``{flag, value}`` dicts
394
+ (flag = int index or a [[flag]] NAME; value defaults to 1). Empty -> None. Round-trips with
395
+ :func:`format_flagdictlist`; reuses :func:`parse_flagpair` per row so a bare name means value 1."""
396
+ s = _str(s).strip()
397
+ if s == "":
398
+ return None
399
+ out = []
400
+ for row in re.split(r"[;\n]+", s):
401
+ if not row.strip():
402
+ continue
403
+ pair = parse_flagpair(row) # "flag, value" -> [flag, value] (name or idx; default 1)
404
+ out.append({"flag": pair[0], "value": pair[1]})
405
+ return out or None
406
+
407
+
408
+ def format_flagdictlist(v):
409
+ return "; ".join(f"{d['flag']}, {int(d.get('value', 1))}" for d in v)
410
+
411
+
412
+ def parse_bytedictlist(s):
413
+ """[startup] words/bytes: semicolon/newline rows, each ``"byte, value"`` -> a list of ``{byte, value}``
414
+ dicts (both whole numbers). Empty -> None. Round-trips with :func:`format_bytedictlist`."""
415
+ s = _str(s).strip()
416
+ if s == "":
417
+ return None
418
+ out = []
419
+ for row in re.split(r"[;\n]+", s):
420
+ if not row.strip():
421
+ continue
422
+ nums = _ints(row, 2, "byte write") # "byte, value" -> [byte, value]
423
+ out.append({"byte": nums[0], "value": nums[1]})
424
+ return out or None
425
+
426
+
427
+ def format_bytedictlist(v):
428
+ return "; ".join(f"{int(d['byte'])}, {int(d['value'])}" for d in v)
429
+
430
+
431
+ def _is_int(s):
432
+ return bool(re.fullmatch(r"-?\d+", str(s).strip()))
433
+
434
+
435
+ def _format_point(v):
436
+ """A movement point: ``[x, z]`` -> "x, z"; a name string -> itself."""
437
+ return format_pair(v) if isinstance(v, (list, tuple)) else _str(v)
438
+
439
+
440
+ def parse_point(raw):
441
+ """A movement target: "x, z" -> [x, z], or any other text -> a marker / @player / @npc name."""
442
+ s = _str(raw).strip()
443
+ if s == "":
444
+ raise ValueError("needs a marker name or \"x, z\"")
445
+ parts = [p for p in re.split(r"[ ,]+", s) if p]
446
+ if len(parts) == 2 and _is_int(parts[0]) and _is_int(parts[1]):
447
+ return [int(parts[0]), int(parts[1])]
448
+ return s
449
+
450
+
451
+ def parse_path(raw):
452
+ """A route: "a; b; c" (or newlines) -> a list of points (each a name or [x, z])."""
453
+ chunks = [c.strip() for c in re.split(r"[;\n]+", _str(raw)) if c.strip()]
454
+ if not chunks:
455
+ raise ValueError("a route needs at least one waypoint, e.g. \"a; b; c\"")
456
+ return [parse_point(c) for c in chunks]
457
+
458
+
459
+ def parse_anim(raw):
460
+ """A gesture: a numeric id -> int, or a name (e.g. "glad") -> the name string."""
461
+ s = _str(raw).strip()
462
+ if s == "":
463
+ raise ValueError("needs a gesture name or id")
464
+ return int(s) if _is_int(s) else s
465
+
466
+
467
+ def _parse_field(kind, raw):
468
+ """Parse one widget value to its TOML value (or None to omit). Raises ValueError on bad input."""
469
+ if kind in (STR, PRESET):
470
+ s = _str(raw).strip()
471
+ return s or None
472
+ if kind == INT:
473
+ s = _str(raw).strip()
474
+ if s == "":
475
+ return None
476
+ try:
477
+ return int(s)
478
+ except ValueError:
479
+ raise ValueError(f"expected a whole number, got {s!r}")
480
+ if kind == OPTINT:
481
+ return parse_optint(raw)
482
+ if kind == FLOAT:
483
+ return parse_optfloat(raw)
484
+ if kind == COORD:
485
+ return parse_coord(raw)
486
+ if kind == PAIR:
487
+ return parse_pair(raw)
488
+ if kind == ZONE:
489
+ return parse_zone(raw)
490
+ if kind == ITEMCOUNT:
491
+ return parse_itemcount(raw)
492
+ if kind == FLAGREF:
493
+ return parse_flagref(raw)
494
+ if kind == FLAGPAIR:
495
+ return parse_flagpair(raw)
496
+ if kind == STRLIST:
497
+ return parse_strlist(raw)
498
+ if kind == SCENARIOREF:
499
+ return parse_flagref(raw) # a beat number -> int, an area name -> str (resolved at build)
500
+ if kind == FLAGDICTLIST:
501
+ return parse_flagdictlist(raw)
502
+ if kind == BYTEDICTLIST:
503
+ return parse_bytedictlist(raw)
504
+ raise ValueError(f"unknown field kind {kind!r}")
505
+
506
+
507
+ # --- entity <-> form values --------------------------------------------------------------
508
+ def build_entity(spec, values: dict) -> dict:
509
+ """Build an entity dict from raw form values (omit empty optionals; coerce types). A BOOL equal to
510
+ its spec default is omitted (so e.g. ``once=true`` isn't written; ``once=false`` is)."""
511
+ out = {}
512
+ for f in spec:
513
+ if f.kind == BOOL:
514
+ b = bool(values.get(f.key, f.default)) # a missing bool means its default
515
+ if b != f.default:
516
+ out[f.key] = b
517
+ continue
518
+ v = _parse_field(f.kind, values.get(f.key, ""))
519
+ if v is None and f.kind == INT: # INT is the REQUIRED int kind (OPTINT is the optional one):
520
+ raise ValueError(f"{f.label or f.key}: a whole number is required") # a blank one is an error, not a
521
+ if v is not None: # silent drop (the GUI callers surface this as 'fix the field')
522
+ out[f.key] = v
523
+ return out
524
+
525
+
526
+ def entity_to_values(spec, entity: dict) -> dict:
527
+ """Flat widget values for a form from an entity dict (missing keys -> '' / the BOOL default)."""
528
+ vals = {}
529
+ for f in spec:
530
+ if f.key not in entity:
531
+ vals[f.key] = f.default if f.kind == BOOL else ""
532
+ continue
533
+ v = entity[f.key]
534
+ if f.kind == BOOL:
535
+ vals[f.key] = bool(v)
536
+ elif f.kind in (COORD, PAIR):
537
+ vals[f.key] = format_pair(v)
538
+ elif f.kind == ZONE:
539
+ vals[f.key] = format_zone(v)
540
+ elif f.kind == ITEMCOUNT:
541
+ vals[f.key] = format_itemcount(v)
542
+ elif f.kind == FLAGPAIR:
543
+ vals[f.key] = format_flagpair(v)
544
+ elif f.kind == STRLIST:
545
+ vals[f.key] = format_strlist(v)
546
+ elif f.kind == FLAGDICTLIST:
547
+ vals[f.key] = format_flagdictlist(v)
548
+ elif f.kind == BYTEDICTLIST:
549
+ vals[f.key] = format_bytedictlist(v)
550
+ else:
551
+ vals[f.key] = str(v) # FLAGREF/SCENARIOREF (int or name), STR, INT, OPTINT, PRESET
552
+ return vals
553
+
554
+
555
+ # --- cutscene steps ----------------------------------------------------------------------
556
+ def make_step(key: str, raw) -> dict:
557
+ """One cutscene step dict from a step type + a raw value (face_player ignores the value)."""
558
+ if key not in STEP_KIND:
559
+ raise ValueError(f"unknown step {key!r}")
560
+ kind = STEP_KIND[key]
561
+ if kind == BOOL: # face_player
562
+ return {key: True}
563
+ if kind == POINT:
564
+ return {key: parse_point(raw)}
565
+ if kind == PATH:
566
+ return {key: parse_path(raw)}
567
+ if kind == ANIM:
568
+ return {key: parse_anim(raw)}
569
+ v = _parse_field(kind, raw)
570
+ if v is None:
571
+ raise ValueError(f"step '{key}' needs a value")
572
+ return {key: v}
573
+
574
+
575
+ def step_key(step: dict) -> str:
576
+ """The single action key of a step (the first recognized one)."""
577
+ for k in step:
578
+ if k in STEP_KIND:
579
+ return k
580
+ return next(iter(step), "")
581
+
582
+
583
+ def step_value_text(step: dict) -> str:
584
+ """The step's value as editable text ('' for face_player)."""
585
+ k = step_key(step)
586
+ kind = STEP_KIND.get(k)
587
+ if not k or kind == BOOL:
588
+ return ""
589
+ v = step[k]
590
+ if kind in (COORD, PAIR):
591
+ return format_pair(v)
592
+ if kind == POINT:
593
+ return _format_point(v)
594
+ if kind == PATH:
595
+ return "; ".join(_format_point(p) for p in v)
596
+ if isinstance(v, list): # any other list value -- show, don't crash
597
+ return ", ".join(str(p) for p in v)
598
+ return str(v)
599
+
600
+
601
+ def step_summary(step: dict) -> str:
602
+ """A one-line summary for the step list, e.g. ``say: "hello"`` or ``walk: 0, -800``."""
603
+ k = step_key(step)
604
+ if not k:
605
+ return "(empty)"
606
+ if k == "face_player":
607
+ return "face_player"
608
+ return f"{k}: {step_value_text(step)}"
609
+
610
+
611
+ # --- choices (npc + prompt + a list of options) ------------------------------------------
612
+ def choice_summary(ch: dict) -> str:
613
+ """One-line label for the choice tree, e.g. ``Vivi: What'll it be? (3)`` or ``zone: Pull? (2)``."""
614
+ who = ch.get("npc") or ("zone" if "zone" in ch else "?")
615
+ q = (ch.get("prompt") or "").strip()
616
+ n = len(ch.get("options", []))
617
+ return f"{who}: {q[:28]}{'...' if len(q) > 28 else ''} ({n})"
618
+
619
+
620
+ def option_summary(o: dict) -> str:
621
+ """One-line label for an option row, e.g. ``Yes [reply, item, -100g, flag 8001]``."""
622
+ txt = o.get("text") or "(no text)"
623
+ tags = []
624
+ if o.get("reply"):
625
+ tags.append("reply")
626
+ if o.get("give_item"):
627
+ tags.append("item")
628
+ if o.get("gil") is not None:
629
+ tags.append(f"{int(o['gil']):+}g")
630
+ if o.get("set_flag"):
631
+ tags.append(f"flag {o['set_flag'][0]}")
632
+ return txt + (f" [{', '.join(tags)}]" if tags else "")