ff9mapkit 1.0.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. ff9mapkit/__init__.py +18 -0
  2. ff9mapkit/__main__.py +36 -0
  3. ff9mapkit/_animdb.py +2994 -0
  4. ff9mapkit/_animdb_all.py +14125 -0
  5. ff9mapkit/_fieldtable.py +1516 -0
  6. ff9mapkit/_fieldtext.py +845 -0
  7. ff9mapkit/_held_poses.py +44 -0
  8. ff9mapkit/_itemdb.py +65 -0
  9. ff9mapkit/_modeldb.py +725 -0
  10. ff9mapkit/_narrowmap_data.py +10 -0
  11. ff9mapkit/_npcparams.py +634 -0
  12. ff9mapkit/_regen_animdb.py +72 -0
  13. ff9mapkit/_regen_animdb_all.py +66 -0
  14. ff9mapkit/_regen_fieldtable.py +95 -0
  15. ff9mapkit/_regen_fieldtext.py +66 -0
  16. ff9mapkit/_regen_modeldb.py +67 -0
  17. ff9mapkit/_regen_npcparams.py +123 -0
  18. ff9mapkit/_regen_scenedb.py +57 -0
  19. ff9mapkit/_scenedb.py +869 -0
  20. ff9mapkit/abilities.py +225 -0
  21. ff9mapkit/animations.py +120 -0
  22. ff9mapkit/archetypes.py +218 -0
  23. ff9mapkit/areatitle.py +76 -0
  24. ff9mapkit/battle/__init__.py +21 -0
  25. ff9mapkit/battle/abilityfeatures.py +294 -0
  26. ff9mapkit/battle/actiondelta.py +441 -0
  27. ff9mapkit/battle/aiauthor.py +305 -0
  28. ff9mapkit/battle/ailint.py +140 -0
  29. ff9mapkit/battle/aipatch.py +175 -0
  30. ff9mapkit/battle/battleai.py +148 -0
  31. ff9mapkit/battle/battlecsv.py +390 -0
  32. ff9mapkit/battle/battlepatch.py +395 -0
  33. ff9mapkit/battle/build.py +558 -0
  34. ff9mapkit/battle/camera_codec.py +332 -0
  35. ff9mapkit/battle/camera_data.py +128 -0
  36. ff9mapkit/battle/characterdelta.py +789 -0
  37. ff9mapkit/battle/event_data.py +72 -0
  38. ff9mapkit/battle/extract.py +540 -0
  39. ff9mapkit/battle/fbx.py +223 -0
  40. ff9mapkit/battle/reskin.py +149 -0
  41. ff9mapkit/battle/scene_codec.py +314 -0
  42. ff9mapkit/battle/scene_data.py +369 -0
  43. ff9mapkit/battle/scenelint.py +125 -0
  44. ff9mapkit/battle/seqasm.py +131 -0
  45. ff9mapkit/battle/seqauthor.py +220 -0
  46. ff9mapkit/battle/seqcodec.py +300 -0
  47. ff9mapkit/battle/seqdis.py +106 -0
  48. ff9mapkit/battle/seqpatch.py +137 -0
  49. ff9mapkit/battle_bgm.py +133 -0
  50. ff9mapkit/binutils.py +60 -0
  51. ff9mapkit/build.py +5445 -0
  52. ff9mapkit/campaign.py +1276 -0
  53. ff9mapkit/catalog.py +316 -0
  54. ff9mapkit/chain.py +358 -0
  55. ff9mapkit/cli.py +3114 -0
  56. ff9mapkit/config.py +360 -0
  57. ff9mapkit/content/__init__.py +13 -0
  58. ff9mapkit/content/areatitle.py +36 -0
  59. ff9mapkit/content/ate.py +118 -0
  60. ff9mapkit/content/camera.py +123 -0
  61. ff9mapkit/content/chest.py +186 -0
  62. ff9mapkit/content/choice.py +163 -0
  63. ff9mapkit/content/conductor.py +217 -0
  64. ff9mapkit/content/cutscene.py +290 -0
  65. ff9mapkit/content/encounter.py +41 -0
  66. ff9mapkit/content/entry_settle.py +50 -0
  67. ff9mapkit/content/equipment.py +93 -0
  68. ff9mapkit/content/event.py +191 -0
  69. ff9mapkit/content/gateway.py +101 -0
  70. ff9mapkit/content/inventory.py +59 -0
  71. ff9mapkit/content/itemdata.py +644 -0
  72. ff9mapkit/content/itemtext.py +168 -0
  73. ff9mapkit/content/jump.py +114 -0
  74. ff9mapkit/content/ladder.py +633 -0
  75. ff9mapkit/content/movement.py +53 -0
  76. ff9mapkit/content/music.py +97 -0
  77. ff9mapkit/content/npc.py +348 -0
  78. ff9mapkit/content/object.py +340 -0
  79. ff9mapkit/content/onentry.py +135 -0
  80. ff9mapkit/content/party.py +111 -0
  81. ff9mapkit/content/pathfind.py +138 -0
  82. ff9mapkit/content/platform.py +314 -0
  83. ff9mapkit/content/player.py +168 -0
  84. ff9mapkit/content/prop.py +75 -0
  85. ff9mapkit/content/region.py +340 -0
  86. ff9mapkit/content/reinit.py +59 -0
  87. ff9mapkit/content/savepoint.py +90 -0
  88. ff9mapkit/content/shop.py +178 -0
  89. ff9mapkit/content/sps_trigger.py +66 -0
  90. ff9mapkit/content/startup.py +71 -0
  91. ff9mapkit/content/synthesis.py +106 -0
  92. ff9mapkit/content/text.py +183 -0
  93. ff9mapkit/content/textcarry.py +290 -0
  94. ff9mapkit/content/verbatim.py +86 -0
  95. ff9mapkit/content/walkmesh_hotfix.py +38 -0
  96. ff9mapkit/data/__init__.py +48 -0
  97. ff9mapkit/data/_regen_provenance.py +142 -0
  98. ff9mapkit/data/provenance/blank.es.patch +1 -0
  99. ff9mapkit/data/provenance/blank.fr.patch +1 -0
  100. ff9mapkit/data/provenance/blank.gr.patch +1 -0
  101. ff9mapkit/data/provenance/blank.it.patch +1 -0
  102. ff9mapkit/data/provenance/blank.jp.patch +1 -0
  103. ff9mapkit/data/provenance/blank.uk.patch +1 -0
  104. ff9mapkit/data/provenance/blank.us.patch +1 -0
  105. ff9mapkit/data/provenance/manifest.json +65 -0
  106. ff9mapkit/data/provenance/region_template.patch +1 -0
  107. ff9mapkit/data/reference_arcs.toml +89 -0
  108. ff9mapkit/data/region_catalog.toml +593 -0
  109. ff9mapkit/deploystack.py +358 -0
  110. ff9mapkit/dialogue.py +803 -0
  111. ff9mapkit/eb/__init__.py +12 -0
  112. ff9mapkit/eb/_exprtable.py +59 -0
  113. ff9mapkit/eb/_membertable.py +38 -0
  114. ff9mapkit/eb/_optables.py +537 -0
  115. ff9mapkit/eb/_regen_optables.py +76 -0
  116. ff9mapkit/eb/cmdasm.py +323 -0
  117. ff9mapkit/eb/disasm.py +332 -0
  118. ff9mapkit/eb/edit.py +439 -0
  119. ff9mapkit/eb/exprasm.py +158 -0
  120. ff9mapkit/eb/model.py +178 -0
  121. ff9mapkit/eb/opcodes.py +463 -0
  122. ff9mapkit/eblint.py +177 -0
  123. ff9mapkit/editor/__init__.py +20 -0
  124. ff9mapkit/editor/app.py +950 -0
  125. ff9mapkit/editor/battle_forms.py +240 -0
  126. ff9mapkit/editor/breadcrumb.py +89 -0
  127. ff9mapkit/editor/dialogs.py +116 -0
  128. ff9mapkit/editor/feedback.py +208 -0
  129. ff9mapkit/editor/forms.py +632 -0
  130. ff9mapkit/editor/graphview.py +350 -0
  131. ff9mapkit/editor/jobs.py +342 -0
  132. ff9mapkit/editor/model.py +243 -0
  133. ff9mapkit/editor/picker.py +120 -0
  134. ff9mapkit/editor/theme.py +212 -0
  135. ff9mapkit/eventscan.py +1441 -0
  136. ff9mapkit/extract.py +2279 -0
  137. ff9mapkit/flags.py +693 -0
  138. ff9mapkit/forkreport.py +1383 -0
  139. ff9mapkit/hub.py +477 -0
  140. ff9mapkit/idgated.py +101 -0
  141. ff9mapkit/infohub.py +580 -0
  142. ff9mapkit/items.py +63 -0
  143. ff9mapkit/itemstats.py +346 -0
  144. ff9mapkit/journey.py +1902 -0
  145. ff9mapkit/keyitems.py +93 -0
  146. ff9mapkit/logic_add.py +632 -0
  147. ff9mapkit/logic_edit.py +728 -0
  148. ff9mapkit/logic_map.py +526 -0
  149. ff9mapkit/pack.py +175 -0
  150. ff9mapkit/playerswap.py +231 -0
  151. ff9mapkit/prop_archetypes.py +228 -0
  152. ff9mapkit/provision.py +282 -0
  153. ff9mapkit/refarc.py +825 -0
  154. ff9mapkit/save.py +337 -0
  155. ff9mapkit/save_items.py +1673 -0
  156. ff9mapkit/scene/__init__.py +11 -0
  157. ff9mapkit/scene/arena.py +63 -0
  158. ff9mapkit/scene/bgart.py +140 -0
  159. ff9mapkit/scene/bgi.py +732 -0
  160. ff9mapkit/scene/bgs.py +174 -0
  161. ff9mapkit/scene/bgx.py +185 -0
  162. ff9mapkit/scene/cam.py +345 -0
  163. ff9mapkit/scene/guide.py +311 -0
  164. ff9mapkit/scene/paint.py +506 -0
  165. ff9mapkit/scene/placeholder.py +107 -0
  166. ff9mapkit/sjbinary.py +285 -0
  167. ff9mapkit/sps/__init__.py +17 -0
  168. ff9mapkit/sps/author.py +294 -0
  169. ff9mapkit/sps/catalog.py +88 -0
  170. ff9mapkit/sps/codec.py +264 -0
  171. ff9mapkit/sps/edit.py +184 -0
  172. ff9mapkit/sps/lint.py +58 -0
  173. ff9mapkit/sps/render.py +116 -0
  174. ff9mapkit/sps/templates.py +47 -0
  175. ff9mapkit/sps/texture.py +131 -0
  176. ff9mapkit/walkmesh_hotfixes.py +163 -0
  177. ff9mapkit/workspace/__init__.py +18 -0
  178. ff9mapkit/workspace/battledoc.py +985 -0
  179. ff9mapkit/workspace/builddoc.py +607 -0
  180. ff9mapkit/workspace/forms_qt.py +586 -0
  181. ff9mapkit/workspace/importdoc.py +665 -0
  182. ff9mapkit/workspace/mapview.py +131 -0
  183. ff9mapkit/workspace/palette.py +85 -0
  184. ff9mapkit/workspace/savedoc.py +664 -0
  185. ff9mapkit/workspace/shell.py +6907 -0
  186. ff9mapkit/workspace/style.py +105 -0
  187. ff9mapkit/workspace/tuningdialog.py +223 -0
  188. ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
  189. ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
  190. ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
  191. ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
  192. ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
  193. ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
