ff9mapkit 1.0.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. ff9mapkit/__init__.py +18 -0
  2. ff9mapkit/__main__.py +36 -0
  3. ff9mapkit/_animdb.py +2994 -0
  4. ff9mapkit/_animdb_all.py +14125 -0
  5. ff9mapkit/_fieldtable.py +1516 -0
  6. ff9mapkit/_fieldtext.py +845 -0
  7. ff9mapkit/_held_poses.py +44 -0
  8. ff9mapkit/_itemdb.py +65 -0
  9. ff9mapkit/_modeldb.py +725 -0
  10. ff9mapkit/_narrowmap_data.py +10 -0
  11. ff9mapkit/_npcparams.py +634 -0
  12. ff9mapkit/_regen_animdb.py +72 -0
  13. ff9mapkit/_regen_animdb_all.py +66 -0
  14. ff9mapkit/_regen_fieldtable.py +95 -0
  15. ff9mapkit/_regen_fieldtext.py +66 -0
  16. ff9mapkit/_regen_modeldb.py +67 -0
  17. ff9mapkit/_regen_npcparams.py +123 -0
  18. ff9mapkit/_regen_scenedb.py +57 -0
  19. ff9mapkit/_scenedb.py +869 -0
  20. ff9mapkit/abilities.py +225 -0
  21. ff9mapkit/animations.py +120 -0
  22. ff9mapkit/archetypes.py +218 -0
  23. ff9mapkit/areatitle.py +76 -0
  24. ff9mapkit/battle/__init__.py +21 -0
  25. ff9mapkit/battle/abilityfeatures.py +294 -0
  26. ff9mapkit/battle/actiondelta.py +441 -0
  27. ff9mapkit/battle/aiauthor.py +305 -0
  28. ff9mapkit/battle/ailint.py +140 -0
  29. ff9mapkit/battle/aipatch.py +175 -0
  30. ff9mapkit/battle/battleai.py +148 -0
  31. ff9mapkit/battle/battlecsv.py +390 -0
  32. ff9mapkit/battle/battlepatch.py +395 -0
  33. ff9mapkit/battle/build.py +558 -0
  34. ff9mapkit/battle/camera_codec.py +332 -0
  35. ff9mapkit/battle/camera_data.py +128 -0
  36. ff9mapkit/battle/characterdelta.py +789 -0
  37. ff9mapkit/battle/event_data.py +72 -0
  38. ff9mapkit/battle/extract.py +540 -0
  39. ff9mapkit/battle/fbx.py +223 -0
  40. ff9mapkit/battle/reskin.py +149 -0
  41. ff9mapkit/battle/scene_codec.py +314 -0
  42. ff9mapkit/battle/scene_data.py +369 -0
  43. ff9mapkit/battle/scenelint.py +125 -0
  44. ff9mapkit/battle/seqasm.py +131 -0
  45. ff9mapkit/battle/seqauthor.py +220 -0
  46. ff9mapkit/battle/seqcodec.py +300 -0
  47. ff9mapkit/battle/seqdis.py +106 -0
  48. ff9mapkit/battle/seqpatch.py +137 -0
  49. ff9mapkit/battle_bgm.py +133 -0
  50. ff9mapkit/binutils.py +60 -0
  51. ff9mapkit/build.py +5445 -0
  52. ff9mapkit/campaign.py +1276 -0
  53. ff9mapkit/catalog.py +316 -0
  54. ff9mapkit/chain.py +358 -0
  55. ff9mapkit/cli.py +3114 -0
  56. ff9mapkit/config.py +360 -0
  57. ff9mapkit/content/__init__.py +13 -0
  58. ff9mapkit/content/areatitle.py +36 -0
  59. ff9mapkit/content/ate.py +118 -0
  60. ff9mapkit/content/camera.py +123 -0
  61. ff9mapkit/content/chest.py +186 -0
  62. ff9mapkit/content/choice.py +163 -0
  63. ff9mapkit/content/conductor.py +217 -0
  64. ff9mapkit/content/cutscene.py +290 -0
  65. ff9mapkit/content/encounter.py +41 -0
  66. ff9mapkit/content/entry_settle.py +50 -0
  67. ff9mapkit/content/equipment.py +93 -0
  68. ff9mapkit/content/event.py +191 -0
  69. ff9mapkit/content/gateway.py +101 -0
  70. ff9mapkit/content/inventory.py +59 -0
  71. ff9mapkit/content/itemdata.py +644 -0
  72. ff9mapkit/content/itemtext.py +168 -0
  73. ff9mapkit/content/jump.py +114 -0
  74. ff9mapkit/content/ladder.py +633 -0
  75. ff9mapkit/content/movement.py +53 -0
  76. ff9mapkit/content/music.py +97 -0
  77. ff9mapkit/content/npc.py +348 -0
  78. ff9mapkit/content/object.py +340 -0
  79. ff9mapkit/content/onentry.py +135 -0
  80. ff9mapkit/content/party.py +111 -0
  81. ff9mapkit/content/pathfind.py +138 -0
  82. ff9mapkit/content/platform.py +314 -0
  83. ff9mapkit/content/player.py +168 -0
  84. ff9mapkit/content/prop.py +75 -0
  85. ff9mapkit/content/region.py +340 -0
  86. ff9mapkit/content/reinit.py +59 -0
  87. ff9mapkit/content/savepoint.py +90 -0
  88. ff9mapkit/content/shop.py +178 -0
  89. ff9mapkit/content/sps_trigger.py +66 -0
  90. ff9mapkit/content/startup.py +71 -0
  91. ff9mapkit/content/synthesis.py +106 -0
  92. ff9mapkit/content/text.py +183 -0
  93. ff9mapkit/content/textcarry.py +290 -0
  94. ff9mapkit/content/verbatim.py +86 -0
  95. ff9mapkit/content/walkmesh_hotfix.py +38 -0
  96. ff9mapkit/data/__init__.py +48 -0
  97. ff9mapkit/data/_regen_provenance.py +142 -0
  98. ff9mapkit/data/provenance/blank.es.patch +1 -0
  99. ff9mapkit/data/provenance/blank.fr.patch +1 -0
  100. ff9mapkit/data/provenance/blank.gr.patch +1 -0
  101. ff9mapkit/data/provenance/blank.it.patch +1 -0
  102. ff9mapkit/data/provenance/blank.jp.patch +1 -0
  103. ff9mapkit/data/provenance/blank.uk.patch +1 -0
  104. ff9mapkit/data/provenance/blank.us.patch +1 -0
  105. ff9mapkit/data/provenance/manifest.json +65 -0
  106. ff9mapkit/data/provenance/region_template.patch +1 -0
  107. ff9mapkit/data/reference_arcs.toml +89 -0
  108. ff9mapkit/data/region_catalog.toml +593 -0
  109. ff9mapkit/deploystack.py +358 -0
  110. ff9mapkit/dialogue.py +803 -0
  111. ff9mapkit/eb/__init__.py +12 -0
  112. ff9mapkit/eb/_exprtable.py +59 -0
  113. ff9mapkit/eb/_membertable.py +38 -0
  114. ff9mapkit/eb/_optables.py +537 -0
  115. ff9mapkit/eb/_regen_optables.py +76 -0
  116. ff9mapkit/eb/cmdasm.py +323 -0
  117. ff9mapkit/eb/disasm.py +332 -0
  118. ff9mapkit/eb/edit.py +439 -0
  119. ff9mapkit/eb/exprasm.py +158 -0
  120. ff9mapkit/eb/model.py +178 -0
  121. ff9mapkit/eb/opcodes.py +463 -0
  122. ff9mapkit/eblint.py +177 -0
  123. ff9mapkit/editor/__init__.py +20 -0
  124. ff9mapkit/editor/app.py +950 -0
  125. ff9mapkit/editor/battle_forms.py +240 -0
  126. ff9mapkit/editor/breadcrumb.py +89 -0
  127. ff9mapkit/editor/dialogs.py +116 -0
  128. ff9mapkit/editor/feedback.py +208 -0
  129. ff9mapkit/editor/forms.py +632 -0
  130. ff9mapkit/editor/graphview.py +350 -0
  131. ff9mapkit/editor/jobs.py +342 -0
  132. ff9mapkit/editor/model.py +243 -0
  133. ff9mapkit/editor/picker.py +120 -0
  134. ff9mapkit/editor/theme.py +212 -0
  135. ff9mapkit/eventscan.py +1441 -0
  136. ff9mapkit/extract.py +2279 -0
  137. ff9mapkit/flags.py +693 -0
  138. ff9mapkit/forkreport.py +1383 -0
  139. ff9mapkit/hub.py +477 -0
  140. ff9mapkit/idgated.py +101 -0
  141. ff9mapkit/infohub.py +580 -0
  142. ff9mapkit/items.py +63 -0
  143. ff9mapkit/itemstats.py +346 -0
  144. ff9mapkit/journey.py +1902 -0
  145. ff9mapkit/keyitems.py +93 -0
  146. ff9mapkit/logic_add.py +632 -0
  147. ff9mapkit/logic_edit.py +728 -0
  148. ff9mapkit/logic_map.py +526 -0
  149. ff9mapkit/pack.py +175 -0
  150. ff9mapkit/playerswap.py +231 -0
  151. ff9mapkit/prop_archetypes.py +228 -0
  152. ff9mapkit/provision.py +282 -0
  153. ff9mapkit/refarc.py +825 -0
  154. ff9mapkit/save.py +337 -0
  155. ff9mapkit/save_items.py +1673 -0
  156. ff9mapkit/scene/__init__.py +11 -0
  157. ff9mapkit/scene/arena.py +63 -0
  158. ff9mapkit/scene/bgart.py +140 -0
  159. ff9mapkit/scene/bgi.py +732 -0
  160. ff9mapkit/scene/bgs.py +174 -0
  161. ff9mapkit/scene/bgx.py +185 -0
  162. ff9mapkit/scene/cam.py +345 -0
  163. ff9mapkit/scene/guide.py +311 -0
  164. ff9mapkit/scene/paint.py +506 -0
  165. ff9mapkit/scene/placeholder.py +107 -0
  166. ff9mapkit/sjbinary.py +285 -0
  167. ff9mapkit/sps/__init__.py +17 -0
  168. ff9mapkit/sps/author.py +294 -0
  169. ff9mapkit/sps/catalog.py +88 -0
  170. ff9mapkit/sps/codec.py +264 -0
  171. ff9mapkit/sps/edit.py +184 -0
  172. ff9mapkit/sps/lint.py +58 -0
  173. ff9mapkit/sps/render.py +116 -0
  174. ff9mapkit/sps/templates.py +47 -0
  175. ff9mapkit/sps/texture.py +131 -0
  176. ff9mapkit/walkmesh_hotfixes.py +163 -0
  177. ff9mapkit/workspace/__init__.py +18 -0
  178. ff9mapkit/workspace/battledoc.py +985 -0
  179. ff9mapkit/workspace/builddoc.py +607 -0
  180. ff9mapkit/workspace/forms_qt.py +586 -0
  181. ff9mapkit/workspace/importdoc.py +665 -0
  182. ff9mapkit/workspace/mapview.py +131 -0
  183. ff9mapkit/workspace/palette.py +85 -0
  184. ff9mapkit/workspace/savedoc.py +664 -0
  185. ff9mapkit/workspace/shell.py +6907 -0
  186. ff9mapkit/workspace/style.py +105 -0
  187. ff9mapkit/workspace/tuningdialog.py +223 -0
  188. ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
  189. ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
  190. ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
  191. ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
  192. ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
  193. ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
