ff9mapkit 1.0.0b3__py3-none-any.whl

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