@bitmagic/cli 0.1.61 → 0.1.62-dev.0

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 (60) hide show
  1. package/README.md +27 -3
  2. package/assets/unity-import/README.md +13 -0
  3. package/assets/unity-import/blender_build.py +462 -0
  4. package/assets/unity-import/build_parts.py +263 -0
  5. package/assets/unity-import/convert_assets.py +193 -0
  6. package/assets/unity-import/fbx_ascii.py +156 -0
  7. package/assets/unity-import/fbx_axis.py +174 -0
  8. package/assets/unity-import/fbx_bounds.py +202 -0
  9. package/assets/unity-import/import_config.py +86 -0
  10. package/assets/unity-import/level_patches.example.json +23 -0
  11. package/assets/unity-import/mesh_walls.py +78 -0
  12. package/assets/unity-import/render_topdown.py +33 -0
  13. package/assets/unity-import/unity-import.example.json +27 -0
  14. package/assets/unity-import/unity_assets.py +114 -0
  15. package/assets/unity-import/unity_colliders2d.py +127 -0
  16. package/assets/unity-import/unity_extract.py +774 -0
  17. package/assets/unity-import/unity_markers.py +249 -0
  18. package/assets/unity-import/unity_renderers.py +279 -0
  19. package/assets/unity-import/write_arena_data.py +198 -0
  20. package/dist/cli.d.ts +12 -0
  21. package/dist/commands/import.d.ts +4 -0
  22. package/dist/commands/import.js +5 -0
  23. package/dist/commands/import.js.map +1 -1
  24. package/dist/commands/init.d.ts +4 -0
  25. package/dist/commands/init.js +20 -0
  26. package/dist/commands/init.js.map +1 -1
  27. package/dist/commands/upgrade.d.ts +4 -0
  28. package/dist/commands/upgrade.js +31 -5
  29. package/dist/commands/upgrade.js.map +1 -1
  30. package/dist/editor/server.js +3 -3
  31. package/dist/editor/shell-page.js +16 -1
  32. package/dist/editor/shell-page.js.map +1 -1
  33. package/dist/project/context.d.ts +9 -0
  34. package/dist/project/context.js +4 -1
  35. package/dist/project/context.js.map +1 -1
  36. package/dist/scaffold/agent-targets.d.ts +126 -0
  37. package/dist/scaffold/agent-targets.js +180 -0
  38. package/dist/scaffold/agent-targets.js.map +1 -0
  39. package/dist/scaffold/agents-md.d.ts +4 -1
  40. package/dist/scaffold/agents-md.js +6 -2
  41. package/dist/scaffold/agents-md.js.map +1 -1
  42. package/dist/scaffold/project-files.d.ts +40 -5
  43. package/dist/scaffold/project-files.js +67 -25
  44. package/dist/scaffold/project-files.js.map +1 -1
  45. package/dist/scaffold/project.d.ts +42 -15
  46. package/dist/scaffold/project.js +100 -38
  47. package/dist/scaffold/project.js.map +1 -1
  48. package/dist/scaffold/reference-guidance.d.ts +1 -1
  49. package/dist/scaffold/reference-guidance.js +2 -1
  50. package/dist/scaffold/reference-guidance.js.map +1 -1
  51. package/dist/scaffold/skill-files.d.ts +32 -9
  52. package/dist/scaffold/skill-files.js +71 -15
  53. package/dist/scaffold/skill-files.js.map +1 -1
  54. package/dist/scaffold/unity-skill-files.d.ts +30 -0
  55. package/dist/scaffold/unity-skill-files.js +474 -0
  56. package/dist/scaffold/unity-skill-files.js.map +1 -0
  57. package/dist/scaffold/upgrade-project.d.ts +31 -11
  58. package/dist/scaffold/upgrade-project.js +35 -23
  59. package/dist/scaffold/upgrade-project.js.map +1 -1
  60. package/package.json +4 -4
