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,332 @@
1
+ """Lossless codec for a battle raw17 SFXDataCamera block -- parse -> edit -> RE-SERIALIZE (with the
2
+ offset-table repack), so a minted battle can author a FROM-SCRATCH opening camera (tier ii), not just
3
+ offset the donor's keyframes in place (tier i, ``camera_data.py``).
4
+
5
+ The native plugin reads the camera block at ``camOffset`` (raw17 header's 2nd int16); ``UpdateBSC``
6
+ (SFXDataCamera.cs:131-203) is Memoria's re-serializer of that block and the exact spec this mirrors. The
7
+ block is self-contained from ``camOffset`` to end-of-file, so editing = parse the block, replace one
8
+ camera's sequence, re-serialize, splice ``raw17[:camOffset] + new_block``.
9
+
10
+ Block layout: a UInt16 set-offset table (one per camera; the first entry == table size, so cameraCount =
11
+ table[0]/2), then each camera at its set-offset = ``Flags u16`` + a UInt16 offset entry per present flag
12
+ (HAS_SEQUENCE_0/1/2=0x01/02/04, HAS_UNKNOWN=0x08, HAS_CUSTOM_POSITION=0xF0) + the pointed-at blocks. A
13
+ sequence block is a Code stream: ``frame u16`` (0=end), ``CodeFlags u16``, then conditional sub-blocks
14
+ (cameraPosition 6B, cameraMovement 4B, target 6B+4B, signing 2B, focal 4B, unknown 2/2/4/4 B).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import struct
19
+
20
+ HAS_SEQ = (0x01, 0x02, 0x04)
21
+ HAS_UNKNOWN = 0x08
22
+ HAS_CUSTOM_POSITION = 0xF0
23
+
24
+
25
+ class CameraCodecError(ValueError):
26
+ pass
27
+
28
+
29
+ def _u16(b, o):
30
+ return struct.unpack_from("<H", b, o)[0]
31
+
32
+
33
+ # ----------------------------------------------------------------- Code stream (one sequence)
34
+ def parse_sequence(b, off, end):
35
+ """Parse a Code stream [off, end) -> list of Codes. Each Code = dict(frame, flags, block:bytes); the
36
+ terminator is dict(frame=0). ``block`` is the raw conditional sub-block bytes (kept verbatim)."""
37
+ codes = []
38
+ while off < end:
39
+ frame = _u16(b, off); off += 2
40
+ if frame == 0:
41
+ codes.append({"frame": 0})
42
+ return codes, off
43
+ flags = _u16(b, off); off += 2
44
+ blk = off
45
+ size = 0
46
+ if flags & 0x03:
47
+ size += 6 # cameraPosition
48
+ if flags & 0x02:
49
+ size += 4 # cameraMovement
50
+ if flags & 0x04: # HAS_UNKNOWN_1 aborts the reader
51
+ codes.append({"frame": frame, "flags": flags, "block": bytes(b[blk:off]), "abort": True})
52
+ return codes, off
53
+ if flags & 0x18:
54
+ size += 6 # targetPosition
55
+ if flags & 0x10:
56
+ size += 4 # targetMovement
57
+ if flags & 0x20: # HAS_UNKNOWN_2 aborts
58
+ codes.append({"frame": frame, "flags": flags, "block": bytes(b[blk:off]), "abort": True})
59
+ return codes, off
60
+ if flags & 0x40:
61
+ size += 2 # signing
62
+ if flags & 0x200:
63
+ size += 2
64
+ if flags & 0x400:
65
+ size += 2
66
+ if flags & 0x800:
67
+ size += 4 # focal
68
+ if flags & 0x1000:
69
+ size += 4
70
+ if flags & 0x4000:
71
+ size += 2 # setting
72
+ if flags & 0x8000:
73
+ size += 4
74
+ codes.append({"frame": frame, "flags": flags, "block": bytes(b[off:off + size])})
75
+ off += size
76
+ return codes, off
77
+
78
+
79
+ def serialize_sequence(codes) -> bytes:
80
+ out = bytearray()
81
+ for c in codes:
82
+ out += struct.pack("<H", c["frame"])
83
+ if c["frame"] == 0:
84
+ break
85
+ out += struct.pack("<H", c["flags"]) + c.get("block", b"")
86
+ if c.get("abort"):
87
+ break
88
+ return bytes(out)
89
+
90
+
91
+ # ----------------------------------------------------------------- one camera + the whole block
92
+ def _parse_camera(b, base, end):
93
+ """Parse a camera at [base, end). Returns dict(flags, sequences:[[Code]], unknown:bytes|None,
94
+ position:bytes|None). Blocks are delimited by the offset entries + the camera end -> lossless."""
95
+ flags = _u16(b, base)
96
+ present = [("seq", bit) for bit in HAS_SEQ if flags & bit]
97
+ if flags & HAS_UNKNOWN:
98
+ present.append(("unknown", HAS_UNKNOWN))
99
+ if flags & HAS_CUSTOM_POSITION:
100
+ present.append(("position", HAS_CUSTOM_POSITION))
101
+ offs = [base + _u16(b, base + 2 + i * 2) for i in range(len(present))]
102
+ bounds = offs + [end] # each block runs to the next offset (or camera end)
103
+ cam = {"flags": flags, "sequences": [], "unknown": None, "position": None}
104
+ for i, (kind, _bit) in enumerate(present):
105
+ lo, hi = bounds[i], bounds[i + 1]
106
+ if kind == "seq":
107
+ codes, _ = parse_sequence(b, lo, hi)
108
+ cam["sequences"].append(codes)
109
+ elif kind == "unknown":
110
+ cam["unknown"] = bytes(b[lo:hi])
111
+ else:
112
+ cam["position"] = bytes(b[lo:hi])
113
+ return cam
114
+
115
+
116
+ def parse_block(raw17):
117
+ """(camOffset, [camera dicts]) from a raw17. Cameras keep their full structure for lossless re-serialize."""
118
+ cam_off = struct.unpack_from("<h", raw17, 2)[0]
119
+ if cam_off <= 0 or cam_off >= len(raw17):
120
+ raise CameraCodecError(f"bad camOffset {cam_off}")
121
+ table0 = _u16(raw17, cam_off)
122
+ n = table0 // 2
123
+ set_offs = [_u16(raw17, cam_off + 2 * i) for i in range(n)]
124
+ cams = []
125
+ for i in range(n):
126
+ base = cam_off + set_offs[i]
127
+ end = cam_off + (set_offs[i + 1] if i + 1 < n else (len(raw17) - cam_off))
128
+ cams.append(_parse_camera(raw17, base, end))
129
+ return cam_off, cams
130
+
131
+
132
+ def _serialize_camera(cam) -> bytes:
133
+ flags = cam["flags"]
134
+ blocks = []
135
+ for bit in HAS_SEQ:
136
+ if flags & bit:
137
+ blocks.append(serialize_sequence(cam["sequences"][len(blocks)]))
138
+ if flags & HAS_UNKNOWN:
139
+ blocks.append(cam["unknown"] or b"")
140
+ if flags & HAS_CUSTOM_POSITION:
141
+ blocks.append(cam["position"] or b"")
142
+ data_start = 2 + len(blocks) * 2 # flags + one UInt16 offset entry per block
143
+ table = bytearray(len(blocks) * 2)
144
+ body = bytearray()
145
+ cur = data_start
146
+ for i, blk in enumerate(blocks):
147
+ struct.pack_into("<H", table, i * 2, cur)
148
+ body += blk
149
+ cur += len(blk)
150
+ return struct.pack("<H", flags) + bytes(table) + bytes(body)
151
+
152
+
153
+ def serialize_block(cams) -> bytes:
154
+ """Re-serialize the camera list -> block bytes (set-offset table + cameras), mirroring UpdateBSC."""
155
+ n = len(cams)
156
+ table = bytearray(n * 2)
157
+ body = bytearray()
158
+ cur = n * 2 # cameras start right after the set-offset table
159
+ for i, cam in enumerate(cams):
160
+ struct.pack_into("<H", table, i * 2, cur)
161
+ cb = _serialize_camera(cam)
162
+ body += cb
163
+ cur += len(cb)
164
+ return bytes(table) + bytes(body)
165
+
166
+
167
+ def splice_block(raw17, cams) -> bytes:
168
+ """raw17 with its camera block replaced by ``serialize_block(cams)`` (camOffset unchanged)."""
169
+ cam_off = struct.unpack_from("<h", raw17, 2)[0]
170
+ return bytes(raw17[:cam_off]) + serialize_block(cams)
171
+
172
+
173
+ # ----------------------------------------------------------------- from-scratch keyframe authoring (tier ii)
174
+ # Grounded in the REAL opening-camera grammar shared by every shipping battle (surveyed across EF_R007,
175
+ # BU_R002, CM_R000, BB_R000, CA_E013, AC_E031 -- see tools/dump_battle_camera.py). A real opening is:
176
+ # 1. an ESTABLISHING code @ frame 1: cameraPosition + targetPosition [+ focal], no movement -> the camera
177
+ # is placed instantly at a start pose, looking at a fixed target.
178
+ # 2. a CHAIN of 2-4 MOVEMENT segments: each is cameraPosition + cameraMovement(duration, easing), firing
179
+ # at prev_frame + prev_duration, so the swoop is continuous (durations seen: 15-70 frames).
180
+ # 3. a HANDOFF code (SAVE_FOR_FIXED|SETTING type=1 = SetCameraPhase(1)) a few frames after the last move
181
+ # settles -- THIS is what flips GetCameraPhase()==1 (SFX.cs:1606) and ends the intro; without it the
182
+ # battle hangs (root-caused via an ultracode workflow). + a trailing UNK6(0x21) marker, then END.
183
+ # We reproduce that grammar, ANCHORED on the donor's proven FIXED-CAMERA pose -- its SETTLE pose, i.e. the
184
+ # LAST cameraPosition code's pose + the LAST targetPosition (the on-fight look-at). That settle pose is what
185
+ # the battle saves via SAVE_FOR_FIXED and uses as its normal camera, so it is GUARANTEED to frame the fight.
186
+ #
187
+ # THE ORIGIN MATTERS AS MUCH AS THE MOTION. The battle centre is the world origin (0,0,0), ground at y=0;
188
+ # the default cameras (BattleMapCameraController) sit ~4500-5900 world units out -> a settle distance byte
189
+ # ~0x0a-0x17 = ~4500-5900w, so 1 distance unit ~= 450-500 world units, NOT the 63 the SFXDataCamera comment
190
+ # guesses. Crucially, the camera distance is measured FROM THE TARGET, so the target's own offset is part of
191
+ # the framing -- zeroing it (or freezing the far establishing target) mis-origins the shot. Rather than
192
+ # reverse-engineer the closed plugin's absolute scale, keyframes are expressed RELATIVE to the proven settle
193
+ # pose: yaw/pitch/roll are degree OFFSETS and `zoom` is a distance MULTIPLIER (consistent with tier i's
194
+ # camera_yaw/pitch/zoom). So offset 0 / zoom 1 == the game's normal framing -- a sweep can't be mis-origined
195
+ # or super-zoomed; it orbits/cranes around the exact shot the battle settles into.
196
+ _EASE = {"linear": 0, "in": 1, "out": 2, "sinusin": 1, "sinusout": 2}
197
+
198
+
199
+ def _split_code(flags, block):
200
+ """Slice a donor Code's ``block`` (the bytes after frame+flags) into named sub-blocks, per the exact
201
+ field order of SFXDataCamera.Sequence.Read. Returns {campos, cammove, tgtpos, tgtmove, focal, ...}
202
+ with each value the verbatim bytes (or None). Lossless for the fields a normal opening uses."""
203
+ o, p = 0, {}
204
+ if flags & 0x03:
205
+ p["campos"] = bytes(block[o:o + 6]); o += 6
206
+ if flags & 0x02:
207
+ p["cammove"] = bytes(block[o:o + 4]); o += 4
208
+ if flags & 0x04:
209
+ return p # HAS_UNKNOWN_1 aborts the reader
210
+ if flags & 0x18:
211
+ p["tgtpos"] = bytes(block[o:o + 6]); o += 6
212
+ if flags & 0x10:
213
+ p["tgtmove"] = bytes(block[o:o + 4]); o += 4
214
+ if flags & 0x20:
215
+ return p
216
+ if flags & 0x40:
217
+ p["sign"] = bytes(block[o:o + 2]); o += 2
218
+ if flags & 0x200:
219
+ p["unk3"] = bytes(block[o:o + 2]); o += 2
220
+ if flags & 0x400:
221
+ p["unk4"] = bytes(block[o:o + 2]); o += 2
222
+ if flags & 0x800:
223
+ p["focal"] = bytes(block[o:o + 4]); o += 4
224
+ if flags & 0x1000:
225
+ p["unk5"] = bytes(block[o:o + 4]); o += 4
226
+ if flags & 0x4000:
227
+ p["setting"] = bytes(block[o:o + 2]); o += 2
228
+ if flags & 0x8000:
229
+ p["unk6"] = bytes(block[o:o + 4]); o += 4
230
+ return p
231
+
232
+
233
+ def _pose_bytes(base6, kf):
234
+ """A new 6-byte cameraPosition by ADJUSTING the proven settle pose ``base6`` (code,flags,pitch,ori,roll,
235
+ dist): ``yaw``/``pitch``/``roll`` are degree OFFSETS (pitch/roll 0x80=360, orientation 0x40=360) and
236
+ ``zoom`` is a distance MULTIPLIER. Offset 0 / zoom 1 reproduces ``base6`` byte-for-byte. The pitch/roll
237
+ HIGH BIT (the plugin's signed-rotation convention) is preserved."""
238
+ out = bytearray(base6)
239
+ p_off = round(float(kf.get("pitch", 0)) / 360.0 * 0x80)
240
+ y_off = round(float(kf.get("yaw", 0)) / 360.0 * 0x40)
241
+ r_off = round(float(kf.get("roll", 0)) / 360.0 * 0x80)
242
+ out[2] = (base6[2] & 0x80) | ((base6[2] + p_off) & 0x7F)
243
+ out[3] = (base6[3] + y_off) % 0x40
244
+ out[4] = (base6[4] & 0x80) | ((base6[4] + r_off) & 0x7F)
245
+ out[5] = max(0, min(255, round(base6[5] * float(kf.get("zoom", 1.0)))))
246
+ return bytes(out)
247
+
248
+
249
+ def _settle_pose(donor_codes, pos_idxs):
250
+ """The donor's proven fixed-camera pose: the LAST cameraPosition code's campos (where the swoop ends ==
251
+ the SAVE_FOR_FIXED snapshot the battle uses) + the LAST targetPosition in the opening (the on-fight
252
+ look-at) + a focal. Returns (base6, target_bytes, focal_bytes)."""
253
+ settle = _split_code(donor_codes[pos_idxs[-1]]["flags"], donor_codes[pos_idxs[-1]]["block"])
254
+ base6 = settle.get("campos", b"\x15\x80\xfb\x19\x80\x0a")
255
+ tgts = [_split_code(c["flags"], c["block"]).get("tgtpos") for c in donor_codes if c.get("frame")]
256
+ tgts = [t for t in tgts if t]
257
+ tgt = tgts[-1] if tgts else b""
258
+ if not settle.get("focal"): # focal may live on the establishing code instead
259
+ est = _split_code(donor_codes[pos_idxs[0]]["flags"], donor_codes[pos_idxs[0]]["block"])
260
+ return base6, tgt, est.get("focal", b"")
261
+ return base6, tgt, settle["focal"]
262
+
263
+
264
+ def build_sequence(donor_codes, keyframes, *, start_delay=1, handoff_gap=5, default_move=30):
265
+ """Build a real-grammar opening Code list from ``keyframes`` (ordered), ANCHORED on the donor's proven
266
+ settle pose: the FIRST keyframe is the instant START pose, each later one is a swoop segment that MOVES
267
+ the camera there over ``move`` frames (default ``default_move``), easing ``ease`` (in|out|linear).
268
+ Frames chain automatically. The donor's on-fight target, focal and handoff/terminator codes are kept, so
269
+ the battle starts AND stays framed around the exact shot it settles into.
270
+
271
+ Each keyframe ADJUSTS the settle pose: {yaw?, pitch?, roll? (degree offsets), zoom? (distance multiplier,
272
+ default 1.0), move?, ease?}. The natural last keyframe is {} (offset 0 / zoom 1) == the game's normal
273
+ framing. Needs >= 2 keyframes (a start + at least one move); fewer is static and belongs to tier i."""
274
+ if len(keyframes) < 2:
275
+ raise CameraCodecError("[[scene.camera_keyframes]] needs >= 2 keyframes (a start pose + at least one "
276
+ "move); for a static nudge use [scene] camera_yaw/pitch/zoom instead")
277
+ pos_idxs = [i for i, c in enumerate(donor_codes) if c.get("frame") and (c["flags"] & 0x03)]
278
+ if not pos_idxs:
279
+ raise CameraCodecError("donor opening camera has no cameraPosition code to template from")
280
+ base6, tgt, focal = _settle_pose(donor_codes, pos_idxs)
281
+ # the donor's control/handoff codes = everything after its LAST cameraPosition code (SAVE_FIXED|SETTING
282
+ # SetCameraPhase(1) + the UNK6 marker). Re-framed after the authored sweep settles -> battle starts.
283
+ trailing = [c for c in donor_codes[pos_idxs[-1] + 1:] if c.get("frame")]
284
+
285
+ out = []
286
+ # 1) establishing code @ frame `start_delay` (== 1, like every donor): instant pose + on-fight target + focal
287
+ f0 = 0x01 | (0x08 if tgt else 0) | (0x800 if focal else 0)
288
+ out.append({"frame": start_delay, "flags": f0, "block": _pose_bytes(base6, keyframes[0]) + tgt + focal})
289
+ # 2) chained movement segments (each starts where the previous ended; first starts at the establish frame)
290
+ t = start_delay
291
+ for kf in keyframes[1:]:
292
+ dur = max(1, int(kf.get("move", default_move)))
293
+ ease = _EASE.get(str(kf.get("ease", "out")).lower(), 2)
294
+ fl = 0x03 | (0x08 if tgt else 0) # CAMPOS+CAMMOVE (+ static on-fight TGTPOS)
295
+ blk = _pose_bytes(base6, kf) + struct.pack("<H", dur) + bytes([ease, 0]) + tgt
296
+ out.append({"frame": t, "flags": fl, "block": blk})
297
+ t += dur
298
+ # 3) handoff, re-framed just after the sweep settles (keep the donor's relative spacing)
299
+ if trailing:
300
+ base = trailing[0]["frame"]
301
+ for c in trailing:
302
+ out.append({"frame": t + handoff_gap + (c["frame"] - base), "flags": c["flags"],
303
+ "block": c["block"]})
304
+ else: # synthesize a minimal handoff if the donor had none
305
+ out.append({"frame": t + handoff_gap, "flags": 0x4080, "block": b"\x01\x00"})
306
+ out.append({"frame": t + handoff_gap + 1, "flags": 0x8000, "block": b"\x21\x00\x00\x00"})
307
+ out.append({"frame": 0}) # terminator
308
+ return out
309
+
310
+
311
+ def author_opening(raw17, cam_indices, keyframes) -> bytes:
312
+ """Replace the opening camera(s) ``cam_indices`` with a from-scratch sweep from ``keyframes`` (keeping
313
+ each donor camera's static target, focal and handoff). Re-serializes the whole block (offset repack).
314
+ Cameras without a usable cameraPosition opening sequence are skipped (cam[1]/[2] are often empty)."""
315
+ if not keyframes:
316
+ return bytes(raw17)
317
+ cam_off, cams = parse_block(raw17)
318
+ n = len(cams)
319
+ authored = 0
320
+ for idx in cam_indices:
321
+ if not 0 <= idx < n or not cams[idx]["sequences"]:
322
+ continue
323
+ donor = cams[idx]["sequences"][0]
324
+ if not any(c.get("frame") and (c["flags"] & 0x03) for c in donor):
325
+ continue # no cameraPosition to anchor on -> skip (e.g. empty default cam)
326
+ seq = build_sequence(donor, keyframes)
327
+ cams[idx]["sequences"] = [list(seq) for _ in cams[idx]["sequences"]]
328
+ authored += 1
329
+ if authored == 0:
330
+ raise CameraCodecError(f"no opening camera among {list(cam_indices)} had a cameraPosition sequence to "
331
+ f"author from (this raw17 has {n} cameras); pin [scene] camera = 0")
332
+ return splice_block(raw17, cams)
@@ -0,0 +1,128 @@
1
+ """In-place tweaks to a minted battle's OPENING camera (raw17 SFXDataCamera) -- yaw / pitch / zoom.
2
+
3
+ The native FF9SpecialEffectPlugin.dll reads the raw17 camera keyframes directly (it's a data consumer, not
4
+ a wall -- see project_ff9_battle_backgrounds). The opening shot is ``cameraList[CameraNo]`` where CameraNo =
5
+ the raw16 pattern ``Camera`` byte (0-2; random if >=3). This rotates/tilts/zooms that opening sweep by
6
+ adding a constant offset to every ``cameraPosition`` spherical coord IN PLACE -- no byte-count change, so no
7
+ camera offset-table repack (full keyframe authoring, which CAN change lengths, is a separate tier). Only
8
+ ``cameraPosition`` (the camera's own position) is changed, not the target, so the battle stays framed.
9
+
10
+ raw17 camera format: header ``int16 seqBlockOffset; int16 camOffset``; at ``camOffset`` a UInt16 set-offset
11
+ table (one per camera); each camera = ``Flags u16`` + per-flag sequence-offset table + a Code stream
12
+ (``frame u16`` [0=end], ``CodeFlags u16``, then ``cameraPosition``[code,flags,PITCH,ORIENTATION,roll,DIST]
13
+ 6B, ``cameraMovement`` 4B, target 6B+4B, ...). pitch/roll 0-0x80 = 360deg; orientation 0-0x40 = 360deg.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import struct
18
+
19
+ _PITCH_FULL = 0x80 # pitch/roll: 0..0x80 = 360 degrees
20
+ _YAW_FULL = 0x40 # orientation: 0..0x40 = 360 degrees
21
+
22
+
23
+ class CameraEditError(ValueError):
24
+ pass
25
+
26
+
27
+ def _u16(b, o):
28
+ return struct.unpack_from("<H", b, o)[0]
29
+
30
+
31
+ def camera_count(raw17) -> int:
32
+ cam_off = struct.unpack_from("<h", raw17, 2)[0]
33
+ return _u16(raw17, cam_off) // 2
34
+
35
+
36
+ def _campos_fields(raw17, cam_index):
37
+ """Yield (pitch_off, orientation_off, distance_off) for every cameraPosition in cameraList[cam_index]."""
38
+ cam_off = struct.unpack_from("<h", raw17, 2)[0]
39
+ n = _u16(raw17, cam_off) // 2
40
+ if not 0 <= cam_index < n:
41
+ return
42
+ base = cam_off + _u16(raw17, cam_off + 2 * cam_index)
43
+ flags = _u16(raw17, base)
44
+ seqs, oo = [], 2
45
+ for bit in (1, 2, 4): # HAS_SEQUENCE_0/1/2
46
+ if flags & bit:
47
+ seqs.append(base + _u16(raw17, base + oo)); oo += 2
48
+ for so in seqs:
49
+ off = so
50
+ while True:
51
+ frame = _u16(raw17, off); off += 2
52
+ if frame == 0:
53
+ break
54
+ fl = _u16(raw17, off); off += 2
55
+ if fl & 3: # HAS_CAMERA_POSITION (6B): code,flags,pitch,ori,roll,dist
56
+ yield (off + 2, off + 3, off + 5); off += 6
57
+ if fl & 2:
58
+ off += 4 # cameraMovement
59
+ if fl & 4:
60
+ break # HAS_UNKNOWN_1 aborts
61
+ if fl & 0x18:
62
+ off += 6 # targetPosition
63
+ if fl & 0x10:
64
+ off += 4 # targetMovement
65
+ if fl & 0x20:
66
+ break
67
+ if fl & 0x40:
68
+ off += 2 # signing
69
+ if fl & 0x200:
70
+ off += 2
71
+ if fl & 0x400:
72
+ off += 2
73
+ if fl & 0x800:
74
+ off += 4 # focal
75
+ if fl & 0x1000:
76
+ off += 4
77
+ if fl & 0x4000:
78
+ off += 2 # setting
79
+ if fl & 0x8000:
80
+ off += 4
81
+
82
+
83
+ def tweak_opening(raw17, cam_indices, *, yaw_deg=0.0, pitch_deg=0.0, zoom=1.0) -> tuple:
84
+ """Rotate (yaw_deg around the target), tilt (pitch_deg), and/or zoom (MAGNIFY: ``zoom`` > 1 moves the camera
85
+ CLOSER -- distance / zoom -- so it reads like a real camera zoom, not a distance scale) the opening
86
+ camera(s) ``cam_indices`` in place. Returns ``(new_raw17_bytes, report)`` -- ``report`` is a list of
87
+ human lines (one per touched camera: keyframe count + a representative distance before->after) so the
88
+ build can SURFACE what the tweak did instead of failing silently. Same length (no offset repack)."""
89
+ if zoom <= 0:
90
+ raise CameraEditError(f"camera_zoom {zoom} must be > 0")
91
+ b = bytearray(raw17)
92
+ dyaw = round(yaw_deg / 360.0 * _YAW_FULL)
93
+ dpitch = round(pitch_deg / 360.0 * _PITCH_FULL)
94
+ touched, report = 0, []
95
+ for idx in cam_indices:
96
+ n_kf, d_before, d_after = 0, None, None
97
+ for p_off, o_off, d_off in _campos_fields(b, idx):
98
+ if d_before is None:
99
+ d_before = b[d_off]
100
+ if dpitch:
101
+ b[p_off] = (b[p_off] + dpitch) % _PITCH_FULL
102
+ if dyaw:
103
+ b[o_off] = (b[o_off] + dyaw) % _YAW_FULL
104
+ if zoom != 1.0:
105
+ b[d_off] = max(0, min(255, round(b[d_off] / zoom))) # zoom>1 -> closer (magnify), like a real zoom
106
+ if d_after is None:
107
+ d_after = b[d_off]
108
+ n_kf += 1
109
+ touched += 1
110
+ if n_kf:
111
+ note = f"camera {idx}: {n_kf} keyframe(s)"
112
+ if zoom != 1.0:
113
+ note += f", distance {d_before}->{d_after}"
114
+ if d_before == d_after:
115
+ note += " (no change -- the distance byte had no headroom to scale)"
116
+ report.append(note)
117
+ if touched == 0:
118
+ raise CameraEditError(f"no cameraPosition keyframes found in cameras {list(cam_indices)} "
119
+ f"(this raw17 has {camera_count(raw17)} cameras)")
120
+ return bytes(b), report
121
+
122
+
123
+ def opening_indices(camera_selector) -> list:
124
+ """Which cameraList indices a tweak targets. A pinned ``[scene] camera`` 0-2 -> just that one; otherwise
125
+ (unset / >=3 random) -> all three possible openings [0,1,2] so whichever the engine rolls is tweaked."""
126
+ if isinstance(camera_selector, int) and 0 <= camera_selector < 3:
127
+ return [camera_selector]
128
+ return [0, 1, 2]