ff9mapkit/sjbinary.py ADDED
@@ -0,0 +1,285 @@
1
+ """SimpleJSON BINARY codec -- the format Memoria's ``JSONNode.Serialize`` / ``JSONNode.Deserialize`` use for
2
+ the unencrypted per-slot extra save file ``SavedData_ww_Memoria_{slot}_{save}.dat``.
3
+
4
+ This is the foundation for the #5 save-side item/equipment/gil editor: the extra file is what the game LOADS
5
+ (it overrides the encrypted main block -- memory project-ff9-save-item-layout), and it stores items/equip/gil
6
+ as a nested SimpleJSON tree, so editing it needs a real parse -> mutate -> re-serialize, NOT an in-place byte
7
+ patch. This module is the parse/serialize half; the editor (a separate ``save_items`` surface) layers on top.
8
+
9
+ Byte-exact with C# ``BinaryWriter`` (verified by a round-trip test against the real file):
10
+
11
+ * tags + counts are **Int32 LE** (``BinaryWriter.Write((int)...)``);
12
+ * strings are **.NET 7-bit-length-prefixed UTF-8** (``BinaryWriter.Write(string)`` / ``BinaryReader.ReadString``):
13
+ a 7-bit-encoded (LEB128) BYTE length then the UTF-8 bytes;
14
+ * leaves are tagged: ``Value=3`` string, ``IntValue=4`` Int32, ``DoubleValue=5`` Double, ``BoolValue=6`` Bool
15
+ (1 byte), ``FloatValue=7`` Single. (``Array=1`` = count + children; ``Class=2`` = count + key/value pairs.)
16
+
17
+ ★ FIDELITY: ``JSONData.Serialize`` RE-INFERS a leaf's tag from its string value on every write (int->4, float->7,
18
+ double->5, bool->6, else string->3). We instead **preserve each leaf's READ tag** -- which already equals what
19
+ re-inference produces, because C# wrote the on-disk file via that same inference (so no Value(3) on disk ever
20
+ holds a parseable number). Preserving the read tag + the ordered keys means an UNCHANGED tree re-serializes
21
+ byte-for-byte identically; we never reproduce C#'s float/culture formatting. A changed int leaf emits ``IntValue``
22
+ (tag 4) -- exactly what C# does for an int.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import io
27
+ import struct
28
+
29
+ # JSONBinaryTag (SimpleJSON/JSONBinaryTag.cs)
30
+ ARRAY = 1
31
+ CLASS = 2
32
+ VALUE = 3 # string
33
+ INT = 4 # Int32
34
+ DOUBLE = 5
35
+ BOOL = 6
36
+ FLOAT = 7 # Single
37
+
38
+
39
+ class SJData:
40
+ """A leaf node: a tag (VALUE/INT/DOUBLE/BOOL/FLOAT) + its typed Python value (str/int/float/bool)."""
41
+ __slots__ = ("tag", "value")
42
+
43
+ def __init__(self, tag: int, value):
44
+ self.tag = tag
45
+ self.value = value
46
+
47
+ def __repr__(self):
48
+ return f"SJData(tag={self.tag}, value={self.value!r})"
49
+
50
+
51
+ class SJArray:
52
+ """An ordered list of child nodes (JSONArray)."""
53
+ __slots__ = ("items",)
54
+
55
+ def __init__(self, items=None):
56
+ self.items = list(items) if items is not None else []
57
+
58
+ def __len__(self):
59
+ return len(self.items)
60
+
61
+ def __iter__(self):
62
+ return iter(self.items)
63
+
64
+ def __repr__(self):
65
+ return f"SJArray({len(self.items)} items)"
66
+
67
+
68
+ class SJClass:
69
+ """An ORDERED string->node map (JSONClass). Key order is preserved (C# Dictionary iterates in insertion
70
+ order for an add-only save tree), which is what makes an unchanged re-serialize byte-identical."""
71
+ __slots__ = ("_items", "_idx")
72
+
73
+ def __init__(self):
74
+ self._items: list = [] # [(key, node), ...] in on-disk order
75
+ self._idx: dict = {} # key -> index in _items
76
+
77
+ def add(self, key: str, node) -> None:
78
+ """Append a key/node in order (used by the parser; a duplicate key replaces in place, mirroring a dict)."""
79
+ if key in self._idx:
80
+ self._items[self._idx[key]] = (key, node)
81
+ else:
82
+ self._idx[key] = len(self._items)
83
+ self._items.append((key, node))
84
+
85
+ def get(self, key: str):
86
+ i = self._idx.get(key)
87
+ return self._items[i][1] if i is not None else None
88
+
89
+ def set(self, key: str, node) -> None:
90
+ """Replace an existing key's node in place (keeps order). Raises KeyError if the key is absent (the
91
+ editor only mutates existing leaves -- adding a new key would change the layout)."""
92
+ if key not in self._idx:
93
+ raise KeyError(f"key {key!r} not in this SJClass (keys: {list(self._idx)})")
94
+ i = self._idx[key]
95
+ self._items[i] = (key, node)
96
+
97
+ def keys(self):
98
+ return [k for k, _ in self._items]
99
+
100
+ def __contains__(self, key):
101
+ return key in self._idx
102
+
103
+ def __iter__(self):
104
+ return iter(self._items) # (key, node) pairs, in order
105
+
106
+ def __len__(self):
107
+ return len(self._items)
108
+
109
+ def __repr__(self):
110
+ return f"SJClass(keys={self.keys()})"
111
+
112
+
113
+ # --- low-level .NET BinaryReader/Writer primitives ------------------------------------------------
114
+
115
+ def _read_7bit(r) -> int:
116
+ """.NET ``Read7BitEncodedInt`` -- LEB128 unsigned (low 7 bits per byte, high bit = continue)."""
117
+ val = 0
118
+ shift = 0
119
+ while True:
120
+ b = r.read(1)
121
+ if not b:
122
+ raise ValueError("unexpected EOF reading a 7-bit length")
123
+ b = b[0]
124
+ val |= (b & 0x7F) << shift
125
+ if not (b & 0x80):
126
+ return val
127
+ shift += 7
128
+ if shift > 35:
129
+ raise ValueError("7-bit length too long (corrupt stream)")
130
+
131
+
132
+ def _write_7bit(w, n: int) -> None:
133
+ if n < 0:
134
+ raise ValueError("string length cannot be negative")
135
+ while n >= 0x80:
136
+ w.write(bytes([(n & 0x7F) | 0x80]))
137
+ n >>= 7
138
+ w.write(bytes([n]))
139
+
140
+
141
+ def _read_string(r) -> str:
142
+ n = _read_7bit(r)
143
+ raw = r.read(n)
144
+ if len(raw) != n:
145
+ raise ValueError(f"unexpected EOF reading a {n}-byte string")
146
+ return raw.decode("utf-8")
147
+
148
+
149
+ def _write_string(w, s: str) -> None:
150
+ raw = s.encode("utf-8")
151
+ _write_7bit(w, len(raw))
152
+ w.write(raw)
153
+
154
+
155
+ def _read_i32(r) -> int:
156
+ raw = r.read(4)
157
+ if len(raw) != 4:
158
+ raise ValueError("unexpected EOF reading an Int32")
159
+ return struct.unpack("<i", raw)[0]
160
+
161
+
162
+ def _write_i32(w, v: int) -> None:
163
+ w.write(struct.pack("<i", v))
164
+
165
+
166
+ # --- tree (de)serialization (mirrors JSONNode.Deserialize / *.Serialize) --------------------------
167
+
168
+ def _deserialize(r):
169
+ tag = _read_i32(r)
170
+ if tag == ARRAY:
171
+ n = _read_i32(r)
172
+ return SJArray([_deserialize(r) for _ in range(n)])
173
+ if tag == CLASS:
174
+ n = _read_i32(r)
175
+ c = SJClass()
176
+ for _ in range(n):
177
+ key = _read_string(r)
178
+ c.add(key, _deserialize(r))
179
+ return c
180
+ if tag == VALUE:
181
+ return SJData(VALUE, _read_string(r))
182
+ if tag == INT:
183
+ return SJData(INT, _read_i32(r))
184
+ if tag == DOUBLE:
185
+ return SJData(DOUBLE, struct.unpack("<d", r.read(8))[0])
186
+ if tag == BOOL:
187
+ return SJData(BOOL, r.read(1)[0] != 0)
188
+ if tag == FLOAT:
189
+ return SJData(FLOAT, struct.unpack("<f", r.read(4))[0])
190
+ raise ValueError(f"unknown SimpleJSON tag {tag} at offset {r.tell() - 4}")
191
+
192
+
193
+ def _serialize(node, w) -> None:
194
+ if isinstance(node, SJArray):
195
+ _write_i32(w, ARRAY)
196
+ _write_i32(w, len(node.items))
197
+ for it in node.items:
198
+ _serialize(it, w)
199
+ elif isinstance(node, SJClass):
200
+ _write_i32(w, CLASS)
201
+ _write_i32(w, len(node._items))
202
+ for key, child in node._items:
203
+ _write_string(w, key)
204
+ _serialize(child, w)
205
+ elif isinstance(node, SJData):
206
+ _write_i32(w, node.tag)
207
+ if node.tag == VALUE:
208
+ _write_string(w, node.value)
209
+ elif node.tag == INT:
210
+ _write_i32(w, int(node.value))
211
+ elif node.tag == DOUBLE:
212
+ w.write(struct.pack("<d", float(node.value)))
213
+ elif node.tag == BOOL:
214
+ w.write(b"\x01" if node.value else b"\x00")
215
+ elif node.tag == FLOAT:
216
+ w.write(struct.pack("<f", float(node.value)))
217
+ else:
218
+ raise ValueError(f"cannot serialize leaf with tag {node.tag}")
219
+ else:
220
+ raise TypeError(f"not an SJ node: {type(node).__name__}")
221
+
222
+
223
+ # --- public API -----------------------------------------------------------------------------------
224
+
225
+ def loads(data: bytes):
226
+ """Parse the SimpleJSON-binary ``data`` into a node tree. Returns ``(root, trailing)`` -- ``trailing`` is
227
+ any bytes after the single root tree (normally empty; preserved so a re-emit is byte-exact even if Memoria
228
+ ever pads the file)."""
229
+ r = io.BytesIO(data)
230
+ root = _deserialize(r)
231
+ trailing = r.read()
232
+ return root, trailing
233
+
234
+
235
+ def dumps(root, trailing: bytes = b"") -> bytes:
236
+ """Serialize ``root`` (+ any preserved ``trailing`` bytes) back to SimpleJSON-binary bytes."""
237
+ w = io.BytesIO()
238
+ _serialize(root, w)
239
+ w.write(trailing)
240
+ return w.getvalue()
241
+
242
+
243
+ def get_path(root, *keys):
244
+ """Walk a path of string keys (SJClass) / int indices (SJArray) and return the node, or ``None`` if any
245
+ step is missing. e.g. ``get_path(root, "40000_Common", "players", 0, "equip")``."""
246
+ node = root
247
+ for k in keys:
248
+ if isinstance(k, int) and isinstance(node, SJArray):
249
+ if k < 0 or k >= len(node.items):
250
+ return None
251
+ node = node.items[k]
252
+ elif isinstance(node, SJClass):
253
+ node = node.get(k)
254
+ else:
255
+ return None
256
+ if node is None:
257
+ return None
258
+ return node
259
+
260
+
261
+ def diff_paths(a, b, _prefix=()):
262
+ """Yield the path (a tuple of string keys / int indices) of every spot where trees ``a`` and ``b`` differ.
263
+ A *structural* difference -- a different node type, a different SJClass key list, or a different SJArray
264
+ length -- yields the path of the differing node ITSELF and does not recurse into it; otherwise recursion
265
+ continues and differing leaves yield their own path. Used to VERIFY a save edit is scoped: an edit may only
266
+ touch paths under a known prefix (e.g. ``("40000_Common","items")``); anything else changing => abort."""
267
+ if type(a) is not type(b):
268
+ yield _prefix
269
+ return
270
+ if isinstance(a, SJData):
271
+ if a.tag != b.tag or a.value != b.value:
272
+ yield _prefix
273
+ elif isinstance(a, SJArray):
274
+ if len(a.items) != len(b.items):
275
+ yield _prefix # a length change => the array itself changed
276
+ else:
277
+ for i, (x, y) in enumerate(zip(a.items, b.items)):
278
+ yield from diff_paths(x, y, _prefix + (i,))
279
+ elif isinstance(a, SJClass):
280
+ if a.keys() != b.keys():
281
+ yield _prefix # a key added/removed/reordered => the class changed
282
+ else:
283
+ for k, _ in a._items:
284
+ yield from diff_paths(a.get(k), b.get(k), _prefix + (k,))
285
+ # any other type: treated as equal (the tree only holds SJData/SJArray/SJClass)
@@ -0,0 +1,17 @@
1
+ """FF9 field ``.sps`` (Special Particle System) effect authoring -- the particle-effect analogue of the
2
+ field / battle pillars. An ``.sps`` is a multi-frame cloud of 2D textured billboard quads ("prims") sampling
3
+ the shared per-scene ``spt.tcb`` texture; the field ``.eb`` fires ``RunSPSCode`` to load, place, and loop it
4
+ (~15 fps). This package owns the ``.sps`` GEOMETRY/ANIMATION bytes -- the half we fully control.
5
+
6
+ ``codec`` is the lossless parse / serialize / build foundation (proven byte-exact vs the engine source --
7
+ ``SPSEffect.LoadSPS`` / ``_GenerateSPSPrims`` -- and a 9-file Ice-Cavern corpus). The texture (``spt.tcb``,
8
+ a PSX-VRAM blit blob) is a SEPARATE format and the real authoring gate; see [[project-ff9-sps-authoring]]
9
+ for the tiered editor plan (catalog browser -> declarative re-skin -> from-scratch creator).
10
+
11
+ NOTE: the kit already CARRIES a fork's ``.sps`` + ``spt.tcb`` verbatim (``extract.py`` / ``build.py``) so a
12
+ forked field keeps its donor's effects ([[project-ff9-sps-fork]]). This package is the from-scratch AUTHORING
13
+ half that the verbatim byte-copy doesn't cover.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from . import codec # noqa: F401 (pure, no I/O -- safe to import always)
@@ -0,0 +1,294 @@
1
+ """``[[sps]]`` -- author a NEW from-scratch field particle effect (Tier 2 creator), NO DLL.
2
+
3
+ A ``[[sps]]`` block defines a brand-new effect: its GEOMETRY (a clone of an existing donor effect, re-authored;
4
+ or a fully inline quad cloud via :func:`codec.build`) over a REUSED / BORROWED ``spt.tcb`` texture. This is
5
+ **Route A** -- the new effect's ``tpage``/``clut``/UVs index pixels that already exist in some field's texture,
6
+ so it draws purely from the TCB already in VRAM (``CommonSPSSystem.SetupSPSTexture`` falls through to the TCB for
7
+ any id not in the hardcoded ``SPSConst.SPSTexture`` dict). Guaranteed no-DLL.
8
+
9
+ The build writes ``<id>.sps.bytes`` + supplies the ``spt.tcb`` into ``FieldMaps/<FBG>/`` (``build._write_authored_sps``)
10
+ and emits a ``RunSPSCode`` create+place trigger into the field's ``.eb`` (``content/sps_trigger.py``) so the effect
11
+ spawns on field load. **Route B** (a custom PNG for genuinely NEW art) needs a Memoria patch -- the spsId->texture
12
+ map is a hardcoded engine dict with no data hook -- so a ``texture = { png = ... }`` block is rejected with that
13
+ pointer. -> [[project-ff9-sps-authoring]], docs/SPS.md.
14
+
15
+ Schema (``field.toml``)::
16
+
17
+ # Clone a real donor effect's texture + colours, re-author the animation (the easiest creator primitive).
18
+ [[sps]]
19
+ id = 5000
20
+ copy_from = { field = "303", sps = 2266 } # take tpage/clut/uv/rgb/size from Ice-Cavern effect 2266
21
+ frames = [ [ {pos=[0,0], uv=0, rgb=1} ], [ {pos=[2,-2], uv=0, rgb=2} ] ] # optional: new geometry
22
+ pos = [120, 30, -40] # world x, y, z (engine negates y); or [x, z] + y = N
23
+ slot = 15
24
+ abr = 1 # 0=50%add 1=add 2=sub 3=25%add (omit = leave default)
25
+ framerate = 16 # 16 = 1x
26
+
27
+ # Fully inline (power user): borrow a donor's tcb, author every byte via codec.build.
28
+ [[sps]]
29
+ id = 5001
30
+ texture = { borrow_tcb = "303", tpage = { tp = 0, tx = 8, ty = 1 }, clut = { cluty = 251, clutx = 20 } }
31
+ size = [9, 9]
32
+ uv = [[0, 96], [32, 96]]
33
+ rgb = [[255, 200, 80], [255, 120, 0]]
34
+ frames = [ [ {pos=[0,0], uv=0, rgb=0} ], [ {pos=[2,-1], uv=1, rgb=1} ] ]
35
+ pos = [0, 0, 0]
36
+ slot = 14
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import copy
41
+
42
+ from . import codec as _codec
43
+ from .lint import lint_sps
44
+
45
+ # placement defaults: a high SPS slot avoids colliding with a donor's low-slot effects on a verbatim fork
46
+ DEFAULT_SLOT = 15
47
+ DEFAULT_ABR = 1 # ADDITIVE -- particle effects glow; the engine default (ABR_OFF) renders opaque black boxes
48
+ SCALE_ONE = 4096
49
+ FRAMERATE_ONE = 16
50
+
51
+
52
+ class SpsAuthorError(ValueError):
53
+ pass
54
+
55
+
56
+ def _block_list(blocks):
57
+ if not isinstance(blocks, list):
58
+ raise SpsAuthorError("[[sps]] must be an array of tables ([[sps]], not [sps])")
59
+ for n, b in enumerate(blocks):
60
+ if not isinstance(b, dict):
61
+ raise SpsAuthorError(f"[[sps]] #{n} must be a table, got {type(b).__name__}")
62
+ return blocks
63
+
64
+
65
+ def _int(b, key, ctx):
66
+ v = b.get(key)
67
+ if not isinstance(v, int) or isinstance(v, bool):
68
+ raise SpsAuthorError(f"{ctx}: {key!r} must be an integer, got {v!r}")
69
+ return v
70
+
71
+
72
+ def make_donor_loader(carried_dir=None):
73
+ """A ``donor_loader`` for :func:`build_sps_from_block`: ``field=None`` -> clone a CARRIED effect (read
74
+ ``carried_dir/<sps>.sps.bytes`` -- reuses the field's own texture, no tcb conflict); else load from the
75
+ install. The build passes ``carried_dir = <member>/sps`` so ``copy_from = {{ sps = N }}`` works."""
76
+ from pathlib import Path
77
+
78
+ def loader(field_token, sps_id):
79
+ if field_token is not None:
80
+ return _default_donor_loader(field_token, sps_id)
81
+ if carried_dir is None:
82
+ raise SpsAuthorError(f"copy_from sps={sps_id} (no field) clones a CARRIED effect, which needs the "
83
+ "field's sps/ sidecar -- not available here")
84
+ d = Path(carried_dir)
85
+ p = d / f"{int(sps_id)}.sps.bytes"
86
+ if not p.is_file():
87
+ avail = sorted(x.name[: -len('.sps.bytes')] for x in d.glob("*.sps.bytes")) if d.is_dir() else []
88
+ raise SpsAuthorError(f"copy_from sps={sps_id}: this field carries no {sps_id}.sps.bytes "
89
+ f"(carried effects: {avail or 'none'})")
90
+ return _codec.parse(p.read_bytes())
91
+ return loader
92
+
93
+
94
+ def _default_donor_loader(field_token, sps_id) -> _codec.Sps:
95
+ """Load donor ``field``'s effect ``sps_id`` to a codec model, live from the install (for ``copy_from``)."""
96
+ if field_token is None:
97
+ raise SpsAuthorError(f"copy_from sps={sps_id} (no field) clones a CARRIED effect -- needs the field's "
98
+ "sps/ sidecar (use the build path, not the bare loader)")
99
+ from . import catalog as _cat
100
+ rows = _cat.list_field_sps(field_token)
101
+ entry = next((e for e in rows if e.sps_id == int(sps_id)), None)
102
+ if entry is None:
103
+ have = [e.sps_id for e in rows]
104
+ raise SpsAuthorError(f"copy_from: field {field_token!r} has no SPS effect {sps_id} "
105
+ + (f"(has: {have})" if have else "(no install / not readable)"))
106
+ return _cat.load_sps(entry)
107
+
108
+
109
+ def _parse_frames(frames, ctx) -> list:
110
+ """``[[{pos=[x,y], uv=I, rgb=J}, ...], ...]`` -> ``list[list[codec.Prim]]``."""
111
+ if not isinstance(frames, list) or not frames:
112
+ raise SpsAuthorError(f"{ctx}: 'frames' must be a non-empty array of frames")
113
+ out = []
114
+ for fi, frame in enumerate(frames):
115
+ if not isinstance(frame, list):
116
+ raise SpsAuthorError(f"{ctx}: frame {fi} must be an array of prims")
117
+ prims = []
118
+ for pi, p in enumerate(frame):
119
+ if not isinstance(p, dict):
120
+ raise SpsAuthorError(f"{ctx}: frame {fi} prim {pi} must be a table {{pos, uv, rgb}}")
121
+ pos = p.get("pos")
122
+ if not (isinstance(pos, list) and len(pos) == 2 and all(isinstance(c, int) for c in pos)):
123
+ raise SpsAuthorError(f"{ctx}: frame {fi} prim {pi} 'pos' must be [x, y] ints, got {pos!r}")
124
+ uv = p.get("uv", 0)
125
+ rgb = p.get("rgb", 0)
126
+ if not all(isinstance(v, int) and not isinstance(v, bool) for v in (uv, rgb)):
127
+ raise SpsAuthorError(f"{ctx}: frame {fi} prim {pi} uv/rgb must be integer indices")
128
+ try:
129
+ prims.append(_codec.prim(pos[0], pos[1], uv, rgb))
130
+ except _codec.SpsCodecError as ex:
131
+ raise SpsAuthorError(f"{ctx}: frame {fi} prim {pi}: {ex}") from ex
132
+ out.append(prims)
133
+ return out
134
+
135
+
136
+ def _texture_words(texture, ctx):
137
+ """Resolve ``tpage``/``clut`` (raw ints or fielded tables) to the two u16 words."""
138
+ def word(key, maker):
139
+ v = texture.get(key)
140
+ if isinstance(v, int) and not isinstance(v, bool):
141
+ return v
142
+ if isinstance(v, dict):
143
+ return maker(**{k: int(n) for k, n in v.items()})
144
+ raise SpsAuthorError(f"{ctx}: texture.{key} must be a u16 int or a fielded table, got {v!r}")
145
+ return word("tpage", _codec.make_tpage), word("clut", _codec.make_clut)
146
+
147
+
148
+ def build_sps_from_block(block: dict, *, donor_loader=None) -> _codec.Sps:
149
+ """Resolve one ``[[sps]]`` block to a :class:`codec.Sps`. ``copy_from`` clones a donor effect (then an
150
+ optional ``frames``/``rgb``/``size`` override); otherwise an inline ``texture``+``size``+``uv``+``rgb``+
151
+ ``frames`` is built via :func:`codec.build`. Lints the result (raises :class:`SpsAuthorError` on a problem)."""
152
+ if "id" not in block:
153
+ raise SpsAuthorError("[[sps]]: every block needs an integer `id` (the effect id)")
154
+ sid = _int(block, "id", "[[sps]]")
155
+ ctx = f"[[sps]] id={sid}"
156
+ if "png" in (block.get("texture") or {}):
157
+ raise SpsAuthorError(f"{ctx}: texture.png (Route B, new art) needs a Memoria SPSConst.SPSTexture "
158
+ "registration patch -- not yet supported. Use copy_from / texture.borrow_tcb (Route A).")
159
+ loader = donor_loader or _default_donor_loader
160
+
161
+ # A geometry source: a named TEMPLATE or an explicit copy_from -> a (field, sps) donor to clone; else inline.
162
+ cf = _resolve_clone_source(block, ctx)
163
+ if cf is not None:
164
+ if "texture" in block or "uv" in block:
165
+ raise SpsAuthorError(f"{ctx}: template/copy_from is exclusive with an inline texture/uv "
166
+ "(it reuses the donor's). frames/rgb/size are allowed as overrides.")
167
+ model = copy.deepcopy(loader(cf.get("field"), cf["sps"])) # field=None -> a carried-effect clone
168
+ model.frame_offsets = None # re-laid canonically on serialize
169
+ model.tail = b""
170
+ if "frames" in block: # optional geometry / colour / size overrides
171
+ model.frames = _parse_frames(block["frames"], ctx)
172
+ if "rgb" in block:
173
+ model.rgb_table = [(*_rgb3(c, ctx), 0) for c in block["rgb"]]
174
+ if "size" in block:
175
+ model.h_raw, model.w_raw = _size(block["size"], ctx)
176
+ else:
177
+ for req in ("texture", "size", "uv", "rgb", "frames"):
178
+ if req not in block:
179
+ raise SpsAuthorError(f"{ctx}: inline effect needs {req!r} (or use copy_from)")
180
+ tpage_raw, clut_raw = _texture_words(block["texture"], ctx)
181
+ h_raw, w_raw = _size(block["size"], ctx)
182
+ model = _codec.build(
183
+ tpage_raw=tpage_raw, clut_raw=clut_raw, h_raw=h_raw, w_raw=w_raw,
184
+ uv_table=[tuple(_xy(c, ctx)) for c in block["uv"]],
185
+ rgb_table=[(*_rgb3(c, ctx), 0) for c in block["rgb"]],
186
+ frames=_parse_frames(block["frames"], ctx),
187
+ )
188
+ problems = lint_sps(model)
189
+ if problems:
190
+ raise SpsAuthorError(f"{ctx}: invalid effect -- " + "; ".join(problems))
191
+ return model
192
+
193
+
194
+ def _rgb3(c, ctx):
195
+ if not (isinstance(c, list) and len(c) == 3 and all(isinstance(v, int) and 0 <= v <= 255 for v in c)):
196
+ raise SpsAuthorError(f"{ctx}: an rgb entry must be [r, g, b] ints 0..255, got {c!r}")
197
+ return c[0], c[1], c[2]
198
+
199
+
200
+ def _xy(c, ctx):
201
+ if not (isinstance(c, list) and len(c) == 2 and all(isinstance(v, int) and 0 <= v <= 255 for v in c)):
202
+ raise SpsAuthorError(f"{ctx}: a uv entry must be [x, y] ints 0..255, got {c!r}")
203
+ return c[0], c[1]
204
+
205
+
206
+ def _size(s, ctx):
207
+ if not (isinstance(s, list) and len(s) == 2 and all(isinstance(v, int) and 1 <= v <= 255 for v in s)):
208
+ raise SpsAuthorError(f"{ctx}: 'size' must be [h_raw, w_raw] ints 1..255, got {s!r}")
209
+ return s[0], s[1]
210
+
211
+
212
+ def _resolve_clone_source(block: dict, ctx: str):
213
+ """A ``template`` name / a ``clone_sps`` carried-effect id / an explicit ``copy_from`` -> a donor to clone,
214
+ or ``None`` for an inline effect. The three are mutually exclusive. ``clone_sps = N`` is the form-friendly
215
+ flat alias for ``copy_from = {{ sps = N }}`` (clone one of THIS field's carried effects)."""
216
+ has_t, has_cf = "template" in block, "copy_from" in block
217
+ has_clone = block.get("clone_sps") is not None
218
+ if sum((has_t, has_cf, has_clone)) > 1:
219
+ raise SpsAuthorError(f"{ctx}: use ONE of `template` / `clone_sps` / `copy_from`")
220
+ if has_clone:
221
+ v = block["clone_sps"]
222
+ if not isinstance(v, int) or isinstance(v, bool):
223
+ raise SpsAuthorError(f"{ctx}: clone_sps must be a carried-effect id (integer), got {v!r}")
224
+ return {"sps": v} # field=None -> a carried-effect clone
225
+ if has_t:
226
+ from . import templates as _tpl
227
+ try:
228
+ t = _tpl.resolve(block["template"])
229
+ except KeyError:
230
+ raise SpsAuthorError(f"{ctx}: unknown template {block['template']!r} (known: {sorted(_tpl.TEMPLATES)})")
231
+ return {"field": t.field, "sps": t.sps}
232
+ if has_cf:
233
+ cf = block["copy_from"]
234
+ if not (isinstance(cf, dict) and "sps" in cf):
235
+ raise SpsAuthorError(f"{ctx}: copy_from must be {{ sps = <id> }} (clone one of THIS field's carried "
236
+ "effects, reusing its texture) or {{ field = <token>, sps = <id> }} (clone a "
237
+ "donor field's effect)")
238
+ return cf
239
+ return None
240
+
241
+
242
+ def tcb_source(block: dict) -> tuple[str, str | None]:
243
+ """How to supply the effect's ``spt.tcb``: ``("borrow", donor_token)`` (a ``template`` / ``copy_from`` /
244
+ ``texture.borrow_tcb``) or ``("reuse", None)`` (use the field's already-carried tcb)."""
245
+ cf = _resolve_clone_source(block, "[[sps]]")
246
+ if cf is not None:
247
+ if cf.get("field") is None: # a carried-effect clone (copy_from = { sps = N })
248
+ return "reuse", None # reuses the field's already-carried tcb -- no conflict
249
+ return "borrow", str(cf["field"])
250
+ tex = block.get("texture") or {}
251
+ if tex.get("borrow_tcb") is not None:
252
+ return "borrow", str(tex["borrow_tcb"])
253
+ return "reuse", None
254
+
255
+
256
+ def trigger_spec(block: dict, *, slot: int | None = None) -> dict:
257
+ """The placement -> the ``RunSPSCode`` create+place spec (consumed by ``content.sps_trigger``)."""
258
+ sid = _int(block, "id", "[[sps]]")
259
+ ctx = f"[[sps]] id={sid}"
260
+ pos = block.get("pos", [0, 0, 0])
261
+ if not (isinstance(pos, list) and len(pos) in (2, 3) and all(isinstance(c, int) for c in pos)):
262
+ raise SpsAuthorError(f"{ctx}: 'pos' must be [x, y, z] (or [x, z] with separate y=), got {pos!r}")
263
+ if len(pos) == 3:
264
+ x, y, z = pos
265
+ else:
266
+ x, z = pos
267
+ y = int(block.get("y", 0))
268
+ for k in ("x", "y", "z"):
269
+ v = {"x": x, "y": y, "z": z}[k]
270
+ if not -32768 <= v <= 32767:
271
+ raise SpsAuthorError(f"{ctx}: pos {k}={v} out of the i16 range RunSPSCode carries")
272
+ spec = {"slot": slot if slot is not None else int(block.get("slot", DEFAULT_SLOT)),
273
+ "sps_id": sid, "pos": (x, y, z)}
274
+ # ABR default = ADDITIVE (1): a particle effect should glow (transparent black, additive brights). The
275
+ # engine's own default is ABR_OFF = OPAQUE, which renders the quads as solid black boxes -- never right for
276
+ # a flame/smoke/glow. Set abr explicitly for another blend (0=50%add, 2=sub, 3=25%add, >=4/15 = opaque).
277
+ spec["abr"] = _int(block, "abr", ctx) if "abr" in block else DEFAULT_ABR
278
+ for key in ("framerate", "scale"):
279
+ if key in block:
280
+ spec[key] = _int(block, key, ctx)
281
+ if not 0 <= spec["slot"] <= 15:
282
+ raise SpsAuthorError(f"{ctx}: slot {spec['slot']} out of range 0..15 (FIELD_DEFAULT_OBJCOUNT=16)")
283
+ return spec
284
+
285
+
286
+ def validate_sps_block(block: dict, *, donor_loader=None) -> list[str]:
287
+ """Offline problems for one ``[[sps]]`` block (empty == OK). Never raises (build-safe). The donor read is
288
+ install-gated, so a copy_from/borrow whose donor can't be read offline degrades to a clean message."""
289
+ try:
290
+ build_sps_from_block(block, donor_loader=donor_loader)
291
+ trigger_spec(block)
292
+ return []
293
+ except (SpsAuthorError, _codec.SpsCodecError) as ex:
294
+ return [str(ex)]