@@ -0,0 +1,249 @@
1
+ """Pull the gameplay markers out of a Unity scene: spawners, bases, powerup spawn points.
2
+
3
+ Colliders come out of `unity_extract.py`; this handles the objects that carry no collider and are
4
+ identified by the MonoBehaviour script attached to them (`markerScripts` in unity-import.json —
5
+ `PowerupSpawnPoint`), plus placed prefab instances of `Base.prefab` and friends.
6
+
7
+ python3 tools/unity_markers.py "<unity-project>/Assets/Scenes/<Scene>.unity" # defaults to the config
8
+ """
9
+
10
+ import json
11
+ import math
12
+ import os
13
+ import re
14
+ import sys
15
+
16
+ DOC_RE = re.compile(r'\n--- !u!(\d+) &(\d+)(?: stripped)?\n')
17
+
18
+
19
+ def find_unity_root(start):
20
+ d = os.path.dirname(os.path.abspath(start))
21
+ while d != os.path.dirname(d):
22
+ if os.path.isdir(os.path.join(d, 'Assets')):
23
+ return d
24
+ d = os.path.dirname(d)
25
+ raise SystemExit('could not find the Unity project root above ' + start)
26
+
27
+
28
+ def guid_map(root):
29
+ out = {}
30
+ for base, _dirs, files in os.walk(os.path.join(root, 'Assets')):
31
+ for f in files:
32
+ if not f.endswith('.meta'):
33
+ continue
34
+ p = os.path.join(base, f)
35
+ try:
36
+ t = open(p, encoding='utf-8', errors='ignore').read(400)
37
+ except Exception:
38
+ continue
39
+ m = re.search(r'guid: ([0-9a-f]{32})', t)
40
+ if m:
41
+ out[m.group(1)] = p[:-5]
42
+ return out
43
+
44
+
45
+ def parse(path):
46
+ txt = open(path, encoding='utf-8', errors='ignore').read()
47
+ parts = DOC_RE.split(txt)
48
+ docs = {}
49
+ i = 1
50
+ while i + 2 <= len(parts) - 1:
51
+ docs[parts[i + 1]] = (parts[i], parts[i + 2])
52
+ i += 3
53
+ return docs
54
+
55
+
56
+ def fid(body, key):
57
+ m = re.search(re.escape(key) + r': \{fileID: (-?\d+)', body)
58
+ return m.group(1) if m else '0'
59
+
60
+
61
+ def vec3(body, key, default=(0.0, 0.0, 0.0)):
62
+ m = re.search(re.escape(key) + r': \{x: ([-\d.eE+]+), y: ([-\d.eE+]+), z: ([-\d.eE+]+)\}', body)
63
+ return tuple(float(v) for v in m.groups()) if m else default
64
+
65
+
66
+ def quat(body, key):
67
+ m = re.search(re.escape(key) + r': \{x: ([-\d.eE+]+), y: ([-\d.eE+]+), z: ([-\d.eE+]+), w: ([-\d.eE+]+)\}', body)
68
+ return tuple(float(v) for v in m.groups()) if m else (0.0, 0.0, 0.0, 1.0)
69
+
70
+
71
+ def qmul(a, b):
72
+ ax, ay, az, aw = a
73
+ bx, by, bz, bw = b
74
+ return (aw * bx + ax * bw + ay * bz - az * by,
75
+ aw * by - ax * bz + ay * bw + az * bx,
76
+ aw * bz + ax * by - ay * bx + az * bw,
77
+ aw * bw - ax * bx - ay * by - az * bz)
78
+
79
+
80
+ def qrot(q, v):
81
+ x, y, z, w = q
82
+ vx, vy, vz = v
83
+ tx = 2 * (y * vz - z * vy)
84
+ ty = 2 * (z * vx - x * vz)
85
+ tz = 2 * (x * vy - y * vx)
86
+ return (vx + w * tx + (y * tz - z * ty),
87
+ vy + w * ty + (z * tx - x * tz),
88
+ vz + w * tz + (x * ty - y * tx))
89
+
90
+
91
+ def world_of(docs, tid, mods):
92
+ """World transform of a scene transform, honouring prefab-instance overrides."""
93
+ chain = []
94
+ guard = 0
95
+ while tid and tid != '0' and tid in docs and guard < 64:
96
+ cls, body = docs[tid]
97
+ if cls == '1001':
98
+ # A prefab instance in the parent chain: fold in its root placement.
99
+ chain.append(('inst', body))
100
+ tid = fid(body, 'm_TransformParent')
101
+ elif cls == '4':
102
+ chain.append(('t', body))
103
+ tid = fid(body, 'm_Father')
104
+ else:
105
+ break
106
+ guard += 1
107
+
108
+ pos = (0.0, 0.0, 0.0)
109
+ rot = (0.0, 0.0, 0.0, 1.0)
110
+ scale = (1.0, 1.0, 1.0)
111
+ for kind, body in reversed(chain):
112
+ if kind == 't':
113
+ lp = vec3(body, 'm_LocalPosition')
114
+ lr = quat(body, 'm_LocalRotation')
115
+ ls = vec3(body, 'm_LocalScale', (1.0, 1.0, 1.0))
116
+ else:
117
+ lp, lr, ls = instance_trs(body)
118
+ sp = (lp[0] * scale[0], lp[1] * scale[1], lp[2] * scale[2])
119
+ rp = qrot(rot, sp)
120
+ pos = (pos[0] + rp[0], pos[1] + rp[1], pos[2] + rp[2])
121
+ rot = qmul(rot, lr)
122
+ scale = (scale[0] * ls[0], scale[1] * ls[1], scale[2] * ls[2])
123
+ _ = mods
124
+ return pos, rot, scale
125
+
126
+
127
+ def instance_trs(body):
128
+ """The root placement recorded in a PrefabInstance's modification list."""
129
+ p = [0.0, 0.0, 0.0]
130
+ r = [0.0, 0.0, 0.0, 1.0]
131
+ s = [1.0, 1.0, 1.0]
132
+ for m in re.finditer(r'propertyPath: m_Local(Position|Rotation|Scale)\.([xyzw])\s*\n\s*value: ([-\d.eE+]+)', body):
133
+ what, axis, val = m.group(1), m.group(2), float(m.group(3))
134
+ idx = 'xyzw'.index(axis)
135
+ if what == 'Position' and idx < 3:
136
+ p[idx] = val
137
+ elif what == 'Rotation':
138
+ r[idx] = val
139
+ elif what == 'Scale' and idx < 3:
140
+ s[idx] = val
141
+ return tuple(p), tuple(r), tuple(s)
142
+
143
+
144
+ def euler_z(q):
145
+ x, y, z, w = q
146
+ return math.degrees(math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)))
147
+
148
+
149
+ from import_config import CFG as _CFG, scene_path as scene_path_from_config
150
+ SCRIPTS = tuple(_CFG['markerScripts'])
151
+
152
+ # Markers are usually placed prefab instances rather than loose scene objects, so the interesting
153
+ # values arrive as property overrides on the instance rather than as fields on a scene component.
154
+ MARKER_PREFABS = tuple(_CFG['markerPrefabs'])
155
+ OVERRIDES = ('spawnerId', 'cameraZRotation', 'cameraOffset.x', 'cameraOffset.y', 'cameraOffset.z',
156
+ 'm_Name')
157
+
158
+
159
+ def prefab_markers(docs, guids):
160
+ out = []
161
+ for _iid, (cls, body) in docs.items():
162
+ if cls != '1001':
163
+ continue
164
+ m = re.search(r'm_SourcePrefab: \{fileID: \d+, guid: ([0-9a-f]{32})', body)
165
+ if not m:
166
+ continue
167
+ src = guids.get(m.group(1))
168
+ base = os.path.basename(src) if src else ''
169
+ if base not in MARKER_PREFABS:
170
+ continue
171
+
172
+ parent = fid(body, 'm_TransformParent')
173
+ ppos, prot, pscale = world_of(docs, parent, None) if parent != '0' else (
174
+ (0.0, 0.0, 0.0), (0.0, 0.0, 0.0, 1.0), (1.0, 1.0, 1.0))
175
+ lp, lr, _ls = instance_trs(body)
176
+ sp = (lp[0] * pscale[0], lp[1] * pscale[1], lp[2] * pscale[2])
177
+ rp = qrot(prot, sp)
178
+ pos = (ppos[0] + rp[0], ppos[1] + rp[1], ppos[2] + rp[2])
179
+ rot = qmul(prot, lr)
180
+
181
+ entry = dict(kind=base.replace('.prefab', ''),
182
+ pos=[round(v, 3) for v in pos],
183
+ zrot=round(euler_z(rot), 2))
184
+ for prop in OVERRIDES:
185
+ mm = re.search(r'propertyPath: ' + re.escape(prop) + r'\s*\n\s*value: (.*)', body)
186
+ if mm:
187
+ raw = mm.group(1).strip()
188
+ try:
189
+ entry[prop] = float(raw)
190
+ except ValueError:
191
+ entry[prop] = raw
192
+ out.append(entry)
193
+ return out
194
+
195
+
196
+ def main():
197
+ scene = sys.argv[1] if len(sys.argv) > 1 else scene_path_from_config()
198
+ root = find_unity_root(scene)
199
+ guids = guid_map(root)
200
+ want = {}
201
+ for guid, path in guids.items():
202
+ base = os.path.basename(path)
203
+ if base in SCRIPTS:
204
+ want[guid] = base[:-3]
205
+
206
+ docs = parse(scene)
207
+
208
+ # GameObject -> its Transform, so a MonoBehaviour can be located.
209
+ go_to_transform = {}
210
+ for tid, (cls, body) in docs.items():
211
+ if cls == '4':
212
+ go_to_transform[fid(body, 'm_GameObject')] = tid
213
+
214
+ markers = []
215
+ for _mid, (cls, body) in docs.items():
216
+ if cls != '114':
217
+ continue
218
+ m = re.search(r'm_Script: \{fileID: -?\d+, guid: ([0-9a-f]{32})', body)
219
+ if not m or m.group(1) not in want:
220
+ continue
221
+ kind = want[m.group(1)]
222
+ goid = fid(body, 'm_GameObject')
223
+ tid = go_to_transform.get(goid)
224
+ if not tid:
225
+ continue
226
+ pos, rot, _scale = world_of(docs, tid, None)
227
+ name = ''
228
+ gd = docs.get(goid)
229
+ if gd:
230
+ nm = re.search(r'm_Name: (.*)', gd[1])
231
+ if nm:
232
+ name = nm.group(1).strip()
233
+ entry = dict(kind=kind, name=name,
234
+ pos=[round(v, 3) for v in pos],
235
+ zrot=round(euler_z(rot), 2))
236
+ sid = re.search(r'\n spawnerId: (-?\d+)', body)
237
+ if sid:
238
+ entry['spawnerId'] = int(sid.group(1))
239
+ cz = re.search(r'\n cameraZRotation: ([-\d.eE+]+)', body)
240
+ if cz:
241
+ entry['cameraZRotation'] = float(cz.group(1))
242
+ markers.append(entry)
243
+
244
+ markers.extend(prefab_markers(docs, guids))
245
+ print(json.dumps(markers, indent=1))
246
+
247
+
248
+ if __name__ == '__main__':
249
+ main()
@@ -0,0 +1,279 @@
1
+ """Extract every MeshRenderer a Unity prefab or scene places, with its world transform.
2
+
3
+ `unity_extract.py` walks the same hierarchy to find colliders; this walks it to find
4
+ *visible* geometry, so the level can be rebuilt from the original models instead of
5
+ from boxes. It reuses that module's transform maths, which is already validated
6
+ against known collider sizes.
7
+
8
+ Output per renderer: the source FBX and which mesh inside it, the Unity materials the
9
+ renderer overrides with, and the composed world TRS.
10
+ """
11
+ import json
12
+ import os
13
+ import re
14
+ import sys
15
+
16
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
17
+
18
+ from import_config import CFG as _CFG
19
+ _UNITY = os.path.expanduser(_CFG['unityRoot'])
20
+ # unity_extract resolves its project root from argv[1]; point it at the active tree.
21
+ if len(sys.argv) < 2:
22
+ sys.argv = [sys.argv[0], os.path.join(_UNITY, 'Assets', 'Scenes')]
23
+
24
+ import unity_extract as ue # noqa: E402
25
+
26
+ MAX_DEPTH = 10
27
+ # the project's TagManager: a layer some games render only into a visibility or shadow mask.
28
+ SHADOW_LAYER = 18
29
+ _LOD_SUFFIX = re.compile(r'_(LOD)?[Ll]?[123]$')
30
+
31
+
32
+ def _is_lod_child(name):
33
+ """LODGroups list LOD0 first; the _L1/_L2 rungs are the same shape at lower detail."""
34
+ return bool(re.search(r'_L[123]$', name)) or bool(re.search(r'_LOD[123]$', name))
35
+
36
+
37
+ def _under_lodgroup(docs, tbody):
38
+ """Does this transform's parent GameObject carry a LODGroup (class 205)?
39
+
40
+ A name alone is not evidence of a LOD rung: some asset packs ship one FBX per detail level,
41
+ so a `..._L2` model placed by hand under a plain group is a whole object. The scene this was
42
+ built on had 16 of those — the four side-middle wall pieces and all twelve corner
43
+ staircases — and dropping them by name emptied the corners and the middle of every side.
44
+ """
45
+ f = ue.fid(tbody, 'm_Father')
46
+ pt = docs.get(f)
47
+ if not pt or pt[0] != '4':
48
+ return False
49
+ pg = docs.get(ue.fid(pt[1], 'm_GameObject'))
50
+ if not pg:
51
+ return False
52
+ for m in re.finditer(r'- component: \{fileID: (\d+)\}', pg[1]):
53
+ c = docs.get(m.group(1))
54
+ if c and c[0] == '205':
55
+ return True
56
+ return False
57
+
58
+
59
+ class RendererWalk:
60
+ def __init__(self):
61
+ self.out = []
62
+
63
+ def walk(self, path, docs, tid, world, depth, mods_for_file, tag):
64
+ if depth > MAX_DEPTH:
65
+ return
66
+ entry = docs.get(tid)
67
+ if not entry:
68
+ return
69
+ cls, body = entry
70
+ if cls == '1001':
71
+ self.prefab(path, docs, tid, body, world, depth, tag, mods_for_file)
72
+ return
73
+ if 'm_LocalPosition' not in body:
74
+ return # a stripped stub standing in for a nested prefab; the 1001 sweep places it
75
+
76
+ trs = ue.local_trs(body)
77
+ mod = mods_for_file.get(tid)
78
+ if mod:
79
+ trs = ue.apply_mods(trs, mod)
80
+ goid = ue.fid(body, 'm_GameObject')
81
+ name = ue.go_name(docs, goid)
82
+ if depth == 0 and path.endswith('.unity'):
83
+ dp = ue.patch_move_for(name, trs[0]) # level patch: slide a scene object
84
+ if any(dp):
85
+ trs = ((trs[0][0] + dp[0], trs[0][1] + dp[1], trs[0][2] + dp[2]), trs[1], trs[2])
86
+ w = ue.compose(world, trs)
87
+
88
+ if not ue.go_active(docs, goid, mods_for_file):
89
+ return
90
+ layer = ue.go_layer(docs, goid, mods_for_file)
91
+
92
+ gd = docs.get(goid)
93
+ if gd and not (_is_lod_child(name) and _under_lodgroup(docs, body)):
94
+ self.components(path, docs, gd[1], name, w, tag, layer, mods_for_file)
95
+
96
+ seg = ''
97
+ if 'm_Children:' in body and 'm_Father:' in body:
98
+ seg = body[body.find('m_Children:'):body.find('m_Father:')]
99
+ for m in re.finditer(r'- \{fileID: (\d+)\}', seg):
100
+ self.walk(path, docs, m.group(1), w, depth + 1, mods_for_file, tag)
101
+
102
+ def components(self, path, docs, gbody, name, world, tag, layer, mods=None):
103
+ """Pair the MeshFilter and MeshRenderer hanging off one GameObject."""
104
+ mesh = None
105
+ mats = None
106
+ enabled = True
107
+ casts = True
108
+ for m in re.finditer(r'- component: \{fileID: (\d+)\}', gbody):
109
+ cd = docs.get(m.group(1))
110
+ if not cd:
111
+ continue
112
+ ccls, cbody = cd
113
+ if ccls == '33': # MeshFilter
114
+ mm = re.search(r'm_Mesh: \{fileID: (-?\d+), guid: ([0-9a-f]{32})', cbody)
115
+ if mm:
116
+ mesh = (mm.group(2), mm.group(1))
117
+ elif ccls == '23': # MeshRenderer
118
+ if not ue.comp_enabled(cbody, m.group(1), mods):
119
+ enabled = False
120
+ cs = re.search(r'm_CastShadows: (\d)', cbody)
121
+ if cs and cs.group(1) == '0':
122
+ casts = False
123
+ seg = cbody[cbody.find('m_Materials:'):]
124
+ seg = seg[:seg.find('\n m_', 1)] if '\n m_' in seg[1:] else seg
125
+ mats = re.findall(r'guid: ([0-9a-f]{32})', seg)
126
+ if not mesh or not enabled:
127
+ return
128
+ mesh_path = ue.guid2path.get(mesh[0])
129
+ if not mesh_path or not os.path.exists(mesh_path):
130
+ return
131
+ if not mesh_path.lower().endswith(('.fbx', '.obj')):
132
+ return # a built-in primitive; the collider pass already covers those
133
+
134
+ (p, q, s) = world
135
+ self.out.append({
136
+ 'name': name,
137
+ 'tag': tag,
138
+ 'layer': layer,
139
+ 'fbx': mesh_path,
140
+ 'meshFileId': mesh[1],
141
+ 'materials': [ue.guid2path.get(g) for g in (mats or []) if ue.guid2path.get(g)],
142
+ 'castShadow': casts,
143
+ 'pos': [round(v, 5) for v in p],
144
+ 'quat': [round(v, 6) for v in q],
145
+ 'scale': [round(v, 5) for v in s],
146
+ })
147
+
148
+ def prefab(self, path, docs, tid, body, world, depth, tag, outer_mods=None):
149
+ m = re.search(r'm_SourcePrefab: \{fileID: \d+, guid: ([0-9a-f]{32})', body)
150
+ if not m:
151
+ return
152
+ src = ue.guid2path.get(m.group(1))
153
+ if not src or not os.path.exists(src):
154
+ return
155
+ pdocs = ue.parse_file(src)
156
+ if not pdocs:
157
+ return
158
+ modmap = ue.instance_mods(body, docs, tid, pdocs, outer_mods)
159
+ proot = next((pid for pid, (c, b) in pdocs.items() if c == '4' and ue.fid(b, 'm_Father') == '0'), None)
160
+ if proot is not None and not ue.go_active(pdocs, ue.fid(pdocs[proot][1], 'm_GameObject'), modmap):
161
+ return # the instance switches the whole prefab off (a scene-level m_IsActive override)
162
+
163
+ newtag = tag or os.path.basename(src).replace('.prefab', '')
164
+ if depth == 0:
165
+ # scene-level instance: honour level patches (tools/level_patches.json), the same way
166
+ # unity_colliders2d does, so what is drawn and what is hit stay identical.
167
+ root = next((pid for pid, (c, b) in pdocs.items() if c == '4' and ue.fid(b, 'm_Father') == '0'), None)
168
+ if root is not None:
169
+ rp = ue.apply_mods(ue.local_trs(pdocs[root][1]), modmap.get(root, {}))
170
+ if ue.patch_skips_instance(newtag, ue.compose(world, rp)[0]):
171
+ return
172
+ self.expand(src, pdocs, world, depth + 1, modmap, newtag)
173
+
174
+ def expand(self, path, docs, world, depth, mods, tag):
175
+ """Everything one file places: its root transforms, then its own nested prefab instances.
176
+
177
+ The second half is not optional. A nested instance appears in its parent's m_Children only
178
+ as a stripped stub the transform walk cannot follow, so a prefab built out of other prefabs
179
+ — which most of a kit-built level is — yields only the handful of objects stored directly in its
180
+ own file unless the 1001 records are swept as well. Sweeping only the top-level file, as
181
+ this used to, meant one placed prefab kept 16 of its 75 pieces and another
182
+ arrived empty.
183
+ """
184
+ if depth > MAX_DEPTH:
185
+ return
186
+ for tid, (cls, body) in docs.items():
187
+ if cls == '4' and ue.fid(body, 'm_Father') == '0' and 'm_LocalPosition' in body:
188
+ self.walk(path, docs, tid, world, depth, mods, tag)
189
+
190
+ for tid, (cls, body) in docs.items():
191
+ if cls != '1001':
192
+ continue
193
+ chain = []
194
+ cur = ue.fid(body, 'm_TransformParent')
195
+ guard = 0
196
+ while cur != '0' and cur in docs and guard < 40:
197
+ ccls, cbody = docs[cur]
198
+ if ccls != '4' or 'm_LocalPosition' not in cbody:
199
+ break
200
+ chain.append((cur, cbody))
201
+ cur = ue.fid(cbody, 'm_Father')
202
+ guard += 1
203
+ w = world
204
+ for cid, cbody in reversed(chain):
205
+ trs = ue.local_trs(cbody)
206
+ mod = mods.get(cid)
207
+ if mod:
208
+ trs = ue.apply_mods(trs, mod)
209
+ w = ue.compose(w, trs)
210
+ self.prefab(path, docs, tid, body, w, depth, tag, mods)
211
+
212
+
213
+ IDENT = ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0, 1.0), (1.0, 1.0, 1.0))
214
+
215
+
216
+ def _add_patched_instances(rw, docs):
217
+ """Extra placements a level patch adds, expanded from the scene's own copy of the prefab."""
218
+ for tid, (cls, body) in docs.items():
219
+ if cls != '1001': continue
220
+ m = re.search(r'm_SourcePrefab: \{fileID: \d+, guid: ([0-9a-f]{32})', body)
221
+ src = ue.guid2path.get(m.group(1)) if m else None
222
+ if not src: continue
223
+ name = os.path.basename(src).replace('.prefab', '')
224
+ adds = ue.patch_added_instances(name)
225
+ if not adds: continue
226
+ pdocs = ue.parse_file(src)
227
+ root = next((pid for pid, (c, b) in pdocs.items() if c == '4' and ue.fid(b, 'm_Father') == '0'), None)
228
+ if root is None: continue
229
+ for (pos, quat) in adds:
230
+ modmap = {root: {'m_LocalPosition.x': pos[0], 'm_LocalPosition.y': pos[1], 'm_LocalPosition.z': pos[2],
231
+ 'm_LocalRotation.x': quat[0], 'm_LocalRotation.y': quat[1], 'm_LocalRotation.z': quat[2], 'm_LocalRotation.w': quat[3]}}
232
+ rw.expand(src, pdocs, ((0.0, 0.0, 0.75), (0.0, 0.0, 0.0, 1.0), (1.0, 1.0, 1.0)), 1, modmap, name)
233
+ break
234
+
235
+
236
+ def extract(path):
237
+ """Every renderer a scene or prefab places, in that file's own space."""
238
+ docs = ue.parse_file(path)
239
+ rw = RendererWalk()
240
+ rw.expand(path, docs, IDENT, 0, {}, None)
241
+ if path.endswith('.unity'):
242
+ _add_patched_instances(rw, docs)
243
+ return _dedupe(rw.out)
244
+
245
+
246
+ def _rank(item):
247
+ """How much a copy deserves to be the one kept. Higher wins.
248
+
249
+ Coincident copies arise two ways, and they are not the same thing:
250
+
251
+ * Unity keeps a second copy of most level geometry on the SHADOWS layer — a stand-in the
252
+ original rendered only into its line-of-sight mask, never to the screen. It sits at exactly
253
+ the same transform as the piece you can see, so it must never win: dropping SHADOWS after
254
+ deduping deletes the visible piece along with it, which is what left whole structures as
255
+ scattered fragments.
256
+ * An object inside a nested prefab is also seen through the outer file, which is where Unity
257
+ stores the override putting it on a gameplay layer. That copy is the authoritative one.
258
+ """
259
+ if item['layer'] == SHADOW_LAYER:
260
+ return 0
261
+ return 2 if item['layer'] != 0 else 1
262
+
263
+
264
+ def _dedupe(items):
265
+ """One renderer reached twice is still one renderer."""
266
+ best = {}
267
+ order = []
268
+ for it in items:
269
+ key = (it['fbx'], it['meshFileId'], tuple(it['pos']), tuple(it['quat']), tuple(it['scale']))
270
+ if key not in best:
271
+ best[key] = it
272
+ order.append(key)
273
+ elif _rank(it) > _rank(best[key]):
274
+ best[key] = it
275
+ return [best[k] for k in order]
276
+
277
+
278
+ if __name__ == '__main__':
279
+ print(json.dumps(extract(sys.argv[1] if len(sys.argv) > 1 else ue.scene_path_from_config()), indent=1))