ff9mapkit/scene/bgs.py ADDED
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env python3
2
+ # FF9 field .bgs (BGSCENE_DEF) binary reader -- the BASE-GAME scene container.
3
+ #
4
+ # A real field's background scene ships as <fbg>.bgs.bytes inside StreamingAssets/p0data*.bin.
5
+ # It holds the field's CAMERAS (BGCAM_DEF) + overlays + animations + lights. The kit normally
6
+ # AUTHORS pure-Memoria .bgx scenes; but to IMPORT a real field as an editable base we must READ
7
+ # its native binary .bgs. This module parses the header + the camera list (what camera import
8
+ # needs); overlay/anim parsing can be layered on later for art extraction.
9
+ #
10
+ # Layout -- verified against Memoria source AND byte-exact vs the engine's own .bgx export
11
+ # (offline p0data spike, 2026-06-04: every GRGR camera field matched the FieldCreatorScene .bgx):
12
+ #
13
+ # BGSCENE_DEF.ExtractHeaderData (BGSCENE_DEF.cs:655), little-endian:
14
+ # u16 sceneLength, depthBitShift, animCount, overlayCount, lightCount, cameraCount
15
+ # u32 animOffset, overlayOffset, lightOffset, cameraOffset # cameraOffset is ABSOLUTE
16
+ # i16 orgZ,curZ, orgX,orgY, curX,curY, minX,maxX, minY,maxY, scrX,scrY
17
+ # then cameraCount BGCAM_DEF blocks at cameraOffset, 52 bytes each (BGCAM_DEF.ReadData, cs:17):
18
+ # u16 proj; i16 r[3][3]; i32 t[3]; i16 centerOffset[2]; i16 w,h;
19
+ # i16 vrpMinX,vrpMaxX,vrpMinY,vrpMaxY; i32 depthOffset
20
+ #
21
+ # The kit's Cam stores exactly these fields (cam.Cam: proj, r[3][3], t[3], centerOffset, range=[w,h],
22
+ # viewport=[vrpMinX,vrpMaxX,vrpMinY,vrpMaxY], depthOffset), so a parsed camera drops straight into
23
+ # cam.decompose / cam.to_canvas / bgx.format_bgx_camera.
24
+ from collections import namedtuple
25
+ import struct
26
+
27
+ from .cam import Cam
28
+
29
+ _HEADER = struct.Struct("<6H4I12h") # 6 counts (u16) + 4 offsets (u32) + 12 scene bounds (i16) = 52 B
30
+ _CAMERA = struct.Struct("<H9h3i2h2h4hi") # one BGCAM_DEF block
31
+ HEADER_SIZE = _HEADER.size # 52
32
+ CAMERA_SIZE = _CAMERA.size # 52
33
+
34
+ BgsHeader = namedtuple(
35
+ "BgsHeader",
36
+ "sceneLength depthBitShift animCount overlayCount lightCount cameraCount "
37
+ "animOffset overlayOffset lightOffset cameraOffset bounds",
38
+ )
39
+
40
+
41
+ def parse_header(data: bytes) -> BgsHeader:
42
+ f = _HEADER.unpack_from(data, 0)
43
+ return BgsHeader(*f[:10], bounds=tuple(f[10:22]))
44
+
45
+
46
+ def camera_from_block(buf: bytes, off: int = 0) -> Cam:
47
+ """Decode one 52-byte BGCAM_DEF block into a kit Cam."""
48
+ f = _CAMERA.unpack_from(buf, off)
49
+ c = Cam()
50
+ c.proj = f[0]
51
+ r = f[1:10]
52
+ c.r = [[r[0], r[1], r[2]], [r[3], r[4], r[5]], [r[6], r[7], r[8]]]
53
+ c.t = list(f[10:13])
54
+ c.centerOffset = list(f[13:15])
55
+ c.range = [f[15], f[16]]
56
+ c.viewport = list(f[17:21]) # vrpMinX, vrpMaxX, vrpMinY, vrpMaxY
57
+ c.depthOffset = f[21]
58
+ return c
59
+
60
+
61
+ def camera_to_block(cam: Cam) -> bytes:
62
+ """Encode a kit Cam back into a 52-byte BGCAM_DEF block (round-trip of camera_from_block)."""
63
+ r = cam.r
64
+ return _CAMERA.pack(
65
+ cam.proj,
66
+ r[0][0], r[0][1], r[0][2], r[1][0], r[1][1], r[1][2], r[2][0], r[2][1], r[2][2],
67
+ cam.t[0], cam.t[1], cam.t[2],
68
+ cam.centerOffset[0], cam.centerOffset[1],
69
+ cam.range[0], cam.range[1],
70
+ cam.viewport[0], cam.viewport[1], cam.viewport[2], cam.viewport[3],
71
+ cam.depthOffset,
72
+ )
73
+
74
+
75
+ def parse_cameras(data: bytes) -> list:
76
+ """All cameras from a .bgs byte blob, as kit Cam objects."""
77
+ h = parse_header(data)
78
+ return [camera_from_block(data, h.cameraOffset + i * CAMERA_SIZE) for i in range(h.cameraCount)]
79
+
80
+
81
+ # --------------------------------------------------------------------------- overlays + sprites (art)
82
+ # A field's background is a stack of OVERLAYS (depth layers); each overlay is a grid of 16-px TILES,
83
+ # each tile sampling a cell of the upscaled atlas.png. Porting BGSCENE_DEF.ExtractOverlayData/
84
+ # ExtractSpriteData + ExtractHeaderData lets us re-composite the real art offline (see scene.bgart).
85
+ _OVERLAY = struct.Struct("<I HH hhhh hhhh hh hh hh I I I I I") # BGOVERLAY_DEF.ReadData, 56 B
86
+ HEADER12 = struct.Struct("<12h")
87
+
88
+
89
+ def _bits(v, start, n):
90
+ return (v >> start) & ((1 << n) - 1)
91
+
92
+
93
+ class Sprite:
94
+ __slots__ = ("offX", "offY", "depth", "trans", "alpha", "atlasX", "atlasY")
95
+
96
+ def __init__(self, offX, offY, depth, trans, alpha, atlasX=0, atlasY=0):
97
+ self.offX, self.offY, self.depth = offX, offY, depth
98
+ self.trans, self.alpha = trans, alpha
99
+ self.atlasX, self.atlasY = atlasX, atlasY
100
+
101
+
102
+ class Overlay:
103
+ __slots__ = ("curZ", "orgZ", "orgX", "orgY", "w", "h", "camNdx",
104
+ "spriteCount", "locOffset", "prmOffset", "sprites")
105
+
106
+ def __init__(self, **kw):
107
+ for k, v in kw.items():
108
+ setattr(self, k, v)
109
+ self.sprites = []
110
+
111
+
112
+ def parse_overlays(data: bytes):
113
+ """(header, [Overlay]) — overlays with their tile sprites resolved to atlas grid cells.
114
+
115
+ Sprite atlas cells follow BGSCENE_DEF's upscale layout: a global tile index laid out
116
+ `countPerRow = atlasW // (tile_size+4)`, `atlasX = 2 + i%cpr*(tile_size+4)`, y analogous.
117
+ `tile_size` defaults to 64 (the Steam 4x atlas); pass the real value if it differs."""
118
+ h = parse_header(data)
119
+ overlays = []
120
+ off = h.overlayOffset
121
+ for _ in range(h.overlayCount):
122
+ f = _OVERLAY.unpack_from(data, off)
123
+ off += _OVERLAY.size
124
+ buf, buf2 = f[0], f[17]
125
+ # buf2 packs camNdx (low 8 bits) | isXOffset (1) | viewportNdx (7) | spriteCount (high 16)
126
+ # -- BGOVERLAY_DEF.ReadData. camNdx = which CAMERA paints this overlay (multi-camera fields
127
+ # split their art across cameras), the key to a per-camera backdrop.
128
+ overlays.append(Overlay(
129
+ curZ=_bits(buf, 8, 12), orgZ=_bits(buf, 20, 12),
130
+ orgX=f[3], orgY=f[4], w=f[1], h=f[2], camNdx=_bits(buf2, 0, 8),
131
+ spriteCount=_bits(buf2, 16, 16), locOffset=f[18], prmOffset=f[19]))
132
+ return h, overlays
133
+
134
+
135
+ TILE = 16 # one background tile is 16x16 logical (pre-upscale) px (BGSCENE_DEF sprite quad)
136
+
137
+
138
+ def tile_box(sprite, mnX, mnY, upscale: int = 4, tile: int = TILE):
139
+ """The crop rectangle ``(left, top, right, bottom)`` of one tile-sprite inside its overlay's
140
+ engine-exported ``Overlay{i}.png``.
141
+
142
+ The ``[Export] Field=1`` dump writes each overlay as a tight composite whose pixel (0,0) is the
143
+ overlay's MIN-offset tile (BGSCENE_DEF.cs:570-588), which is exactly where
144
+ ``extract.compose_background`` places the whole PNG (``(sOrg+org+min(off))*upscale``, no flip), so
145
+ a tile at ``(offX, offY)`` sits ``(offX-mnX, offY-mnY)`` tiles in -- each ``tile`` px, upscaled.
146
+ Pure arithmetic so the per-tile occlusion split is unit-testable without art."""
147
+ x0 = (sprite.offX - mnX) * upscale
148
+ y0 = (sprite.offY - mnY) * upscale
149
+ return (x0, y0, x0 + tile * upscale, y0 + tile * upscale)
150
+
151
+
152
+ def resolve_sprites(data: bytes, overlays, atlas_w: int, tile_size: int = 64):
153
+ """Fill each overlay's .sprites (offX/offY/depth + atlas cell). Mutates in place."""
154
+ cpr = atlas_w // (tile_size + 4)
155
+ idx = 0
156
+ for ov in overlays:
157
+ alpha_trans = []
158
+ po = ov.prmOffset
159
+ for _ in range(ov.spriteCount):
160
+ p1, p2 = struct.unpack_from("<II", data, po)
161
+ po += 8
162
+ alpha_trans.append((_bits(p1, 22, 2), _bits(p2, 28, 1))) # alpha, trans (shader)
163
+ lo = ov.locOffset
164
+ for j in range(ov.spriteCount):
165
+ (L,) = struct.unpack_from("<I", data, lo)
166
+ lo += 4
167
+ depth, offY, offX = _bits(L, 0, 12), _bits(L, 12, 10), _bits(L, 22, 10)
168
+ alpha, trans = alpha_trans[j]
169
+ ov.sprites.append(Sprite(
170
+ offX, offY, depth, trans, alpha,
171
+ atlasX=2 + idx % cpr * (tile_size + 4),
172
+ atlasY=2 + idx // cpr * (tile_size + 4)))
173
+ idx += 1
174
+ return overlays
ff9mapkit/scene/bgx.py ADDED
@@ -0,0 +1,185 @@
1
+ """FF9 pure-Memoria background scene (``.bgx``) text format.
2
+
3
+ A ``.bgx`` is a small line-oriented text file describing how a field's background renders:
4
+ a sequence of blocks (``OVERLAY`` layers, a ``CAMERA``, optional ``ANIMATION`` /
5
+ ``USE_BASE_SCENE`` / ``LANGUAGE``). Memoria parses it whitespace-insensitively (blank and
6
+ ``#``/``//`` comment lines are skipped), so this module models blocks structurally and emits
7
+ a canonical form. Two helpers cover the kit's needs:
8
+
9
+ * :class:`BgxScene` — parse / serialize / inspect (overlays + camera) / surgically replace.
10
+ * :func:`build` — assemble a scene from a :class:`~ff9mapkit.scene.cam.Cam` + overlays.
11
+
12
+ OVERLAY keys: OverlayId? CameraId ViewportId Position(x,y,z) Size(w,h) Image Shader.
13
+ CAMERA keys : ViewDistance CenterOffset Position Range DepthOffset Viewport
14
+ OrientationMatrix (9) | OrientationAngles (3).
15
+ Overlay Z (Position 3rd value) is the depth: smaller = nearer the camera (drawn in front).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, field
21
+
22
+ from . import cam as _cam
23
+
24
+ BLOCK_TYPES = ("LANGUAGE", "USE_BASE_SCENE", "OVERLAY", "ANIMATION", "CAMERA")
25
+ DEFAULT_SHADER = "PSX/FieldMap_Abr_None"
26
+
27
+
28
+ @dataclass
29
+ class Overlay:
30
+ """One background layer."""
31
+
32
+ image: str
33
+ position: tuple = (0, 0, 0) # x, y, z(depth)
34
+ size: tuple = (384, 448) # w, h
35
+ shader: str = DEFAULT_SHADER
36
+ camera_id: int = 0
37
+ viewport_id: int = 0
38
+ overlay_id: int | None = None
39
+
40
+ def to_lines(self) -> list[str]:
41
+ lines = ["OVERLAY"]
42
+ if self.overlay_id is not None:
43
+ lines.append(f"OverlayId: {self.overlay_id}")
44
+ lines.append(f"CameraId: {self.camera_id}")
45
+ lines.append(f"ViewportId: {self.viewport_id}")
46
+ lines.append(f"Position: {self.position[0]}, {self.position[1]}, {self.position[2]}")
47
+ lines.append(f"Size: {self.size[0]}, {self.size[1]}")
48
+ lines.append(f"Image: {self.image}")
49
+ lines.append(f"Shader: {self.shader}")
50
+ return lines
51
+
52
+ @classmethod
53
+ def from_fields(cls, fields: list[tuple]) -> "Overlay":
54
+ o = cls(image="")
55
+ for key, val in fields:
56
+ args = [a.strip() for a in val.split(",")]
57
+ if key == "OverlayId":
58
+ o.overlay_id = int(args[0])
59
+ elif key == "CameraId":
60
+ o.camera_id = int(args[0])
61
+ elif key == "ViewportId":
62
+ o.viewport_id = int(args[0])
63
+ elif key == "Position":
64
+ o.position = (int(args[0]), int(args[1]), int(args[2]))
65
+ elif key == "Size":
66
+ o.size = (int(args[0]), int(args[1]))
67
+ elif key == "Image":
68
+ o.image = val.strip()
69
+ elif key == "Shader":
70
+ o.shader = val.strip()
71
+ return o
72
+
73
+
74
+ def _camera_to_lines(cam: _cam.Cam) -> list[str]:
75
+ # reuse cam.format_bgx_camera (already validated faithful) minus its trailing newline
76
+ return _cam.format_bgx_camera(cam).rstrip("\n").split("\n")
77
+
78
+
79
+ @dataclass
80
+ class _Block:
81
+ type: str
82
+ fields: list = field(default_factory=list) # ordered (key, value) for non-comment lines
83
+
84
+
85
+ class BgxScene:
86
+ """Structured view of a ``.bgx``: leading comments, ordered blocks (overlays / camera / ...)."""
87
+
88
+ def __init__(self):
89
+ self.header_comments: list[str] = [] # comment lines before the first block
90
+ self.blocks: list[_Block] = []
91
+
92
+ # ---- parse ----
93
+ @classmethod
94
+ def parse(cls, text: str) -> "BgxScene":
95
+ self = cls()
96
+ cur: _Block | None = None
97
+ seen_block = False
98
+ for raw in text.splitlines():
99
+ s = raw.strip()
100
+ if not s:
101
+ continue
102
+ if s.startswith("#") or s.startswith("//"):
103
+ if not seen_block:
104
+ self.header_comments.append(s)
105
+ continue
106
+ if s in BLOCK_TYPES:
107
+ cur = _Block(s)
108
+ self.blocks.append(cur)
109
+ seen_block = True
110
+ continue
111
+ if cur is not None and ":" in s:
112
+ key, _, val = s.partition(":")
113
+ cur.fields.append((key.strip(), val.strip()))
114
+ return self
115
+
116
+ @classmethod
117
+ def from_file(cls, path) -> "BgxScene":
118
+ with open(path, encoding="utf-8", errors="replace") as fh:
119
+ return cls.parse(fh.read())
120
+
121
+ # ---- serialize (canonical) ----
122
+ def to_text(self) -> str:
123
+ out: list[str] = list(self.header_comments)
124
+ for blk in self.blocks:
125
+ if blk.type == "OVERLAY":
126
+ out += self.overlay_of(blk).to_lines()
127
+ elif blk.type == "CAMERA":
128
+ out += _camera_to_lines(self.camera_of(blk))
129
+ else:
130
+ out.append(blk.type)
131
+ out += [f"{k}: {v}" for k, v in blk.fields]
132
+ out.append("") # blank line between blocks
133
+ return "\n".join(out).rstrip("\n") + "\n"
134
+
135
+ # ---- typed views ----
136
+ @staticmethod
137
+ def overlay_of(blk: _Block) -> Overlay:
138
+ return Overlay.from_fields(blk.fields)
139
+
140
+ @staticmethod
141
+ def camera_of(blk: _Block) -> _cam.Cam:
142
+ text = "CAMERA\n" + "\n".join(f"{k}: {v}" for k, v in blk.fields) + "\n"
143
+ return _cam.parse_bgx_cameras_text(text)[0]
144
+
145
+ @property
146
+ def overlays(self) -> list[Overlay]:
147
+ return [self.overlay_of(b) for b in self.blocks if b.type == "OVERLAY"]
148
+
149
+ @property
150
+ def cameras(self) -> list[_cam.Cam]:
151
+ return [self.camera_of(b) for b in self.blocks if b.type == "CAMERA"]
152
+
153
+ # ---- edits ----
154
+ def set_camera(self, cam: _cam.Cam) -> None:
155
+ """Replace the (first) CAMERA block's fields from a Cam, preserving everything else."""
156
+ lines = _camera_to_lines(cam)[1:] # drop the "CAMERA" header
157
+ fields = [tuple(s.split(": ", 1)) for s in lines]
158
+ for blk in self.blocks:
159
+ if blk.type == "CAMERA":
160
+ blk.fields = [(k, v) for k, v in fields]
161
+ return
162
+ self.blocks.append(_Block("CAMERA", [(k, v) for k, v in fields]))
163
+
164
+
165
+ def build(camera, overlays: list[Overlay], *, header_comment: str | None = None,
166
+ base_scene: str | None = None) -> str:
167
+ """Assemble a complete ``.bgx`` text from a camera (or list of cameras) + ordered overlays.
168
+
169
+ ``camera`` may be a single ``cam.Cam`` (one CAMERA block, unchanged) or a list of Cams for a
170
+ MULTI-CAMERA field -- N CAMERA blocks in order (camera index = list position). Each overlay's
171
+ ``camera_id`` selects which camera shows it; the script switches the active camera via
172
+ SetFieldCamera (see content.camera). The engine reads cameras in file order (camIdx 0..N-1)."""
173
+ cameras = camera if isinstance(camera, (list, tuple)) else [camera]
174
+ out: list[str] = []
175
+ if header_comment:
176
+ out.append(f"# {header_comment}")
177
+ if base_scene:
178
+ out += ["USE_BASE_SCENE", f"Name: {base_scene}", ""]
179
+ for ov in overlays:
180
+ out += ov.to_lines()
181
+ out.append("")
182
+ for c in cameras:
183
+ out += _camera_to_lines(c)
184
+ out.append("")
185
+ return "\n".join(out).rstrip("\n") + "\n"