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/bgi.py ADDED
@@ -0,0 +1,732 @@
1
+ """FF9 walkmesh (``.bgi.bytes``) codec + a flat-floor builder.
2
+
3
+ The ``.bgi`` describes the invisible walkable geometry: a set of triangles (with vertex /
4
+ edge / neighbor links so the engine can path and detect borders), grouped into floors. This
5
+ module mirrors Memoria's ``BGI_DEF`` read/write exactly, so :meth:`BgiWalkmesh.to_bytes`
6
+ round-trips any real ``.bgi`` byte-for-byte, and adds:
7
+
8
+ * :func:`build_flat` — construct a complete ``.bgi`` from world-space vertices + triangle
9
+ faces (the human's Blender walkmesh, flat y=0 floor). Computes triangle centers, the
10
+ per-triangle edge entries, the neighbor/edgeClone links, and a single floor. This removes
11
+ the dependency on Memoria's in-editor ``ConvertToBGI``.
12
+ * :func:`load_obj` — read a Wavefront ``.obj`` (vertices in FF9 world coords, faces).
13
+ * :meth:`BgiWalkmesh.rebuild_neighbors` — recompute all neighbor/edgeClone links from
14
+ shared-vertex analysis (the ``bgi_fix_neighbors`` fix: ``ConvertToBGI`` links unreliably).
15
+
16
+ File layout (little-endian; offsets in the header are relative to byte 4):
17
+ magic u32 = 0xACDCDEAD ; dataSize u16 ;
18
+ orgPos curPos minPos maxPos charPos : BGI_VEC (3xi16 each) ;
19
+ activeFloor i16 ; activeTri i16 ;
20
+ {tri,edge,anm,floor,normal,vertex} x (count u16, offset u16) ;
21
+ sections in order: tris(40B) edges(4B) anms(16B) floors(32B) normals(16B) verts(6B)
22
+ then per-anm frames(8B), per-floor triNdx int32 list, per-frame triNdx.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import struct
28
+ from collections import deque
29
+ from dataclasses import dataclass, field
30
+
31
+ MAGIC = 0xACDCDEAD
32
+ # THE FRAME (confirmed from Memoria source -- WalkMesh.cs:53,141,227):
33
+ # world_vertex = vertexList[i] + floor.org + bgi.orgPos (collision uses *.cur, equal
34
+ # to *.org for a static field)
35
+ # So orgPos and each floor.org ARE a render/collision transform, NOT mere bookkeeping. A real .bgi
36
+ # stores each FLOOR's verts CORNER-ORIGIN in the floor's own frame and tiles them with floor.org,
37
+ # with bgi.orgPos placing the whole mesh in the world (== header.minPos by convention). The IMPORTER
38
+ # inverts this in BgiWalkmesh.world_verts(); this EXPORTER is its inverse: for authored/edited
39
+ # WORLD-coordinate geometry it emits orgPos=0 and every floor.org=0, so world_vertex = vertexList[i]
40
+ # verbatim -- what you author is exactly where the engine renders it. (header.minPos/maxPos and the
41
+ # per-floor min/max are loaded but UNUSED at runtime -- WavefrontObject.cs sets them, nothing reads
42
+ # them; charPos is the debug spawn. Verified in-game across GLGV/GRGR/BRMC/BMVL, 1..7 floors.)
43
+ HEADER_SIZE = 64 # magic+dataSize+5*vec(30)+activeFloor/Tri(4)+12*u16(24)
44
+ FIRST_SECTION_REL = 0x3C # triOffset (relative to byte 4)
45
+ TRI_SIZE, EDGE_SIZE, ANM_SIZE, FLOOR_SIZE, NORMAL_SIZE, VERT_SIZE = 40, 4, 16, 32, 16, 6
46
+
47
+ # slot -> the pair of triangle-local vertex indices forming that edge (BGI convention)
48
+ SLOT_PAIRS = [(0, 2), (0, 1), (1, 2)]
49
+
50
+
51
+ def _pt_in_tri_xz(px, pz, a, b, c) -> bool:
52
+ """(px,pz) inside-or-on triangle (a,b,c) using only X and Z (top-down). Same-sign barycentric."""
53
+ def cross(p, q):
54
+ return (px - q[0]) * (p[2] - q[2]) - (p[0] - q[0]) * (pz - q[2])
55
+ d1, d2, d3 = cross(a, b), cross(b, c), cross(c, a)
56
+ return not ((d1 < 0 or d2 < 0 or d3 < 0) and (d1 > 0 or d2 > 0 or d3 > 0))
57
+
58
+
59
+ def _pt_seg_dist_xz(px, pz, a, b) -> float:
60
+ """Distance from (px,pz) to segment a-b in the XZ plane (Y ignored)."""
61
+ ax, az, bx, bz = a[0], a[2], b[0], b[2]
62
+ dx, dz = bx - ax, bz - az
63
+ l2 = dx * dx + dz * dz
64
+ t = 0.0 if l2 == 0 else max(0.0, min(1.0, ((px - ax) * dx + (pz - az) * dz) / l2))
65
+ cx, cz = ax + t * dx, az + t * dz
66
+ return ((px - cx) ** 2 + (pz - cz) ** 2) ** 0.5
67
+
68
+
69
+ def _i16(b, o):
70
+ return struct.unpack_from("<h", b, o)[0]
71
+
72
+
73
+ def _u16(b, o):
74
+ return struct.unpack_from("<H", b, o)[0]
75
+
76
+
77
+ def _i32(b, o):
78
+ return struct.unpack_from("<i", b, o)[0]
79
+
80
+
81
+ @dataclass
82
+ class Vec3:
83
+ x: int = 0
84
+ y: int = 0
85
+ z: int = 0
86
+
87
+ def pack(self) -> bytes:
88
+ return struct.pack("<hhh", self.x, self.y, self.z)
89
+
90
+ @classmethod
91
+ def read(cls, b, o):
92
+ return cls(_i16(b, o), _i16(b, o + 2), _i16(b, o + 4))
93
+
94
+
95
+ @dataclass
96
+ class FVec:
97
+ coord: tuple = (0, 0, 0)
98
+ one_over_y: int = 0
99
+
100
+ def pack(self) -> bytes:
101
+ return struct.pack("<iiii", self.coord[0], self.coord[1], self.coord[2], self.one_over_y)
102
+
103
+ @classmethod
104
+ def read(cls, b, o):
105
+ return cls((_i32(b, o), _i32(b, o + 4), _i32(b, o + 8)), _i32(b, o + 12))
106
+
107
+
108
+ @dataclass
109
+ class Tri:
110
+ tri_flags: int = 1
111
+ tri_data: int = 0
112
+ floor_ndx: int = 0
113
+ normal_ndx: int = -1
114
+ theta_x: int = 0
115
+ theta_z: int = 0
116
+ vtx: list = field(default_factory=lambda: [0, 0, 0])
117
+ edge: list = field(default_factory=lambda: [0, 0, 0])
118
+ nbr: list = field(default_factory=lambda: [-1, -1, -1])
119
+ center: Vec3 = field(default_factory=Vec3)
120
+ d: int = 0
121
+
122
+ def pack(self) -> bytes:
123
+ return (struct.pack("<HHhhhh", self.tri_flags, self.tri_data, self.floor_ndx,
124
+ self.normal_ndx, self.theta_x, self.theta_z)
125
+ + struct.pack("<hhh", *self.vtx)
126
+ + struct.pack("<hhh", *self.edge)
127
+ + struct.pack("<hhh", *self.nbr)
128
+ + self.center.pack()
129
+ + struct.pack("<i", self.d))
130
+
131
+ @classmethod
132
+ def read(cls, b, o):
133
+ t = cls()
134
+ t.tri_flags, t.tri_data, t.floor_ndx, t.normal_ndx, t.theta_x, t.theta_z = \
135
+ struct.unpack_from("<HHhhhh", b, o)
136
+ t.vtx = list(struct.unpack_from("<hhh", b, o + 12))
137
+ t.edge = list(struct.unpack_from("<hhh", b, o + 18))
138
+ t.nbr = list(struct.unpack_from("<hhh", b, o + 24))
139
+ t.center = Vec3.read(b, o + 30)
140
+ t.d = _i32(b, o + 36)
141
+ return t
142
+
143
+
144
+ @dataclass
145
+ class Edge:
146
+ flags: int = 0
147
+ clone: int = -1
148
+
149
+ def pack(self) -> bytes:
150
+ return struct.pack("<Hh", self.flags, self.clone)
151
+
152
+ @classmethod
153
+ def read(cls, b, o):
154
+ return cls(_u16(b, o), _i16(b, o + 2))
155
+
156
+
157
+ @dataclass
158
+ class Floor:
159
+ flags: int = 0
160
+ ndx: int = 0
161
+ org: Vec3 = field(default_factory=Vec3)
162
+ cur: Vec3 = field(default_factory=Vec3)
163
+ min: Vec3 = field(default_factory=Vec3)
164
+ max: Vec3 = field(default_factory=Vec3)
165
+ tri_ndx_list: list = field(default_factory=list)
166
+
167
+ def pack_struct(self, tri_ndx_offset: int) -> bytes:
168
+ return (struct.pack("<HH", self.flags, self.ndx)
169
+ + self.org.pack() + self.cur.pack() + self.min.pack() + self.max.pack()
170
+ + struct.pack("<HH", len(self.tri_ndx_list), tri_ndx_offset))
171
+
172
+
173
+ @dataclass
174
+ class Anm:
175
+ flags: int = 0
176
+ frame_rate: int = 0
177
+ counter: int = 0
178
+ cur_frame: int = 0
179
+ frames: list = field(default_factory=list) # list of (flags, value, tri_idx_list)
180
+
181
+
182
+ class BgiWalkmesh:
183
+ def __init__(self):
184
+ self.orgPos = Vec3(0, 0, 300)
185
+ self.curPos = Vec3(0, 0, 300)
186
+ self.minPos = Vec3(0, 0, 300)
187
+ self.maxPos = Vec3(0, 0, 300)
188
+ self.charPos = Vec3(0, 0, 300)
189
+ self.activeFloor = 0
190
+ self.activeTri = 0
191
+ self.tris: list[Tri] = []
192
+ self.edges: list[Edge] = []
193
+ self.anms: list[Anm] = []
194
+ self.floors: list[Floor] = []
195
+ self.normals: list[FVec] = []
196
+ self.verts: list[Vec3] = []
197
+
198
+ # ---------------- world transform (corner-origin per-floor -> world) ----------------
199
+ def vert_floor_map(self) -> dict:
200
+ """vert index -> floor index. A real .bgi gives each floor a DISJOINT vertex set, so this
201
+ is well-defined; unowned verts map to floor 0."""
202
+ m = {}
203
+ for fi, fl in enumerate(self.floors):
204
+ for ti in fl.tri_ndx_list:
205
+ if 0 <= ti < len(self.tris):
206
+ for vi in self.tris[ti].vtx:
207
+ m[vi] = fi
208
+ return m
209
+
210
+ def world_verts(self):
211
+ """World (camera/art/engine) position of each vertex = vert + header.orgPos + its floor.org.
212
+
213
+ Each FLOOR stores its verts CORNER-ORIGIN in the floor's own frame; `floor.org` tiles the
214
+ floors and the header `orgPos` places the whole walkmesh in the world. Verified: this lands
215
+ every GRGR vert exactly inside the header [minPos,maxPos] and tiles its 7 floors into a
216
+ coherent centred tunnel. Single-floor fields have floor.org=(0,0,0), so this reduces to
217
+ vert + orgPos (GLGV unchanged). This is the exact transform the EXPORTER inverts."""
218
+ vf = self.vert_floor_map()
219
+ op = self.orgPos
220
+ out = []
221
+ for i, v in enumerate(self.verts):
222
+ fi = vf.get(i)
223
+ fo = self.floors[fi].org if (fi is not None and fi < len(self.floors)) else Vec3(0, 0, 0)
224
+ out.append((v.x + op.x + fo.x, v.y + op.y + fo.y, v.z + op.z + fo.z))
225
+ return out
226
+
227
+ # ---------------- connectivity (the navmesh adjacency graph) ----------------
228
+ def all_floors(self) -> set:
229
+ return {t.floor_ndx for t in self.tris}
230
+
231
+ def reachable_floors(self, start_tri: int | None = None) -> set:
232
+ """Floor indices walk-reachable from start_tri (default activeTri) by following triangle
233
+ neighbor links -- the SAME links the engine pathfinds over. If this is a strict subset of
234
+ all_floors(), some floors are stranded (unreachable on foot).
235
+
236
+ This is the build-time guard for the obj->build connectivity loss: a multi-floor walkmesh
237
+ gives each floor a DISJOINT vertex set, so rebuild_neighbors (which links by shared vertex
238
+ index) can only connect within a floor -- cross-floor seams vanish and the player is trapped.
239
+ The .bgi codec itself preserves the original links; only the obj intermediate drops them."""
240
+ start = start_tri if start_tri is not None else self.activeTri
241
+ if not (0 <= start < len(self.tris)):
242
+ start = 0
243
+ seen, q = set(), deque([start])
244
+ while q:
245
+ t = q.popleft()
246
+ if t in seen or not (0 <= t < len(self.tris)):
247
+ continue
248
+ seen.add(t)
249
+ for n in self.tris[t].nbr:
250
+ if n >= 0:
251
+ q.append(n)
252
+ return {self.tris[t].floor_ndx for t in seen}
253
+
254
+ def tri_components(self) -> list:
255
+ """Connected components of triangles by neighbor links -- each a list of triangle indices. A walkmesh
256
+ that splits into >1 component has walled-off regions (e.g. a shop counter separating the customer area
257
+ from the behind-counter pocket); the largest on-screen component is the player's free-roam area, so a
258
+ fork should spawn there (not stranded in a pocket). Single-region walkmesh -> one component."""
259
+ seen, comps = set(), []
260
+ for s in range(len(self.tris)):
261
+ if s in seen:
262
+ continue
263
+ comp, q = [], deque([s])
264
+ while q:
265
+ t = q.popleft()
266
+ if t in seen or not (0 <= t < len(self.tris)):
267
+ continue
268
+ seen.add(t)
269
+ comp.append(t)
270
+ for n in self.tris[t].nbr:
271
+ if n >= 0:
272
+ q.append(n)
273
+ comps.append(comp)
274
+ return comps
275
+
276
+ # ---------------- seam extract / reconcile (the editable-multi-floor sidecar; WALKMESH_EDITING.md) ----------------
277
+ def _tri_floor(self) -> dict:
278
+ return {ti: fi for fi, fl in enumerate(self.floors) for ti in fl.tri_ndx_list}
279
+
280
+ def _edge_world_pos(self, wv, ti, slot):
281
+ """The slot's edge as a sorted pair of world positions (a stable, renumber-proof key)."""
282
+ i, j = SLOT_PAIRS[slot]
283
+ return tuple(sorted((wv[self.tris[ti].vtx[i]], wv[self.tris[ti].vtx[j]])))
284
+
285
+ def extract_seams(self):
286
+ """Cross-floor seams as (a_floor, a_edge, b_floor, b_edge); each edge a sorted world-position
287
+ pair. This is the adjacency a geometry-only `.obj` can't carry (rebuild_neighbors links only
288
+ within a floor, and FF9 floors use disjoint vertex sets). Pair with `apply_seams` to reconcile
289
+ an edited obj against an imported field. Validated game-wide (tools/sweep_seams.py)."""
290
+ wv = self.world_verts()
291
+ fo = self._tri_floor()
292
+ seams, seen = [], set()
293
+ for ti, t in enumerate(self.tris):
294
+ fa = fo.get(ti)
295
+ for k in range(3):
296
+ nb = t.nbr[k]
297
+ if nb < 0 or nb >= len(self.tris) or fo.get(nb) == fa:
298
+ continue
299
+ key = (min(ti, nb), max(ti, nb))
300
+ if key in seen:
301
+ continue
302
+ seen.add(key)
303
+ ec = self.edges[t.edge[k]].clone if 0 <= t.edge[k] < len(self.edges) else -1
304
+ a = self._edge_world_pos(wv, ti, k)
305
+ b = self._edge_world_pos(wv, nb, ec) if 0 <= ec < 3 else None
306
+ seams.append((fa, a, fo.get(nb), b))
307
+ return seams
308
+
309
+ def apply_seams(self, seams):
310
+ """Link cross-floor neighbors by matching each seam's edge endpoints by WORLD POSITION (this
311
+ mesh already has intra-floor links from `bgi.build`). Sets `nbr` + `edgeClone` on both sides,
312
+ the same convention as `rebuild_neighbors`. Returns (linked, missing, misses) -- a miss means
313
+ a seam's connecting edge was moved/deleted in the edit. The v2 reconcile."""
314
+ wv = self.world_verts()
315
+ fo = self._tri_floor()
316
+ lut = {}
317
+ for ti in range(len(self.tris)):
318
+ for k in range(3):
319
+ lut[(fo[ti], self._edge_world_pos(wv, ti, k))] = (ti, k)
320
+ linked = missing = 0
321
+ misses = []
322
+ for (fa, a_edge, fb, b_edge) in seams:
323
+ ta = lut.get((fa, a_edge))
324
+ tb = lut.get((fb, b_edge)) if b_edge else None
325
+ if ta and tb:
326
+ (ia, sa), (ib, sb) = ta, tb
327
+ self.tris[ia].nbr[sa] = ib
328
+ self.tris[ib].nbr[sb] = ia
329
+ self.edges[self.tris[ia].edge[sa]].clone = sb
330
+ self.edges[self.tris[ib].edge[sb]].clone = sa
331
+ linked += 1
332
+ else:
333
+ missing += 1
334
+ misses.append((fa, a_edge, fb, b_edge))
335
+ return linked, missing, misses
336
+
337
+ # ---------------- placement / geometry validation (catch authoring mistakes pre-build) ----------------
338
+ def point_on_walkmesh(self, x, z):
339
+ """Floor index of the triangle whose XZ-projection contains (x, z), else None. Validates that
340
+ authored content (NPC / player spawn / gateway zone) sits on the walkable area before build --
341
+ a top-down point-in-triangle test (multi-floor fields can overlap in XZ; returns first match)."""
342
+ wv = self.world_verts()
343
+ fo = self._tri_floor()
344
+ for ti, t in enumerate(self.tris):
345
+ if _pt_in_tri_xz(x, z, wv[t.vtx[0]], wv[t.vtx[1]], wv[t.vtx[2]]):
346
+ return fo.get(ti, t.floor_ndx)
347
+ return None
348
+
349
+ def height_at(self, x, z):
350
+ """The floor Y (world height) at (x, z): the triangle whose XZ-projection contains (x, z),
351
+ barycentric-interpolated from its 3 verts. None if off-mesh. A navigable ladder's dismount must
352
+ land at the floor's REAL height -- otherwise the jump arcs to Y=0 and SetPathing snaps you up
353
+ (the fall+slingshot). Lets the builder auto-fill an omitted floor_landing/top_landing Y."""
354
+ wv = self.world_verts()
355
+ for t in self.tris:
356
+ a, b, c = wv[t.vtx[0]], wv[t.vtx[1]], wv[t.vtx[2]] # each vert = (x, y, z)
357
+ if _pt_in_tri_xz(x, z, a, b, c):
358
+ den = (b[2] - c[2]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[2] - c[2])
359
+ if den == 0:
360
+ return int(round(a[1]))
361
+ wa = ((b[2] - c[2]) * (x - c[0]) + (c[0] - b[0]) * (z - c[2])) / den
362
+ wb = ((c[2] - a[2]) * (x - c[0]) + (a[0] - c[0]) * (z - c[2])) / den
363
+ return int(round(wa * a[1] + wb * b[1] + (1 - wa - wb) * c[1]))
364
+ return None
365
+
366
+ def distance_to_boundary(self, x, z):
367
+ """Min XZ distance from (x,z) to the nearest collision WALL of the floor it sits on -- a
368
+ walkmesh-boundary edge (one with no neighbor across it). A cross-floor SEAM is NOT a wall
369
+ (the player crosses it to the next floor), so a seamed edge is skipped. Returns None if the
370
+ point is off the walkmesh. The player's CENTRE can't get within ~COLLISION_RADIUS_W of a
371
+ wall, so content closer than that to an edge may be unreachable / shoved inward in-game."""
372
+ floor = self.point_on_walkmesh(x, z)
373
+ if floor is None:
374
+ return None
375
+ wv = self.world_verts()
376
+ fo = self._tri_floor()
377
+ best = None
378
+ for ti, t in enumerate(self.tris):
379
+ if fo.get(ti, t.floor_ndx) != floor:
380
+ continue
381
+ for k in range(3):
382
+ if t.nbr[k] >= 0: # neighbor across this edge -> not a wall
383
+ continue
384
+ i, j = SLOT_PAIRS[k]
385
+ d = _pt_seg_dist_xz(x, z, wv[t.vtx[i]], wv[t.vtx[j]])
386
+ if best is None or d < best:
387
+ best = d
388
+ return best
389
+
390
+ def degenerate_tris(self):
391
+ """Triangle indices with ~zero XZ area -- collinear/vertical verts make a DEAD ZONE in the
392
+ engine's IsInQuad fan test (the player can't stand there). Almost always an editing mistake."""
393
+ wv = self.world_verts()
394
+ out = []
395
+ for ti, t in enumerate(self.tris):
396
+ a, b, c = wv[t.vtx[0]], wv[t.vtx[1]], wv[t.vtx[2]]
397
+ if (b[0] - a[0]) * (c[2] - a[2]) - (c[0] - a[0]) * (b[2] - a[2]) == 0:
398
+ out.append(ti)
399
+ return out
400
+
401
+ # ---------------- parse ----------------
402
+ @classmethod
403
+ def from_bytes(cls, data: bytes) -> "BgiWalkmesh":
404
+ b = bytes(data)
405
+ if struct.unpack_from("<I", b, 0)[0] != MAGIC:
406
+ raise ValueError("not a .bgi (bad magic)")
407
+ self = cls()
408
+ self.orgPos = Vec3.read(b, 6)
409
+ self.curPos = Vec3.read(b, 12)
410
+ self.minPos = Vec3.read(b, 18)
411
+ self.maxPos = Vec3.read(b, 24)
412
+ self.charPos = Vec3.read(b, 30)
413
+ self.activeFloor = _i16(b, 36)
414
+ self.activeTri = _i16(b, 38)
415
+ triCount, triOff = _u16(b, 40), _u16(b, 42)
416
+ edgeCount, edgeOff = _u16(b, 44), _u16(b, 46)
417
+ anmCount, anmOff = _u16(b, 48), _u16(b, 50)
418
+ floorCount, floorOff = _u16(b, 52), _u16(b, 54)
419
+ normalCount, normalOff = _u16(b, 56), _u16(b, 58)
420
+ vertexCount, vertexOff = _u16(b, 60), _u16(b, 62)
421
+ for i in range(triCount):
422
+ self.tris.append(Tri.read(b, 4 + triOff + i * TRI_SIZE))
423
+ for i in range(edgeCount):
424
+ self.edges.append(Edge.read(b, 4 + edgeOff + i * EDGE_SIZE))
425
+ raw_anms = []
426
+ for i in range(anmCount):
427
+ o = 4 + anmOff + i * ANM_SIZE
428
+ flags, fcount, frate, counter = struct.unpack_from("<HHhH", b, o)
429
+ curframe = _i32(b, o + 8)
430
+ foff = struct.unpack_from("<I", b, o + 12)[0]
431
+ raw_anms.append((flags, fcount, frate, counter, curframe, foff))
432
+ for i in range(floorCount):
433
+ o = 4 + floorOff + i * FLOOR_SIZE
434
+ fl = Floor()
435
+ fl.flags, fl.ndx = struct.unpack_from("<HH", b, o)
436
+ fl.org = Vec3.read(b, o + 4)
437
+ fl.cur = Vec3.read(b, o + 10)
438
+ fl.min = Vec3.read(b, o + 16)
439
+ fl.max = Vec3.read(b, o + 22)
440
+ tcount, toff = struct.unpack_from("<HH", b, o + 28)
441
+ fl.tri_ndx_list = [_i32(b, 4 + toff + k * 4) for k in range(tcount)]
442
+ self.floors.append(fl)
443
+ for i in range(normalCount):
444
+ self.normals.append(FVec.read(b, 4 + normalOff + i * NORMAL_SIZE))
445
+ for i in range(vertexCount):
446
+ self.verts.append(Vec3.read(b, 4 + vertexOff + i * VERT_SIZE))
447
+ # anm frames (after vertices); preserved for round-trip fidelity
448
+ for (flags, fcount, frate, counter, curframe, foff) in raw_anms:
449
+ a = Anm(flags, frate, counter, curframe)
450
+ for j in range(fcount):
451
+ fo = 4 + foff + j * 8
452
+ fflags, value, tcount, toff = struct.unpack_from("<HhHH", b, fo)
453
+ idxs = [_i32(b, 4 + toff + k * 4) for k in range(tcount)]
454
+ a.frames.append((fflags, value, idxs))
455
+ self.anms.append(a)
456
+ return self
457
+
458
+ @classmethod
459
+ def from_file(cls, path) -> "BgiWalkmesh":
460
+ with open(path, "rb") as fh:
461
+ return cls.from_bytes(fh.read())
462
+
463
+ # ---------------- serialize (mirrors BGI_DEF.WriteData + UpdateOffsets) ----------------
464
+ def to_bytes(self) -> bytes:
465
+ triCount, edgeCount = len(self.tris), len(self.edges)
466
+ anmCount, floorCount = len(self.anms), len(self.floors)
467
+ normalCount, vertexCount = len(self.normals), len(self.verts)
468
+
469
+ off = FIRST_SECTION_REL
470
+ triOff = off; off += TRI_SIZE * triCount
471
+ edgeOff = off; off += EDGE_SIZE * edgeCount
472
+ anmOff = off; off += ANM_SIZE * anmCount
473
+ floorOff = off; off += FLOOR_SIZE * floorCount
474
+ normalOff = off; off += NORMAL_SIZE * normalCount
475
+ vertexOff = off; off += VERT_SIZE * vertexCount
476
+ # per-anm frame tables
477
+ frame_offsets = []
478
+ for a in self.anms:
479
+ frame_offsets.append(off); off += 8 * len(a.frames)
480
+ # per-floor tri-index lists
481
+ floor_list_offsets = []
482
+ for fl in self.floors:
483
+ floor_list_offsets.append(off); off += 4 * len(fl.tri_ndx_list)
484
+ # per-frame tri-index lists
485
+ frame_list_offsets = []
486
+ for ai, a in enumerate(self.anms):
487
+ row = []
488
+ for (_f, _v, idxs) in a.frames:
489
+ row.append(off); off += 4 * len(idxs)
490
+ frame_list_offsets.append(row)
491
+ data_size = off
492
+
493
+ out = bytearray()
494
+ out += struct.pack("<IH", MAGIC, data_size & 0xFFFF)
495
+ for v in (self.orgPos, self.curPos, self.minPos, self.maxPos, self.charPos):
496
+ out += v.pack()
497
+ out += struct.pack("<hh", self.activeFloor, self.activeTri)
498
+ out += struct.pack("<HHHHHHHHHHHH",
499
+ triCount, triOff, edgeCount, edgeOff, anmCount, anmOff,
500
+ floorCount, floorOff, normalCount, normalOff, vertexCount, vertexOff)
501
+ assert len(out) == HEADER_SIZE, len(out)
502
+ # the writer seeks to absolute offsets; our sections are contiguous in the same order,
503
+ # so we can simply append. Pad to data_size+4 at the end.
504
+ for t in self.tris:
505
+ out += t.pack()
506
+ for e in self.edges:
507
+ out += e.pack()
508
+ for ai, a in enumerate(self.anms):
509
+ out += struct.pack("<HHhHiI", a.flags, len(a.frames), a.frame_rate, a.counter,
510
+ a.cur_frame, frame_offsets[ai])
511
+ for fi, fl in enumerate(self.floors):
512
+ out += fl.pack_struct(floor_list_offsets[fi])
513
+ for nrm in self.normals:
514
+ out += nrm.pack()
515
+ for v in self.verts:
516
+ out += v.pack()
517
+ # trailing variable-length tables, in UpdateOffsets order: anm frames, floor lists, frame lists
518
+ for ai, a in enumerate(self.anms):
519
+ for fj, (fflags, value, idxs) in enumerate(a.frames):
520
+ out += struct.pack("<HhHH", fflags, value, len(idxs), frame_list_offsets[ai][fj])
521
+ for fi, fl in enumerate(self.floors):
522
+ for idx in fl.tri_ndx_list:
523
+ out += struct.pack("<i", idx)
524
+ for ai, a in enumerate(self.anms):
525
+ for (fflags, value, idxs) in a.frames:
526
+ for idx in idxs:
527
+ out += struct.pack("<i", idx)
528
+ return bytes(out)
529
+
530
+ # ---------------- neighbor rebuild (bgi_fix_neighbors) ----------------
531
+ def rebuild_neighbors(self) -> None:
532
+ """Recompute all neighbor + edgeClone links from shared-vertex analysis."""
533
+ for t in self.tris:
534
+ t.nbr = [-1, -1, -1]
535
+ for e in self.edges:
536
+ e.clone = -1
537
+
538
+ def slot_of(tri: Tri, a: int, c: int):
539
+ s = {a, c}
540
+ for k, (i, j) in enumerate(SLOT_PAIRS):
541
+ if {tri.vtx[i], tri.vtx[j]} == s:
542
+ return k
543
+ return None
544
+
545
+ n = len(self.tris)
546
+ for ia in range(n):
547
+ for ib in range(ia + 1, n):
548
+ shared = set(self.tris[ia].vtx) & set(self.tris[ib].vtx)
549
+ if len(shared) != 2:
550
+ continue
551
+ a, c = tuple(shared)
552
+ sa, sb = slot_of(self.tris[ia], a, c), slot_of(self.tris[ib], a, c)
553
+ if sa is None or sb is None:
554
+ continue
555
+ self.tris[ia].nbr[sa] = ib
556
+ self.tris[ib].nbr[sb] = ia
557
+ self.edges[self.tris[ia].edge[sa]].clone = sb
558
+ self.edges[self.tris[ib].edge[sb]].clone = sa
559
+
560
+
561
+ # ----------------------------------------------------------------------------- builders
562
+
563
+ def _bbox(verts):
564
+ xs = [v[0] for v in verts]
565
+ ys = [v[1] for v in verts]
566
+ zs = [v[2] for v in verts]
567
+ return (min(xs), min(ys), min(zs)), (max(xs), max(ys), max(zs))
568
+
569
+
570
+ def _check_int16(verts):
571
+ for (x, y, z) in verts: # .bgi stores verts as Int16
572
+ for v in (x, y, z):
573
+ if not (-32768 <= round(v) <= 32767):
574
+ raise ValueError(
575
+ f"walkmesh vertex coordinate {v:.0f} exceeds the .bgi Int16 range +/-32767 "
576
+ f"-- the room/floor is too large in world units; scale it down (FF9 rooms are "
577
+ f"typically a few thousand units across).")
578
+
579
+
580
+ def build(verts, faces, *, floor_ids=None, tri_flags: int = 1, floor_flags: int = 0,
581
+ org=(0, 0, 0), header_min=None, header_max=None, char=None) -> BgiWalkmesh:
582
+ """Build a complete walkmesh from WORLD-space verts + triangle faces (the EXPORTER).
583
+
584
+ This is the inverse of :meth:`BgiWalkmesh.world_verts`: with ``org=(0,0,0)`` and every floor at
585
+ ``org=(0,0,0)`` the engine renders ``world = vert + 0 + 0``, so the verts you pass ARE the
586
+ in-game positions (see the frame note at the top of this module).
587
+
588
+ verts : iterable of (x, y, z) — FF9 WORLD coords (flat floor => y = 0)
589
+ faces : iterable of (i, j, k) — 0-based vertex indices (one triangle each)
590
+ floor_ids : optional per-FACE floor id (len == len(faces)). ``None`` => one floor with every
591
+ triangle (the flat case). Distinct ids => one BGI floor each — a multi-level room or
592
+ a faithful re-export of an imported real field (e.g. GRGR's 7 floors). Tris are
593
+ grouped by id; each floor sits at org=cur=(0,0,0) (verts already carry world height).
594
+
595
+ Computes triangle centers, the 3-edges-per-triangle table, and neighbor/edgeClone links via
596
+ shared-vertex analysis. header.minPos/maxPos default to the true world bounding box (informative;
597
+ the engine ignores them — see the frame note); charPos (debug spawn) defaults to the bbox centre.
598
+ """
599
+ verts = [(round(x), round(y), round(z)) for (x, y, z) in verts]
600
+ faces = [tuple(f) for f in faces]
601
+ _check_int16(verts)
602
+ if not verts or not faces:
603
+ raise ValueError("walkmesh has no geometry (need at least one vertex and one triangle) -- "
604
+ "check the .obj has both 'v' and 'f' lines.")
605
+ nv = len(verts)
606
+ for ti, f in enumerate(faces):
607
+ if len(f) != 3:
608
+ raise ValueError(f"walkmesh face {ti} has {len(f)} vertices, expected 3 (a triangle).")
609
+ for vi in f:
610
+ if not (0 <= vi < nv):
611
+ raise ValueError(f"walkmesh face {ti} references vertex index {vi}, out of range "
612
+ f"0..{nv - 1} -- a malformed or mis-edited .obj.")
613
+ if floor_ids is None:
614
+ floor_ids = [0] * len(faces)
615
+ if len(floor_ids) != len(faces):
616
+ raise ValueError(f"floor_ids has {len(floor_ids)} entries for {len(faces)} faces")
617
+
618
+ m = BgiWalkmesh()
619
+ bmin, bmax = _bbox(verts) if verts else ((0, 0, 0), (0, 0, 0))
620
+ hmin = tuple(header_min) if header_min is not None else bmin
621
+ hmax = tuple(header_max) if header_max is not None else bmax
622
+ hchar = tuple(char) if char is not None else ((bmin[0] + bmax[0]) // 2,
623
+ (bmin[1] + bmax[1]) // 2,
624
+ (bmin[2] + bmax[2]) // 2)
625
+ m.orgPos = Vec3(*org)
626
+ m.curPos = Vec3(*org)
627
+ m.minPos = Vec3(*hmin)
628
+ m.maxPos = Vec3(*hmax)
629
+ m.charPos = Vec3(*hchar)
630
+ m.verts = [Vec3(x, y, z) for (x, y, z) in verts]
631
+
632
+ # distinct floor ids in first-seen order -> contiguous BGI floor indices 0..N-1
633
+ order = []
634
+ for fid in floor_ids:
635
+ if fid not in order:
636
+ order.append(fid)
637
+ remap = {fid: i for i, fid in enumerate(order)}
638
+
639
+ for ti, (i, j, k) in enumerate(faces):
640
+ t = Tri(tri_flags=tri_flags, floor_ndx=remap[floor_ids[ti]], normal_ndx=-1)
641
+ t.vtx = [i, j, k]
642
+ t.edge = [ti * 3 + 0, ti * 3 + 1, ti * 3 + 2]
643
+ cx = (m.verts[i].x + m.verts[j].x + m.verts[k].x) / 3.0
644
+ cy = (m.verts[i].y + m.verts[j].y + m.verts[k].y) / 3.0
645
+ cz = (m.verts[i].z + m.verts[j].z + m.verts[k].z) / 3.0
646
+ t.center = Vec3(int(round(cx)), int(round(cy)), int(round(cz)))
647
+ m.tris.append(t)
648
+ m.edges += [Edge(0, -1), Edge(0, -1), Edge(0, -1)]
649
+
650
+ for fi, fid in enumerate(order): # floor.org=cur=min=max=(0,0,0): verts carry world
651
+ tris = [ti for ti, f in enumerate(floor_ids) if f == fid]
652
+ m.floors.append(Floor(flags=floor_flags, ndx=fi, tri_ndx_list=tris))
653
+ m.rebuild_neighbors()
654
+ return m
655
+
656
+
657
+ def build_flat(verts, faces, *, tri_flags: int = 1, floor_flags: int = 0,
658
+ header_vec=(0, 0, 300)) -> BgiWalkmesh:
659
+ """Legacy single-floor builder with a UNIFORM header vector (a thin wrapper over :func:`build`).
660
+
661
+ Equivalent to ``build(..., org=header_vec, header_min/max=header_vec, char=header_vec)`` with one
662
+ floor — kept so the proven HUT / auto-frame pipeline (calibrated against ``header_vec=(0,0,300)``)
663
+ stays byte-for-byte unchanged. NEW multi-floor / world-frame authoring calls :func:`build`
664
+ (``org`` defaults to 0, so what you author is exactly where the engine renders it).
665
+ """
666
+ return build(verts, faces, floor_ids=None, tri_flags=tri_flags, floor_flags=floor_flags,
667
+ org=header_vec, header_min=header_vec, header_max=header_vec, char=header_vec)
668
+
669
+
670
+ def quad(corners) -> BgiWalkmesh:
671
+ """Build a 2-triangle quad floor from 4 corners (each (x, z) or (x, y, z)).
672
+
673
+ Vertex order around the quad: v0, v1, v2, v3 (tri0 = v0v1v2, tri1 = v0v2v3, diagonal v0-v2)
674
+ — the convention the proven HUT walkmesh uses.
675
+ """
676
+ verts = []
677
+ for c in corners:
678
+ if len(c) == 2:
679
+ verts.append((c[0], 0, c[1]))
680
+ else:
681
+ verts.append(tuple(c))
682
+ if len(verts) != 4:
683
+ raise ValueError("quad() needs exactly 4 corners")
684
+ return build_flat(verts, [(0, 1, 2), (0, 2, 3)])
685
+
686
+
687
+ def load_obj_floors(path):
688
+ """Parse a Wavefront .obj into (verts, faces, floor_ids), one floor per ``o``/``g`` object.
689
+
690
+ Each ``o <name>`` (or ``g <name>``) starts a new floor; a repeated name reuses its floor; faces
691
+ before any object go to floor 0. Vertices are FF9 world coords (shared across floors — OBJ vertex
692
+ indices are file-global). Faces with >3 verts are fan-triangulated; refs may be ``a/b/c``.
693
+ """
694
+ verts, faces, floor_ids = [], [], []
695
+ names, cur, next_id = {}, 0, 0
696
+ with open(path, encoding="utf-8", errors="replace") as fh:
697
+ for line in fh:
698
+ s = line.split()
699
+ if not s:
700
+ continue
701
+ if s[0] == "v":
702
+ verts.append((float(s[1]), float(s[2]), float(s[3])))
703
+ elif s[0] in ("o", "g"):
704
+ name = s[1] if len(s) > 1 else ""
705
+ if name not in names:
706
+ names[name] = next_id
707
+ next_id += 1
708
+ cur = names[name]
709
+ elif s[0] == "f":
710
+ idx = [int(tok.split("/")[0]) - 1 for tok in s[1:]] # 1-based -> 0-based
711
+ for k in range(1, len(idx) - 1):
712
+ faces.append((idx[0], idx[k], idx[k + 1]))
713
+ floor_ids.append(cur)
714
+ return verts, faces, floor_ids
715
+
716
+
717
+ def load_obj(path):
718
+ """Parse a Wavefront .obj into (verts, faces) — floors merged. See :func:`load_obj_floors`."""
719
+ verts, faces, _ = load_obj_floors(path)
720
+ return verts, faces
721
+
722
+
723
+ def obj_to_bgi(path, **kwargs) -> bytes:
724
+ """Convert a Wavefront .obj walkmesh (FF9 world coords) to .bgi bytes.
725
+
726
+ Multiple ``o``/``g`` objects => a multi-floor world-frame walkmesh (:func:`build`, org=0); a
727
+ single object => the legacy flat builder (:func:`build_flat`), so existing output is unchanged.
728
+ """
729
+ verts, faces, floor_ids = load_obj_floors(path)
730
+ if len(set(floor_ids)) > 1:
731
+ return build(verts, faces, floor_ids=floor_ids).to_bytes()
732
+ return build_flat(verts, faces, **kwargs).to_bytes()