# Spectacles 522 To 515 Migration

> Battle-tested playbook for migrating a Lens Studio 5.22 project down to LS 5.15.4 for Spectacles (2024). Triggers on: migrate 5.22 to 5.15, downgrade lens studio project, 5.22 package errors in 5.15, SingleCompressionSettings error, PerformanceCompressionSettings error, Invalid graph header, !passList.empty(), duplicate PassInfo reimport loop, PassInfo transplant, materials white after clone, 5.22 lspkg crashes 5.15.

- Skill: `ptpavlotkachenko/spectacles-522-to-515-migration` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ptpavlotkachenko/spectacles-522-to-515-migration`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ptpavlotkachenko/spectacles-522-to-515-migration/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: PtPavloTkachenko (https://skillmd.com/u/ptpavlotkachenko)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/ptpavlotkachenko/spectacles-522-to-515-migration

---


# 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
  `.esproj` must 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_graph` and 5.22-built `.lspkg` are NOT portable — see below.
- Code portability contract: audit scripts against a known 5.15 `Support/StudioLib.d.ts`
  BEFORE 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-*.txt`
  ```bash
  LOG=$(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

```bash
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:**
1. Remove ALL 5.22-era packages: SIK 0.18+, SpectaclesUIKit, Utilities, SnapDecorators, LEAF.
2. Drop in 5.15-native versions — from the specs-devs `packages` repo (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 + `FontSize` YAML fails).
3. **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.
4. **Packages must be ZIP files, not directories.** An unpacked `.lspkg` dir in `Packages/`
   can crash LS on reimport. To patch a package: unpack → edit → repack:
   ```bash
   cd unpacked_pkg && zip -r ../MyPackage.lspkg .   # zip ROOT must contain Package/
   ```
5. 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.
6. 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 `.meta` files entirely.** `requireAsset(...)` is
  path-based, so scripts survive; 5.15 regenerates fresh metas.
  ```bash
  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.
  ```python
  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):**
```python
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` / `baseColor` properties 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}` |

```python
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:**
```typescript
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 `@input` that 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**: `createComponent` runs 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 an
  **`AudioListenerComponent`** — 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-515` MCP entry if driving via MCP — never share the
  5.22 server entry.

## Step 6 — Verify loop (acceptance = all three)

1. **"TypeScript compilation succeeded!"** in the log/Logger.
2. **Preview renders** the full flow.
3. **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:
```bash
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 `.esproj` in 5.15, or let both LS versions touch one project.
- ❌ Do NOT copy ANY 5.22-built `.lspkg` into 5.15 — binary-incompatible, corrupts the project.
- ❌ Do NOT leave an unpacked `.lspkg` DIRECTORY in `Packages/` — can crash LS. Repack as zip.
- ❌ Do NOT transplant a PassInfo keeping the donor's pass UUID — infinite reimport loop.
- ❌ Do NOT delete texture `.meta` files — 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 `colorMask` writes 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/generate` on 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_graph` binary 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 (`SetLensStudioProperty` on the .mat asset: `passInfos.0.<texParam>` valueType reference → texture asset id). Runtime `pass.tex = ...` on a clone does NOT resurrect a pass that compiled without its sampler.
- **SyncKit 1.3 preview:** `MULTIPLAYER` startMode needs My Lenses login (401 otherwise, LS-relogin + retry); the StartMenu **Solo/Single Player button calls `prepareOfflineMode()`** — 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 Quartz `CGEventCreateMouseEvent` move→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.

