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/sps/catalog.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Live enumeration of a field's ``.sps`` effects from the install (Tier 0 catalog). INSTALL-GATED, like
|
|
2
|
+
``extract.py``: nothing is baked -- the ``.sps``/``spt.tcb`` bytes are Square-Enix game data (gitignored, never
|
|
3
|
+
shipped), and there is no Memoria name-table to transcribe -- so this reads the bundle on demand and degrades to
|
|
4
|
+
``[]`` / ``None`` with no install or UnityPy. Reuses the same enumeration trio ``extract.write_native_project``
|
|
5
|
+
uses: ``find_field`` -> ``env.container`` prefix filter -> ``_raw_bytes``. -> [[project-ff9-sps-authoring]].
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from . import codec
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class SpsEntry:
|
|
16
|
+
field_token: str # what import/extract takes (a field id or an FBG/mapid token)
|
|
17
|
+
folder: str # the donor FBG folder the effect lives under
|
|
18
|
+
sps_id: int # the numeric .sps id (filename stem == the RunSPSCode arg)
|
|
19
|
+
container_key: str # the env.container key, for a lazy byte read
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _fieldmaps_prefix(folder: str) -> str:
|
|
23
|
+
return f"assets/resources/fieldmaps/{folder}/"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def list_field_sps(field, *, game=None, bundle=None) -> list[SpsEntry]:
|
|
27
|
+
"""Every ``.sps`` effect bin in a field's FBG folder, sorted by id. ``[]`` if the install/UnityPy is absent
|
|
28
|
+
or the field has no effects."""
|
|
29
|
+
try:
|
|
30
|
+
from .. import extract
|
|
31
|
+
_bundle, folder, _roles, env = extract.find_field(field, game=game, bundle=bundle)
|
|
32
|
+
except Exception: # noqa: BLE001 -- no install / no UnityPy / unknown field -> empty catalog
|
|
33
|
+
return []
|
|
34
|
+
pref = _fieldmaps_prefix(folder)
|
|
35
|
+
out: list[SpsEntry] = []
|
|
36
|
+
for key in env.container:
|
|
37
|
+
if not key.startswith(pref) or not key.endswith(".sps.bytes"):
|
|
38
|
+
continue
|
|
39
|
+
stem = key.rsplit("/", 1)[-1][: -len(".sps.bytes")]
|
|
40
|
+
try:
|
|
41
|
+
sid = int(stem)
|
|
42
|
+
except ValueError:
|
|
43
|
+
continue
|
|
44
|
+
out.append(SpsEntry(str(field), folder, sid, key))
|
|
45
|
+
return sorted(out, key=lambda e: e.sps_id)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_sps_bytes(entry: SpsEntry, *, game=None, bundle=None) -> bytes:
|
|
49
|
+
"""Raw bytes of one effect bin."""
|
|
50
|
+
from .. import extract
|
|
51
|
+
_bundle, _folder, _roles, env = extract.find_field(entry.field_token, game=game, bundle=bundle)
|
|
52
|
+
return extract._raw_bytes(env.container[entry.container_key].read())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_sps(entry: SpsEntry, *, game=None, bundle=None) -> codec.Sps:
|
|
56
|
+
"""Parse one effect to its codec model (the detail/preview source)."""
|
|
57
|
+
return codec.parse(load_sps_bytes(entry, game=game, bundle=bundle))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_tcb(field, *, game=None, bundle=None) -> bytes | None:
|
|
61
|
+
"""The field's shared ``spt.tcb`` texture bytes (every effect in the field samples it), or ``None``."""
|
|
62
|
+
try:
|
|
63
|
+
from .. import extract
|
|
64
|
+
_bundle, folder, _roles, env = extract.find_field(field, game=game, bundle=bundle)
|
|
65
|
+
except Exception: # noqa: BLE001
|
|
66
|
+
return None
|
|
67
|
+
from .. import extract
|
|
68
|
+
key = _fieldmaps_prefix(folder) + "spt.tcb.bytes"
|
|
69
|
+
for k, obj in env.container.items(): # ContainerHelper has no .get(); iterate items
|
|
70
|
+
if k == key:
|
|
71
|
+
return extract._raw_bytes(obj.read())
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def effect_facts(sps: codec.Sps) -> list[tuple[str, str]]:
|
|
76
|
+
"""Human-readable facts about one effect (for the CLI / Info Hub detail pane)."""
|
|
77
|
+
prims = [len(f) for f in sps.frames]
|
|
78
|
+
tp = sps.tpage
|
|
79
|
+
return [
|
|
80
|
+
("kind", "SPS field effect"),
|
|
81
|
+
("frames", str(sps.frame_count)),
|
|
82
|
+
("prims/frame", f"{min(prims)}-{max(prims)}" if prims else "0"),
|
|
83
|
+
("quad half-size", f"{sps.half_w}x{sps.half_h}"),
|
|
84
|
+
("uv cells", str(len(sps.uv_table))),
|
|
85
|
+
("ramp colours", str(len(sps.rgb_table))),
|
|
86
|
+
("tpage", f"TP{tp['TP']} TX{tp['TX']} TY{tp['TY']}"),
|
|
87
|
+
("clut", f"Y{sps.clut['ClutY']} X{sps.clut['ClutX']}"),
|
|
88
|
+
]
|
ff9mapkit/sps/codec.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Lossless codec for the FF9 field ``.sps`` (Special Particle System) effect binary -- parse -> model ->
|
|
2
|
+
re-serialize -> build, the foundation a future SPS browser / re-skin / creator reads from. An ``.sps`` is a
|
|
3
|
+
multi-frame cloud of 2D textured billboard quads ("prims"); the PIXELS live in the shared per-scene
|
|
4
|
+
``spt.tcb`` VRAM texture (a SEPARATE format -- the authoring gate), and the field ``.eb`` fires ``RunSPSCode``
|
|
5
|
+
to load + place + animate it. This module owns only the ``.sps`` GEOMETRY/ANIMATION bytes -- the half we
|
|
6
|
+
fully control. -> [[project-ff9-sps-authoring]].
|
|
7
|
+
|
|
8
|
+
PROVEN against the engine source (``SPSEffect.LoadSPS`` / ``_GenerateSPSPrims`` :148-247, the TPage/Clut
|
|
9
|
+
bitfields in ``TIMUtils``) and a 9-file Ice-Cavern corpus: ``serialize(parse(b)) == b`` byte-exact on 9/9
|
|
10
|
+
real donors, and ``build(...)`` (a from-scratch constructor that recomputes every offset) reproduces all 9
|
|
11
|
+
from high-level fields alone -- so the encoder needs no hand-authored offsets and a synthetic effect is
|
|
12
|
+
well-formed under the same code path the engine uses.
|
|
13
|
+
|
|
14
|
+
Format facts (all little-endian; frame-table values are ABSOLUTE file offsets):
|
|
15
|
+
|
|
16
|
+
* Header: ``header0 u16 @0`` -- ``frame_count = header0 & 0x7FFF``; bit 15 = a flag (never set in the corpus,
|
|
17
|
+
carried opaque). ``tpage_raw u16 @2`` -- the TIM page word; the runtime OR-s the ABR blend bits (5-6) in
|
|
18
|
+
from the SPS instance, so they are NOT stored here. ``clut_raw u16 @4`` -- the palette word.
|
|
19
|
+
``h_raw u8 @6`` / ``w_raw u8 @7`` -- the size byte does DOUBLE DUTY: quad half-size (world) = ``(raw-1)*2``,
|
|
20
|
+
UV-cell half-size (px) = ``raw-1``.
|
|
21
|
+
* ``frame_offsets`` : ``u16[frame_count] @8`` -- each is the absolute offset of that frame's prim block.
|
|
22
|
+
* ``work_offset = frame_count*2 + 8`` : ``rgb_offset u16`` (== the UV-table entry count). Then
|
|
23
|
+
``pt = work_offset + 2`` (UV table base) and ``rgb = pt + rgb_offset*2 + 2`` (RGB table base; the ``+2``
|
|
24
|
+
skips a constant ``0x0010`` separator word that sits between the two tables).
|
|
25
|
+
* UV table @ ``pt`` : ``rgb_offset`` x ``(u8 uvx, u8 uvy)``; a prim's UV cell = entry ``texpos & 0x0F``.
|
|
26
|
+
* RGB table @ ``rgb`` : stride-4 ``(u8 r, u8 g, u8 b, u8 pad)`` ramp colors; a prim's color = entry
|
|
27
|
+
``texpos >> 4`` (the runtime multiplies it by the instance ``fade``). The 4th ``pad`` byte is NOT read by
|
|
28
|
+
the colour path (stride-4, only r/g/b consumed) -- real files carry 0 or 1; the codec preserves it verbatim.
|
|
29
|
+
* Each frame block @ its offset: ``u8 prim_count`` then ``prim_count`` x 3-byte prim
|
|
30
|
+
``(i8 pos_x, i8 pos_y, u8 texpos)`` -- runtime world pos = ``pos << 2`` (so +-508 units), ``texpos`` packs
|
|
31
|
+
the UV index (low nibble) + RGB index (high nibble).
|
|
32
|
+
* Canonical layout (what the real files use AND what ``build`` emits): header -> frame table -> rgb_offset
|
|
33
|
+
word -> UV table -> separator -> RGB table -> frame blocks (contiguous, in index order) -> 0..3 tail pad
|
|
34
|
+
bytes. ``parse`` captures the source frame offsets + tail verbatim, so ``serialize`` stays byte-exact even
|
|
35
|
+
on an off-canonical layout.
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import struct
|
|
40
|
+
from dataclasses import dataclass, field as _field
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SpsCodecError(ValueError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ----------------------------------------------------------------------------- texpos / size bit helpers
|
|
48
|
+
def pack_texpos(uv_index: int, rgb_index: int) -> int:
|
|
49
|
+
"""Pack a UV-cell index (0..15) + RGB-ramp index (0..15) into a prim ``texpos`` byte."""
|
|
50
|
+
if not (0 <= uv_index <= 0x0F and 0 <= rgb_index <= 0x0F):
|
|
51
|
+
raise SpsCodecError(f"uv/rgb index out of nibble range (0..15): {uv_index},{rgb_index}")
|
|
52
|
+
return (rgb_index << 4) | uv_index
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def make_tpage(*, tp: int = 0, ty: int = 0, tx: int = 0) -> int:
|
|
56
|
+
"""Build a ``tpage_raw`` word from its fields (TP = color mode 0=4bpp/1=8bpp/2=16bpp, TY*256/TX*64 =
|
|
57
|
+
VRAM base). ABR (blend) is set at runtime via the ``.eb`` ABR op, not stored, so it is omitted here."""
|
|
58
|
+
return ((tp & 3) << 7) | ((ty & 1) << 4) | (tx & 0x0F)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def make_clut(*, cluty: int = 0, clutx: int = 0) -> int:
|
|
62
|
+
"""Build a ``clut_raw`` word from its fields (ClutY = VRAM row, ClutX*16 = VRAM halfword x)."""
|
|
63
|
+
return ((cluty & 0x1FF) << 6) | (clutx & 0x3F)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _i8(v: int) -> int:
|
|
67
|
+
if not -128 <= v <= 127:
|
|
68
|
+
raise SpsCodecError(f"prim position {v} out of i8 range (-128..127)")
|
|
69
|
+
return v
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ----------------------------------------------------------------------------- the model
|
|
73
|
+
@dataclass
|
|
74
|
+
class Prim:
|
|
75
|
+
"""One textured billboard quad in a frame. ``pos_*`` are signed i8 (runtime ``<<2``); ``texpos`` packs
|
|
76
|
+
the UV-cell index (low nibble) + RGB-ramp index (high nibble)."""
|
|
77
|
+
pos_x: int
|
|
78
|
+
pos_y: int
|
|
79
|
+
texpos: int
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def uv_index(self) -> int:
|
|
83
|
+
return self.texpos & 0x0F
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def rgb_index(self) -> int:
|
|
87
|
+
return (self.texpos >> 4) & 0x0F
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def prim(pos_x: int, pos_y: int, uv_index: int, rgb_index: int) -> Prim:
|
|
91
|
+
"""Construct a :class:`Prim` from explicit UV + RGB indices (friendlier than a raw ``texpos`` byte)."""
|
|
92
|
+
return Prim(pos_x, pos_y, pack_texpos(uv_index, rgb_index))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class Sps:
|
|
97
|
+
"""A parsed / authorable ``.sps`` effect: header + per-frame prim lists + the shared UV + RGB tables."""
|
|
98
|
+
tpage_raw: int
|
|
99
|
+
clut_raw: int
|
|
100
|
+
h_raw: int
|
|
101
|
+
w_raw: int
|
|
102
|
+
frames: list[list[Prim]] = _field(default_factory=list)
|
|
103
|
+
uv_table: list[tuple[int, int]] = _field(default_factory=list)
|
|
104
|
+
rgb_table: list[tuple[int, int, int, int]] = _field(default_factory=list)
|
|
105
|
+
flag_bit15: int = 0
|
|
106
|
+
separator: int = 0x0010
|
|
107
|
+
tail: bytes = b""
|
|
108
|
+
# Captured source layout (set by parse). None for a from-scratch model -> serialize lays the frame
|
|
109
|
+
# blocks out canonically (contiguous, after the RGB table).
|
|
110
|
+
frame_offsets: list[int] | None = None
|
|
111
|
+
|
|
112
|
+
# --- decoded conveniences (mirror SPSEffect) ---
|
|
113
|
+
@property
|
|
114
|
+
def frame_count(self) -> int:
|
|
115
|
+
return len(self.frames)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def half_w(self) -> int:
|
|
119
|
+
return (self.w_raw - 1) * 2
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def half_h(self) -> int:
|
|
123
|
+
return (self.h_raw - 1) * 2
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def tpage(self) -> dict:
|
|
127
|
+
v = self.tpage_raw
|
|
128
|
+
return {"TP": (v >> 7) & 3, "ABR": (v >> 5) & 3, "TY": (v >> 4) & 1, "TX": v & 0x0F}
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def clut(self) -> dict:
|
|
132
|
+
v = self.clut_raw
|
|
133
|
+
return {"ClutY": (v >> 6) & 0x1FF, "ClutX": v & 0x3F}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ----------------------------------------------------------------------------- parse
|
|
137
|
+
def parse(data: bytes) -> Sps:
|
|
138
|
+
"""Decode an ``.sps`` blob into an :class:`Sps`. Captures the frame offsets + trailing pad verbatim so
|
|
139
|
+
:func:`serialize` round-trips byte-exact."""
|
|
140
|
+
if len(data) < 8:
|
|
141
|
+
raise SpsCodecError(f".sps too short: {len(data)} bytes")
|
|
142
|
+
header0 = struct.unpack_from("<H", data, 0)[0]
|
|
143
|
+
frame_count = header0 & 0x7FFF
|
|
144
|
+
work_offset = frame_count * 2 + 8
|
|
145
|
+
if work_offset + 2 > len(data):
|
|
146
|
+
raise SpsCodecError(f"frame table ({frame_count} frames) overruns {len(data)}-byte file")
|
|
147
|
+
s = Sps(
|
|
148
|
+
tpage_raw=struct.unpack_from("<H", data, 2)[0],
|
|
149
|
+
clut_raw=struct.unpack_from("<H", data, 4)[0],
|
|
150
|
+
h_raw=data[6],
|
|
151
|
+
w_raw=data[7],
|
|
152
|
+
flag_bit15=(header0 >> 15) & 1,
|
|
153
|
+
)
|
|
154
|
+
s.frame_offsets = list(struct.unpack_from("<%dH" % frame_count, data, 8))
|
|
155
|
+
rgb_offset = struct.unpack_from("<H", data, work_offset)[0]
|
|
156
|
+
pt = work_offset + 2
|
|
157
|
+
rgb = pt + rgb_offset * 2 + 2
|
|
158
|
+
if rgb > len(data):
|
|
159
|
+
raise SpsCodecError("UV/RGB table overruns file")
|
|
160
|
+
# UV table = exactly rgb_offset entries, then the separator word.
|
|
161
|
+
for k in range(rgb_offset):
|
|
162
|
+
s.uv_table.append((data[pt + (k << 1)], data[pt + (k << 1) + 1]))
|
|
163
|
+
s.separator = struct.unpack_from("<H", data, pt + rgb_offset * 2)[0]
|
|
164
|
+
# Frame prim blocks.
|
|
165
|
+
for fo in s.frame_offsets:
|
|
166
|
+
prim_count = data[fo]
|
|
167
|
+
prims: list[Prim] = []
|
|
168
|
+
p = fo + 1
|
|
169
|
+
for _ in range(prim_count):
|
|
170
|
+
pos_x = struct.unpack_from("<b", data, p)[0]
|
|
171
|
+
pos_y = struct.unpack_from("<b", data, p + 1)[0]
|
|
172
|
+
prims.append(Prim(pos_x, pos_y, data[p + 2]))
|
|
173
|
+
p += 3
|
|
174
|
+
s.frames.append(prims)
|
|
175
|
+
# RGB table spans rgb -> the first frame block (canonical) or EOF; stride 4.
|
|
176
|
+
first_frame = min(s.frame_offsets) if s.frame_offsets else len(data)
|
|
177
|
+
rgb_end = first_frame if first_frame > rgb else len(data)
|
|
178
|
+
for k in range((rgb_end - rgb) // 4):
|
|
179
|
+
base = rgb + (k << 2)
|
|
180
|
+
s.rgb_table.append((data[base], data[base + 1], data[base + 2], data[base + 3]))
|
|
181
|
+
# Tail = bytes after the last structural element (verbatim alignment padding).
|
|
182
|
+
last_end = (max(fo + 1 + 3 * len(pr) for fo, pr in zip(s.frame_offsets, s.frames))
|
|
183
|
+
if s.frames else rgb_end)
|
|
184
|
+
struct_end = max(last_end, rgb + len(s.rgb_table) * 4, pt + rgb_offset * 2 + 2)
|
|
185
|
+
s.tail = data[struct_end:]
|
|
186
|
+
return s
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ----------------------------------------------------------------------------- serialize
|
|
190
|
+
def _layout(s: Sps):
|
|
191
|
+
"""Return ``(frame_offsets, work_offset, pt, rgb, struct_end)``. Reuses the captured frame offsets when
|
|
192
|
+
present + consistent (byte-exact round-trip); else lays the frame blocks out canonically (contiguous,
|
|
193
|
+
right after the RGB table, in index order)."""
|
|
194
|
+
nframes = len(s.frames)
|
|
195
|
+
rgb_offset = len(s.uv_table)
|
|
196
|
+
work_offset = nframes * 2 + 8
|
|
197
|
+
pt = work_offset + 2
|
|
198
|
+
rgb = pt + rgb_offset * 2 + 2
|
|
199
|
+
rgb_end = rgb + len(s.rgb_table) * 4
|
|
200
|
+
if s.frame_offsets is not None and len(s.frame_offsets) == nframes:
|
|
201
|
+
frame_offsets = list(s.frame_offsets)
|
|
202
|
+
else:
|
|
203
|
+
frame_offsets = []
|
|
204
|
+
cur = rgb_end
|
|
205
|
+
for prims in s.frames:
|
|
206
|
+
frame_offsets.append(cur)
|
|
207
|
+
cur += 1 + 3 * len(prims)
|
|
208
|
+
last_end = (max(fo + 1 + 3 * len(pr) for fo, pr in zip(frame_offsets, s.frames))
|
|
209
|
+
if s.frames else rgb_end)
|
|
210
|
+
return frame_offsets, work_offset, pt, rgb, max(last_end, rgb_end)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def serialize(s: Sps) -> bytes:
|
|
214
|
+
"""Encode an :class:`Sps` back to ``.sps`` bytes (the inverse of :func:`parse`)."""
|
|
215
|
+
if len(s.uv_table) > 0xFFFF:
|
|
216
|
+
raise SpsCodecError("uv_table too large for a u16 rgb_offset")
|
|
217
|
+
frame_offsets, work_offset, pt, rgb, struct_end = _layout(s)
|
|
218
|
+
out = bytearray(struct_end + len(s.tail))
|
|
219
|
+
header0 = (len(s.frames) & 0x7FFF) | ((s.flag_bit15 & 1) << 15)
|
|
220
|
+
struct.pack_into("<H", out, 0, header0)
|
|
221
|
+
struct.pack_into("<H", out, 2, s.tpage_raw & 0xFFFF)
|
|
222
|
+
struct.pack_into("<H", out, 4, s.clut_raw & 0xFFFF)
|
|
223
|
+
out[6] = s.h_raw & 0xFF
|
|
224
|
+
out[7] = s.w_raw & 0xFF
|
|
225
|
+
for i, fo in enumerate(frame_offsets):
|
|
226
|
+
struct.pack_into("<H", out, 8 + i * 2, fo)
|
|
227
|
+
struct.pack_into("<H", out, work_offset, len(s.uv_table))
|
|
228
|
+
for k, (ux, uy) in enumerate(s.uv_table):
|
|
229
|
+
out[pt + (k << 1)] = ux & 0xFF
|
|
230
|
+
out[pt + (k << 1) + 1] = uy & 0xFF
|
|
231
|
+
struct.pack_into("<H", out, pt + len(s.uv_table) * 2, s.separator & 0xFFFF)
|
|
232
|
+
for k, (r, g, b, pad) in enumerate(s.rgb_table):
|
|
233
|
+
base = rgb + (k << 2)
|
|
234
|
+
out[base] = r & 0xFF
|
|
235
|
+
out[base + 1] = g & 0xFF
|
|
236
|
+
out[base + 2] = b & 0xFF
|
|
237
|
+
out[base + 3] = pad & 0xFF
|
|
238
|
+
for fo, prims in zip(frame_offsets, s.frames):
|
|
239
|
+
out[fo] = len(prims) & 0xFF
|
|
240
|
+
p = fo + 1
|
|
241
|
+
for pr in prims:
|
|
242
|
+
struct.pack_into("<b", out, p, _i8(pr.pos_x))
|
|
243
|
+
struct.pack_into("<b", out, p + 1, _i8(pr.pos_y))
|
|
244
|
+
out[p + 2] = pr.texpos & 0xFF
|
|
245
|
+
p += 3
|
|
246
|
+
if s.tail:
|
|
247
|
+
out[struct_end:struct_end + len(s.tail)] = s.tail
|
|
248
|
+
return bytes(out)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ----------------------------------------------------------------------------- build (from-scratch)
|
|
252
|
+
def build(*, tpage_raw: int, clut_raw: int, h_raw: int, w_raw: int,
|
|
253
|
+
uv_table, rgb_table, frames, flag_bit15: int = 0, separator: int = 0x0010,
|
|
254
|
+
tail: bytes = b"") -> Sps:
|
|
255
|
+
"""Construct a from-scratch :class:`Sps` (no captured layout -> :func:`serialize` emits the canonical
|
|
256
|
+
contiguous layout, recomputing every offset). ``frames`` is a list of prim lists; ``uv_table`` is
|
|
257
|
+
``(x, y)`` pairs; ``rgb_table`` is ``(r, g, b, pad)`` ramp colors."""
|
|
258
|
+
return Sps(
|
|
259
|
+
tpage_raw=tpage_raw, clut_raw=clut_raw, h_raw=h_raw, w_raw=w_raw,
|
|
260
|
+
frames=[list(f) for f in frames],
|
|
261
|
+
uv_table=[tuple(t) for t in uv_table],
|
|
262
|
+
rgb_table=[tuple(t) for t in rgb_table],
|
|
263
|
+
flag_bit15=flag_bit15, separator=separator, tail=tail,
|
|
264
|
+
)
|
ff9mapkit/sps/edit.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""``[[sps_edit]]`` -- declarative re-skin of an EXISTING ``.sps`` effect over its carried texture (Tier 1).
|
|
2
|
+
Mirrors the ``[[logic_edit]]`` lifecycle (single error type, ``_int``/``_req`` guards, ``_edit_list`` container
|
|
3
|
+
guard, an old-guard per op, a re-parse lint gate) but operates on the decoded :class:`codec.Sps` model -- the
|
|
4
|
+
re-skin is semantic (recolour the RGB ramp, tint the whole ramp, rescale the quad/UV size, reposition the prims),
|
|
5
|
+
so model edits are cleaner than byte-offset patches.
|
|
6
|
+
|
|
7
|
+
NO texture work and NO ``.eb`` change: every op stays inside the ``.sps`` bin geometry/colour we fully control,
|
|
8
|
+
over the donor's already-carried ``spt.tcb``. (The ``.eb``-side levers -- playback FRAMERATE and ABR blend mode --
|
|
9
|
+
are RunSPSCode operands, not bin fields, and are a follow-up.) A bad edit raises :class:`SpsEditError` so it fails
|
|
10
|
+
the BUILD, never the game. -> [[project-ff9-sps-authoring]].
|
|
11
|
+
|
|
12
|
+
Schema (``field.toml``)::
|
|
13
|
+
|
|
14
|
+
[[sps_edit]]
|
|
15
|
+
kind = "recolor_ramp" # overwrite one ramp colour
|
|
16
|
+
sps = 2266 # REQUIRED selector: targets <sps>.sps.bytes in the field's sps/ sidecar
|
|
17
|
+
index = 1 # rgb_table row
|
|
18
|
+
old = [128, 128, 128] # old-guard (current r,g,b) -> refuse on donor drift
|
|
19
|
+
new = [200, 40, 40]
|
|
20
|
+
|
|
21
|
+
[[sps_edit]]
|
|
22
|
+
kind = "tint" # multiply EVERY ramp colour (recolour/brighten the whole effect)
|
|
23
|
+
sps = 2266
|
|
24
|
+
mul = [0, 0, 512] # per-channel, 256 == identity (so this makes a grey effect blue)
|
|
25
|
+
|
|
26
|
+
[[sps_edit]]
|
|
27
|
+
kind = "scale" # resize the quads + UV cells
|
|
28
|
+
sps = 2266
|
|
29
|
+
old_size = [9, 9] # [h_raw, w_raw] guard
|
|
30
|
+
new_size = [13, 13]
|
|
31
|
+
|
|
32
|
+
[[sps_edit]]
|
|
33
|
+
kind = "reposition" # shift prims (one frame, or all)
|
|
34
|
+
sps = 2266
|
|
35
|
+
dx = 4
|
|
36
|
+
dy = -2
|
|
37
|
+
# frame = 0 # optional; omit = every frame
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from . import codec as _codec
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SpsEditError(ValueError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_KIND_KEYS = {
|
|
49
|
+
"recolor_ramp": {"kind", "sps", "index", "old", "new"},
|
|
50
|
+
"tint": {"kind", "sps", "mul"},
|
|
51
|
+
"scale": {"kind", "sps", "old_size", "new_size"},
|
|
52
|
+
"reposition": {"kind", "sps", "dx", "dy", "frame"},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _edit_list(edits):
|
|
57
|
+
"""Guard that ``edits`` is a list of tables (catches the ``[sps_edit]`` vs ``[[sps_edit]]`` mistake)."""
|
|
58
|
+
if not isinstance(edits, list):
|
|
59
|
+
raise SpsEditError("[[sps_edit]] must be an array of tables ([[sps_edit]], not [sps_edit])")
|
|
60
|
+
for n, e in enumerate(edits):
|
|
61
|
+
if not isinstance(e, dict):
|
|
62
|
+
raise SpsEditError(f"[[sps_edit]] #{n} must be a table, got {type(e).__name__}")
|
|
63
|
+
return edits
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _int(e, key, ctx):
|
|
67
|
+
v = e.get(key)
|
|
68
|
+
if not isinstance(v, int) or isinstance(v, bool):
|
|
69
|
+
raise SpsEditError(f"{ctx}: {key!r} must be an integer, got {v!r}")
|
|
70
|
+
return v
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _rgb(e, key, ctx):
|
|
74
|
+
v = e.get(key)
|
|
75
|
+
if not (isinstance(v, list) and len(v) == 3 and all(isinstance(c, int) and not isinstance(c, bool) for c in v)):
|
|
76
|
+
raise SpsEditError(f"{ctx}: {key!r} must be a [r, g, b] integer triple, got {v!r}")
|
|
77
|
+
if not all(0 <= c <= 255 for c in v):
|
|
78
|
+
raise SpsEditError(f"{ctx}: {key!r} {v} channel out of 0..255")
|
|
79
|
+
return tuple(v)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _check_keys(e, kind, ctx):
|
|
83
|
+
allowed = _KIND_KEYS[kind]
|
|
84
|
+
unknown = set(e) - allowed
|
|
85
|
+
if unknown:
|
|
86
|
+
raise SpsEditError(f"{ctx}: unknown key(s) {sorted(unknown)} for kind {kind!r} (allowed: {sorted(allowed)})")
|
|
87
|
+
for req in allowed - {"frame"}:
|
|
88
|
+
if req not in e:
|
|
89
|
+
raise SpsEditError(f"{ctx}: missing required key {req!r} for kind {kind!r}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _apply_one(model: _codec.Sps, e: dict, ctx: str) -> None:
|
|
93
|
+
kind = e.get("kind")
|
|
94
|
+
if kind not in _KIND_KEYS:
|
|
95
|
+
raise SpsEditError(f"{ctx}: unknown kind {kind!r} (known: {sorted(_KIND_KEYS)})")
|
|
96
|
+
_check_keys(e, kind, ctx)
|
|
97
|
+
|
|
98
|
+
if kind == "recolor_ramp":
|
|
99
|
+
idx = _int(e, "index", ctx)
|
|
100
|
+
if not (0 <= idx < len(model.rgb_table)):
|
|
101
|
+
raise SpsEditError(f"{ctx}: index {idx} out of range (rgb_table has {len(model.rgb_table)})")
|
|
102
|
+
old = _rgb(e, "old", ctx)
|
|
103
|
+
cur = model.rgb_table[idx][:3]
|
|
104
|
+
if cur != old:
|
|
105
|
+
raise SpsEditError(f"{ctx}: old-guard mismatch at rgb_table[{idx}] -- expected {old}, found {cur}")
|
|
106
|
+
new = _rgb(e, "new", ctx)
|
|
107
|
+
model.rgb_table[idx] = (new[0], new[1], new[2], 0)
|
|
108
|
+
|
|
109
|
+
elif kind == "tint":
|
|
110
|
+
mul = e.get("mul")
|
|
111
|
+
if not (isinstance(mul, list) and len(mul) == 3 and all(isinstance(c, int) and not isinstance(c, bool) for c in mul)):
|
|
112
|
+
raise SpsEditError(f"{ctx}: 'mul' must be a [r, g, b] integer triple, got {mul!r}")
|
|
113
|
+
if not all(0 <= c <= 4096 for c in mul):
|
|
114
|
+
raise SpsEditError(f"{ctx}: 'mul' {mul} channel out of 0..4096 (256 == identity)")
|
|
115
|
+
for ri, (r, g, b, _pad) in enumerate(model.rgb_table):
|
|
116
|
+
model.rgb_table[ri] = (
|
|
117
|
+
min(255, (r * mul[0]) >> 8),
|
|
118
|
+
min(255, (g * mul[1]) >> 8),
|
|
119
|
+
min(255, (b * mul[2]) >> 8),
|
|
120
|
+
0,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
elif kind == "scale":
|
|
124
|
+
old_size = e.get("old_size")
|
|
125
|
+
if not (isinstance(old_size, list) and len(old_size) == 2):
|
|
126
|
+
raise SpsEditError(f"{ctx}: 'old_size' must be [h_raw, w_raw], got {old_size!r}")
|
|
127
|
+
cur = [model.h_raw, model.w_raw]
|
|
128
|
+
if list(old_size) != cur:
|
|
129
|
+
raise SpsEditError(f"{ctx}: old-guard mismatch -- expected size {list(old_size)}, found {cur}")
|
|
130
|
+
new_size = e.get("new_size")
|
|
131
|
+
if not (isinstance(new_size, list) and len(new_size) == 2
|
|
132
|
+
and all(isinstance(c, int) and not isinstance(c, bool) and 1 <= c <= 255 for c in new_size)):
|
|
133
|
+
raise SpsEditError(f"{ctx}: 'new_size' must be two ints 1..255 [h_raw, w_raw], got {new_size!r}")
|
|
134
|
+
model.h_raw, model.w_raw = new_size[0], new_size[1]
|
|
135
|
+
|
|
136
|
+
elif kind == "reposition":
|
|
137
|
+
dx = _int(e, "dx", ctx)
|
|
138
|
+
dy = _int(e, "dy", ctx)
|
|
139
|
+
frame = e.get("frame")
|
|
140
|
+
if frame is not None:
|
|
141
|
+
if not isinstance(frame, int) or isinstance(frame, bool) or not (0 <= frame < model.frame_count):
|
|
142
|
+
raise SpsEditError(f"{ctx}: 'frame' {frame!r} out of range 0..{model.frame_count - 1}")
|
|
143
|
+
targets = [model.frames[frame]]
|
|
144
|
+
else:
|
|
145
|
+
targets = model.frames
|
|
146
|
+
for fr in targets:
|
|
147
|
+
for p in fr:
|
|
148
|
+
nx, ny = p.pos_x + dx, p.pos_y + dy
|
|
149
|
+
if not (-128 <= nx <= 127 and -128 <= ny <= 127):
|
|
150
|
+
raise SpsEditError(f"{ctx}: reposition pushes a prim to ({nx},{ny}), out of i8 range (-128..127)")
|
|
151
|
+
p.pos_x, p.pos_y = nx, ny
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def apply_sps_edits(sps_bytes: bytes, edits, *, sps_id: int) -> bytes:
|
|
155
|
+
"""Apply every ``[[sps_edit]]`` whose ``sps == sps_id`` to one ``.sps`` blob; return the new bytes.
|
|
156
|
+
No matching edit (or an empty list) -> byte-identical. Old-guarded; re-parses + lints the result (the SPS
|
|
157
|
+
analogue of the eblint gate). Raises :class:`SpsEditError` on any unsafe edit."""
|
|
158
|
+
_edit_list(edits)
|
|
159
|
+
mine = [e for e in edits if e.get("sps") == sps_id]
|
|
160
|
+
if not mine:
|
|
161
|
+
return bytes(sps_bytes)
|
|
162
|
+
model = _codec.parse(sps_bytes)
|
|
163
|
+
for n, e in enumerate(mine):
|
|
164
|
+
_apply_one(model, e, f"[[sps_edit]] sps={sps_id} #{n}")
|
|
165
|
+
# Structural gate: the result must re-encode and re-parse stably. (We do NOT run the authoring lint here:
|
|
166
|
+
# real shipping effects can index a nibble past a short table into adjacent data -- valid, deterministic --
|
|
167
|
+
# and that must remain editable. Each op already guards its own range; serialize's _i8 enforces positions.)
|
|
168
|
+
try:
|
|
169
|
+
out = _codec.serialize(model)
|
|
170
|
+
if _codec.serialize(_codec.parse(out)) != out:
|
|
171
|
+
raise SpsEditError(f"edited sps {sps_id} did not round-trip stably")
|
|
172
|
+
except _codec.SpsCodecError as ex:
|
|
173
|
+
raise SpsEditError(f"edited sps {sps_id} no longer encodes: {ex}") from ex
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def validate_sps_edits(sps_bytes: bytes, edits, *, sps_id: int) -> list[str]:
|
|
178
|
+
"""Offline problems for one bin's edits (empty == OK). Runs :func:`apply_sps_edits` and returns any
|
|
179
|
+
:class:`SpsEditError`/:class:`codec.SpsCodecError` as a message. NEVER raises (build-safe)."""
|
|
180
|
+
try:
|
|
181
|
+
apply_sps_edits(sps_bytes, edits, sps_id=sps_id)
|
|
182
|
+
return []
|
|
183
|
+
except (SpsEditError, _codec.SpsCodecError) as ex:
|
|
184
|
+
return [str(ex)]
|
ff9mapkit/sps/lint.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Offline structural validator for an authored / edited ``.sps`` effect (mirrors ``battle.seqauthor.lint_seq``:
|
|
2
|
+
a bare ``list[str]`` of problems, empty == OK, NEVER raises). The codec guarantees a byte-exact round-trip of a
|
|
3
|
+
PARSED file; this guards an AUTHORED or ``[[sps_edit]]``-edited model BEFORE serialize -> deploy, catching the
|
|
4
|
+
references that would corrupt the bin or crash ``SPSEffect._GenerateSPSPrims`` at runtime. -> [[project-ff9-sps-authoring]].
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from . import codec as _codec
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def lint_sps(model: _codec.Sps) -> list[str]:
|
|
12
|
+
"""Structural problems of an :class:`codec.Sps` (empty list == OK). This is an AUTHORING-quality check
|
|
13
|
+
(for from-scratch / Tier-0 display): the uv/rgb index-in-table checks flag a prim that samples a cell you
|
|
14
|
+
never defined. NOTE a few real shipping effects legitimately index a nibble PAST a short table into the
|
|
15
|
+
adjacent data region (valid, deterministic engine behaviour), so this is NOT asserted clean on every donor
|
|
16
|
+
and is NOT used as the ``[[sps_edit]]`` gate (``edit.apply_sps_edits`` gates on encodability instead)."""
|
|
17
|
+
problems: list[str] = []
|
|
18
|
+
n_uv = len(model.uv_table)
|
|
19
|
+
n_rgb = len(model.rgb_table)
|
|
20
|
+
|
|
21
|
+
if not (1 <= model.frame_count <= 0x7FFF):
|
|
22
|
+
problems.append(f"frame_count {model.frame_count} out of range 1..32767")
|
|
23
|
+
if n_uv == 0:
|
|
24
|
+
problems.append("uv_table is empty -- prims have no UV cell to sample")
|
|
25
|
+
if n_uv > 0xFFFF:
|
|
26
|
+
problems.append(f"uv_table has {n_uv} entries (max 65535 for the u16 rgb_offset)")
|
|
27
|
+
if n_rgb == 0:
|
|
28
|
+
problems.append("rgb_table is empty -- prims have no ramp colour")
|
|
29
|
+
if not (1 <= model.w_raw <= 0xFF) or not (1 <= model.h_raw <= 0xFF):
|
|
30
|
+
problems.append(f"size bytes h_raw={model.h_raw} w_raw={model.w_raw} must be 1..255 "
|
|
31
|
+
"(quad/UV half-size = (raw-1)*2 / (raw-1))")
|
|
32
|
+
|
|
33
|
+
for fi, frame in enumerate(model.frames):
|
|
34
|
+
if len(frame) > 0xFF:
|
|
35
|
+
problems.append(f"frame {fi}: {len(frame)} prims exceeds the 255-prim u8 count")
|
|
36
|
+
for pi, p in enumerate(frame):
|
|
37
|
+
if not (0 <= p.uv_index < n_uv):
|
|
38
|
+
problems.append(f"frame {fi} prim {pi}: uv_index {p.uv_index} out of range (uv_table has {n_uv})")
|
|
39
|
+
if not (0 <= p.rgb_index < n_rgb):
|
|
40
|
+
problems.append(f"frame {fi} prim {pi}: rgb_index {p.rgb_index} out of range (rgb_table has {n_rgb})")
|
|
41
|
+
if not (-128 <= p.pos_x <= 127) or not (-128 <= p.pos_y <= 127):
|
|
42
|
+
problems.append(f"frame {fi} prim {pi}: pos ({p.pos_x},{p.pos_y}) out of i8 range (-128..127)")
|
|
43
|
+
|
|
44
|
+
for ri, entry in enumerate(model.rgb_table):
|
|
45
|
+
r, g, b, _pad = entry # the 4th (stride-4) byte is unused by the colour path; real files use 0 or 1
|
|
46
|
+
if not all(0 <= c <= 255 for c in (r, g, b)):
|
|
47
|
+
problems.append(f"rgb_table[{ri}]: colour {(r, g, b)} channel out of 0..255")
|
|
48
|
+
return problems
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def lint_sps_bytes(data: bytes) -> list[str]:
|
|
52
|
+
"""Lint raw ``.sps`` bytes -- parse then lint, degrading to a problem string on a parse failure
|
|
53
|
+
(never raises, so a ``validate`` caller is build-safe)."""
|
|
54
|
+
try:
|
|
55
|
+
model = _codec.parse(data)
|
|
56
|
+
except _codec.SpsCodecError as ex:
|
|
57
|
+
return [f"unparseable .sps: {ex}"]
|
|
58
|
+
return lint_sps(model)
|