LS 5.22 → 5.15.4 Migration Playbook (Spectacles 2024)
Sister skill:
spectacles-522-portable-design— how to ARCHITECT a 5.22 project from day one so this migration takes an hour instead of a day (runtime-first scene, no graph shaders, material state set on clones in code). If the project followed those rules, half of this playbook is unnecessary (our second project: 19 scripts compiled in 5.15 unchanged).
Proven live on a real migration: 32 scripts, 9 materials, GLB/OBJ/WAV/PNG assets, SIK 0.17.2 — from first open to "TypeScript compilation succeeded" + full device flow rendering.
Ground rules (read before touching anything)
- Work on a COPY. Never open the same project in 5.15 and 5.22. A 5.22-saved
.esprojmust never be reopened in 5.15 — copy files into a fresh tree instead. - GLB / OBJ / WAV / PNG / TTF are version-agnostic — copy verbatim.
- 5.22-rewritten
.ss_graphand 5.22-built.lspkgare NOT portable — see below. - Code portability contract: audit scripts against a known 5.15
Support/StudioLib.d.tsBEFORE migrating (SIK 0.17.2 surface,InternetModule,requireAsset, etc. are all fine). - The iteration loop: open in 5.15 → read errors → fix files ON DISK → LS watcher
auto-reimports → repeat. LS log:
~/Library/Preferences/Snap/Lens Studio/logs/LensStudioLog-*.txtLOG=$(ls -t ~/Library/Preferences/Snap/Lens\ Studio/logs/LensStudioLog-*.txt | head -1) grep -E "error TS|compilation succeeded|duplicate of the loaded id|Invalid graph header|CompressionSettings" "$LOG" | tail -30
Step 0 — Copy the project
SRC="path/to/project-522"; DST="path/to/project-515"
rsync -a "$SRC/" "$DST/" \
--exclude Cache --exclude Workspaces --exclude .claude --exclude .codex \
--exclude .mcp.json --exclude PluginsUserPreferences
mv "$DST"/*.esproj "$DST/project-515.esproj" # rename so you never confuse the two
Also DELETE from the copy: AiPreviewAgentInspect, LEAF, and any 5.22-only tooling — 5.15
has zero support for them.
Step 1 — Packages swap (do this BEFORE first open)
SYMPTOM: EntityRegistry::create: Couldn't find Entity creation function for type PerformanceCompressionSettings / SizeCompressionSettings / DracoCompressionSettings,
YAML PARSE FAIL ... FontSize — flood of asset load errors, project partly corrupts.
CAUSE: 5.22-built .lspkg packages are binary-incompatible with the 5.15 engine.
No exceptions. This includes packages that "look" version-neutral (Utilities, SnapDecorators).
FIX:
- Remove ALL 5.22-era packages: SIK 0.18+, SpectaclesUIKit, Utilities, SnapDecorators, LEAF.
- Drop in 5.15-native versions — from the specs-devs
packagesrepo (SIK 0.17.2) or from a proven 5.15 donor project. ⚠️ Verify the donor project's packages are still 5.15-built — we once had a donor whose Utilities/SnapDecorators got silently updated to 5.22 builds (loaded with warnings +FontSizeYAML fails). - Delete nested Examples folders inside SIK (
SpectaclesInteractionKitExamples) — they drag in UIKit deps you may not want. If any package script imports UIKit (e.g.ForceHover), add the 5.15-native SpectaclesUIKit too rather than surgery. - Packages must be ZIP files, not directories. An unpacked
.lspkgdir inPackages/can crash LS on reimport. To patch a package: unpack → edit → repack:cd unpacked_pkg && zip -r ../MyPackage.lspkg . # zip ROOT must contain Package/ - A package repacked FROM a 5.22 project may still fail to import in 5.15 — prefer the 5.15-native original + minimal patch (e.g. recolor only) over porting your 5.22-patched copy.
- Check import paths after the swap (
NativeLogger, SIK component paths) on first compile.
Step 2 — Asset metas
"SingleCompressionSettings Uid 0000..." / CompressionSettings errors on GLB/OBJ
5.22 metas carry CompressionSettings: !<PerformanceCompressionSettings> (or Single/Size/Draco
variants) blocks that the 5.15 importer cannot parse.
- Model metas (GLB/OBJ): DELETE the
.metafiles entirely.requireAsset(...)is path-based, so scripts survive; 5.15 regenerates fresh metas.find "$DST/Assets" \( -name "*.glb.meta" -o -name "*.obj.meta" -o -name "*.mtl.meta" \) -delete - Texture metas (PNG/JPG): strip ONLY the CompressionSettings block, PRESERVE the file
— materials reference texture UUIDs stored in these metas; deleting them breaks wiring.
import re, glob for p in glob.glob("Assets/**/*.png.meta", recursive=True) + \ glob.glob("Assets/**/*.jpg.meta", recursive=True): s = open(p).read() s2 = re.sub(r"\n CompressionSettings: !<\w*CompressionSettings>\n( .*\n?)*", "\n", s) if s2 != s: open(p, "w").write(s2)
"Invalid graph header Expected 975438, got 1701339987" (.ss_graph)
Any .ss_graph that 5.22 has REWRITTEN (hand-edited in its Material Editor, or re-saved)
has an alien binary header for 5.15. Do NOT try to fix the binary.
FIX: rebuild from GLSL source with ss_graph_tool.py build <src.glsl> Assets/Shaders
under a NEW name (avoids stale-import collisions), with LS closed. Keep GLSL sources
stashed in the repo (Assets/Shaders/src/) exactly for this. Delete 5.22-authored graphs
that have no source. BUT see the rendering caveat in Step 3.4 — verify tool-built graphs
actually RENDER in 5.15 before betting materials on them.
Step 3 — Materials (the shader war)
3.1 SYMPTOM: InternalError: !passList.empty() at runtime on mainPass access
5.22 preset-created materials arrive in 5.15 PASSLESS — the .mat imports but has no
usable pass, so the first material.mainPass touch throws.
THE FIX: PassInfo transplant. Take a donor .mat that provably renders in 5.15, copy its FULL content into the broken material's file, keeping the target's original Material UUID (scene wiring survives) and giving the pass a FRESH uuid4.
3.2 SYMPTOM: infinite reimport loop — "contains a duplicate of the loaded id ... Duplicate type is 'PassInfo'"
You transplanted the donor pass WITH the donor's PassInfo UUID. Duplicate PassInfo ids across files send LS into an endless reimport loop. Every transplanted pass needs its own uuid4.
Transplant script (final recipe, proven on 9 materials):
import re, uuid
DONOR = "Assets/Image.mat" # native 5.15 ImageMaterialPreset material — see 3.3
def transplant(target_path):
donor = open(DONOR).read()
target = open(target_path).read()
# 1) keep the TARGET's Material UUID (scene references stay intact)
mat_uuid = re.search(r"!<Material/([0-9a-f-]{36})>", target).group(1)
out = re.sub(r"(!<Material/)[0-9a-f-]{36}(>)", rf"\g<1>{mat_uuid}\g<2>", donor)
# 2) FRESH uuid4 for the transplanted PassInfo (duplicate ids = infinite reimport loop)
new_pass = str(uuid.uuid4())
donor_pass = re.search(r"- !<own> ([0-9a-f-]{36})", out).group(1)
out = out.replace(f"- !<own> {donor_pass}", f"- !<own> {new_pass}")
out = out.replace(f"!<PassInfo/{donor_pass}>", f"!<PassInfo/{new_pass}>")
open(target_path, "w").write(out)
for m in ["Assets/GlowGreen.mat", "Assets/GlowPink.mat", "Assets/NeonSolidGreen.mat"]:
transplant(m)
LS watcher reimports live — no restart needed.
3.3 Choosing the donor
Best donor = a native 5.15 ImageMaterialPreset material (create one in 5.15:
- → Material → Image). Its pass has
baseTex/baseColorproperties with the EXACT names runtime code writes (mat.mainPass.baseColor = ...). A donor without those names → runtime crash on property read + textures fall off (solid squares).
3.4 Tool-built ss_graph shaders: verify RENDERING, not import
ss_graph_tool-built graphs may import cleanly in 5.15 (icons, no errors) and still
NOT RENDER — meshes invisible while native Text keeps drawing (Text uses its own
material; it surviving proves the scene works and YOUR materials don't). "Imports fine"
means nothing; the acceptance test is pixels on screen. When in doubt, the ImageMaterialPreset
transplant is the proven path; assign per-material behavior at runtime on clones.
3.5 Donor pass FLAGS ride along — bake per-material flags INTO the .mat
The transplant copies the donor's BlendMode / DepthWrite / DepthTest / ColorMask.
The Image donor ships DepthTest: false → direct-use materials render through everything.
Bake correct flags into each transplanted file:
| Material kind | BlendMode | DepthWrite | DepthTest | ColorMask |
|---|---|---|---|---|
| Additive glow/neon | Add |
false |
true (occlusion breaks otherwise!) |
all true |
| Opaque (metal/dark) | Disabled |
true |
true |
all true |
| Depth-only occluder | Disabled |
true |
true |
{x: false, y: false, z: false, w: false} |
def bake_flags(path, blend, dw, dt, colormask_off=False):
s = open(path).read()
s = re.sub(r"BlendMode: \w+", f"BlendMode: {blend}", s)
s = re.sub(r"DepthWrite: \w+", f"DepthWrite: {str(dw).lower()}", s)
s = re.sub(r"DepthTest: \w+", f"DepthTest: {str(dt).lower()}", s)
if colormask_off:
s = re.sub(r"ColorMask: \{[^}]*\}",
"ColorMask: {x: false, y: false, z: false, w: false}", s)
open(path, "w").write(s)
bake_flags("Assets/GlowGreen.mat", "Add", False, True)
bake_flags("Assets/MetalUnlit.mat", "Disabled", True, True)
bake_flags("Assets/Occluder.mat", "Disabled", True, True, colormask_off=True)
⚠️ Runtime pass.colorMask = ... writes DO NOT work in 5.15 — occluders MUST have
ColorMask baked in the file. Also bake default palette colors into baseColor values so
direct (non-cloned) uses look right immediately.
3.6 5.15 RUNTIME GOTCHA: Material.clone() resets graph property VALUES
Clones come back with graph DEFAULTS (direct asset uses keep Inspector values; clones go
white). After every clone(), set colors/uniforms explicitly in code:
const m = base.clone();
m.mainPass.blendMode = BlendMode.Add; // preset-independent, set on clone
m.mainPass.depthWrite = false;
m.mainPass.baseColor = new vec4(0.35, 1, 0.5, 1); // ALWAYS — clone reset it
Step 4 — Scripts
- Any
@inputthat may be unwired in the new scene →@allowUndefined+ null-guards (e.g. optional shader material inputs with a code fallback). - Guard reads of material properties that may not exist on a given pass
(
if (pass.baseColor !== undefined)) — a wrong donor/manual material otherwise crashes the whole script on first property read. - Runtime-created components with required @inputs:
createComponentruns the lifecycle INLINE — the input-check throws before you can set inputs. Create the component on a DISABLED SceneObject, set inputs, then enable the object. - Code-built scenes: camera needs
Device Tracking(World), Far ≈ 100000, and anAudioListenerComponent— without it audio "plays" (isPlaying=true) into silence.
Step 5 — Scene + editor settings (user clicks, MCP can't)
- Preview Device → Spectacles (2024).
- Project Settings → Experimental APIs ✅ (needed for
ws://insecure WebSocket). - Logger: Clear → Window → Utilities → TypeScript Status (green = clean).
- Register a separate
lens-studio-515MCP entry if driving via MCP — never share the 5.22 server entry.
Step 6 — Verify loop (acceptance = all three)
- "TypeScript compilation succeeded!" in the log/Logger.
- Preview renders the full flow.
- Every material actually DRAWS — walk the scene visually; invisible meshes with a working Text overlay = shader/material failure, not scene failure (Step 3.4).
Grep loop while iterating:
grep -E "error TS|compilation succeeded|duplicate of the loaded id|Invalid graph header" "$LOG" | tail -20
DO NOT (crashers & dead ends)
- ❌ Do NOT open a 5.22-saved
.esprojin 5.15, or let both LS versions touch one project. - ❌ Do NOT copy ANY 5.22-built
.lspkginto 5.15 — binary-incompatible, corrupts the project. - ❌ Do NOT leave an unpacked
.lspkgDIRECTORY inPackages/— can crash LS. Repack as zip. - ❌ Do NOT transplant a PassInfo keeping the donor's pass UUID — infinite reimport loop.
- ❌ Do NOT delete texture
.metafiles — materials reference their UUIDs. Strip only the CompressionSettings block. (Model metas: deleting IS the fix.) - ❌ Do NOT trust "imports without errors" for shaders/materials — verify rendering.
- ❌ Do NOT rely on runtime
colorMaskwrites for occluders in 5.15 — bake into the .mat. - ❌ Do NOT expect clone() to keep material values in 5.15 — set them in code every time.
- ❌ Do NOT run
ss_graph_tool build/generateon a graph while LS has the project open (in-memory copy overwrites your file);update(body-only) is the only LS-open-safe op. - ❌ Do NOT bring LEAF / AiPreviewAgent / any CLAD 5.22 tooling — no 5.15 support, hard gate.
- ❌ Do NOT hand-edit the
.ss_graphbinary header — rebuild from GLSL source, new name.
Addenda — Stage Zone migration (2026-07-14)
- ss_graph_tool .mat: NEVER hand-patch the internal Material UUID (
!<Material/...>) to preserve scene refs — it desyncs .mat↔.meta, LS reimports the material FRESH (new id) and drops all CachedProperties/Tweaks (params reset to 1s, textures to null). Let the tool's UUIDs stand; rewire scene refs instead. - A graph material with a NULL texture param does not render AT ALL in 5.15 (whole pass silently fails → invisible mesh; no log). Texture-less graphs (same tool, same build) render fine. Fix: bind the texture at editor level (
SetLensStudioPropertyon the .mat asset:passInfos.0.<texParam>valueType reference → texture asset id). Runtimepass.tex = ...on a clone does NOT resurrect a pass that compiled without its sampler. - SyncKit 1.3 preview:
MULTIPLAYERstartMode needs My Lenses login (401 otherwise, LS-relogin + retry); the StartMenu Solo/Single Player button callsprepareOfflineMode()— mock session, zero login. For agent smoke tests: keep START_MENU and auto-click Solo. - Auto-click the 5.15 preview from the agent: activate the LS app first (
NSRunningApplication.runningApplicationWithProcessIdentifier_(pid).activateWithOptions_(1<<1)), then QuartzCGEventCreateMouseEventmove→down→up at screen coords (window bounds from CGWindowList + retina /2). Without activation the click is ignored. - 5.15 granular MCP scene assembly works end-to-end: CreateLensStudioSceneObject → CreateLensStudioComponent(ScriptComponent) → SetLensStudioProperty scriptAsset (valueType reference, TypeScriptAsset id) attaches @component TS scripts fine; script @inputs settable by name.
- "Positions look broken" red herring: if only small sub-parts of a mesh render (e.g. logo plates), the layout LOOKS scattered/detached from wires. Verify with temporary
print()diagnostics (NativeLogger may be filtered) before touching layout code. - Air Hockey / Basic Example samples (15.4 repo) = proven donor package set for 5.15.4: SIK 0.17.2 (5.15-built) + SyncKit 1.3 + UIKit 0.1.4 + LSTween + Utilities; SyncKit 1.3 is 5.21-built but ships in 5.15.4 samples and loads clean. SyncKit 1.x has the SAME prefab skeleton as 2.x (ColocatedWorld [CONFIGURE_ME]/EnableOnReady) and the same SyncEntity/StorageProperty API surface.
- Basic Example scene carries a world offset on the SyncKit subtree (~+40cm z) — stage-local math is self-consistent (inverse×forward cancels), don't "fix" it.