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