# PrimoEngine complete creator spec (single file, for LLMs) PrimoEngine is a native macOS 26+ animated-wallpaper app. Users make `.livewallpaper` files (ZIP: manifest.json + content/) and import them with the + button in the app's Installed tab, or share them peer-to-peer. This file contains the whole format spec, contract, rules, errors and working examples. Human docs: https://engine.oprimo.dev/docs/ ## Generation checklist (follow exactly when producing a wallpaper) - Output `manifest.json` + the payload file(s) under `content/`. Omit `checksum`; the user runs `python3 pack.py ` (https://engine.oprimo.dev/docs/tools/pack.py) which adds it and zips. - `schemaVersion` is the number 1; `version` is a string; `type` is "metal" | "web" | "video"; `entry` starts with `content/`. - Metal: include the exact `Uniforms` struct and the exact `v_main`; fragment is `f_main` reading `constant Uniforms& u [[buffer(0)]]`. Only `speed` and `tint` config keys reach the shader. Never write `kernel `, `device `, `threadgroup `, `atomic_`, `#include "` (even inside names). MSL, not GLSL. fragCoord origin is top-left. - Web: single offline page, all assets bundled under the entry's folder; no network, no mouse. Read settings from `window.LiveWallpaper.config`, update in `window.LiveWallpaper.onConfig`. - config types: float, int (delivered as a decimal; round it), bool, color ("#RRGGBB"). No enum. - Video wallpapers require Premium; metal and web are free. --- ## Make your own wallpaper: practical guide A PrimoEngine wallpaper is a **`.livewallpaper`** file: a ZIP holding a `manifest.json` and a `content/` folder. There are three types: | Type | What it is | Plan | |---|---|---| | `metal` | A Metal (MSL) fragment shader. Lightest on battery. | Free | | `web` | An HTML/Canvas/WebGL page running sandboxed and offline. | Free | | `video` | A looping `.mp4`/`.mov`/`.m4v`. | Premium | You need: a Mac with PrimoEngine, a text editor, and Python 3 (ships with the macOS Command Line Tools; check with `python3 --version` in Terminal). Download `pack.py`: it validates your wallpaper, computes the checksum and writes the file. Every field and error is in the Reference. --- ## 1. Your first shader in 10 minutes **1.** Create this layout: ``` hello-shader/ manifest.json content/ shader.metal ``` **2.** `hello-shader/manifest.json`: ```json { "schemaVersion": 1, "id": "com.yourname.hello-shader", "version": "1.0.0", "title": "Hello Shader", "author": { "handle": "@yourname" }, "type": "metal", "entry": "content/shader.metal", "minMacOS": "26.0", "config": [ { "key": "speed", "type": "float", "min": 0.1, "max": 3.0, "default": 1.0, "label": "Speed" }, { "key": "tint", "type": "color", "default": "#FFFFFF", "label": "Tint" } ], "capabilities": { "network": [] } } ``` **3.** `hello-shader/content/shader.metal`: ```metal #include using namespace metal; // Contract with PrimoEngine: keep this struct exactly as-is (32 bytes, buffer 0). struct Uniforms { float2 resolution; // drawable size in pixels float time; // seconds since the wallpaper started float speed; // config key "speed" (default 1.0) float4 tint; // config key "tint" as RGB 0..1 (a = 1) }; struct VOut { float4 position [[position]]; }; // Required: full-screen triangle vertex stage, named exactly v_main. vertex VOut v_main(uint vid [[vertex_id]]) { float2 p = float2((vid << 1) & 2, vid & 2); VOut o; o.position = float4(p * 2.0 - 1.0, 0.0, 1.0); return o; } // Required: fragment stage, named exactly f_main. fragment float4 f_main(float4 fragCoord [[position]], constant Uniforms& u [[buffer(0)]]) { float2 uv = fragCoord.xy / u.resolution; // 0..1, origin top-left float t = u.time * u.speed; float v = sin(uv.x * 8.0 + t) + sin(uv.y * 6.0 - t * 0.7) + sin(length(uv - 0.5) * 14.0 - t * 1.3); float3 col = 0.5 + 0.5 * cos(6.28318 * (v * 0.2 + float3(0.0, 0.33, 0.67))); return float4(col * u.tint.rgb, 1.0); } ``` **4.** Pack it: ```bash python3 pack.py hello-shader ``` You get `hello-shader.livewallpaper`. **5.** In PrimoEngine, open **Installed → + (Import)** and pick the file. It goes straight onto your desktop. **Coming from Shadertoy?** `iResolution.xy` → `u.resolution`, `iTime` → `u.time`, `fragCoord` is the same (but **y grows downward**; flip with `uv.y = 1.0 - uv.y` if needed), `vec2/vec3` → `float2/float3`, `mod` → `fmod`. There is no `iMouse` and no textures (`iChannel`). **Shader rules** (import is refused if broken): - Must contain `f_main`, and must contain `v_main` (without it the shader renders black). - Forbidden: `kernel`, `device` buffers, `threadgroup` memory, `atomic_*`, `#include "file"`. The check is textual and case-sensitive: avoid those words followed by a space even inside identifiers (`my_kernel `, `device `). Comments are ignored. - Only `speed` and `tint` reach the shader. Other settings show in the panel but do nothing. --- ## 2. A web wallpaper (Canvas / WebGL) Layout: ``` hello-web/ manifest.json content/ web/ index.html ← plus any assets (js, css, png, jpg, webp, svg, woff2, wasm, mp4…) ``` `hello-web/manifest.json`: ```json { "schemaVersion": 1, "id": "com.yourname.hello-web", "version": "1.0.0", "title": "Hello Web", "author": { "handle": "@yourname" }, "type": "web", "entry": "content/web/index.html", "minMacOS": "26.0", "config": [ { "key": "count", "type": "int", "min": 20, "max": 400, "default": 150, "label": "Particles" }, { "key": "speed", "type": "float", "min": 0.1, "max": 3.0, "default": 1.0, "label": "Speed" }, { "key": "trails", "type": "bool", "default": true, "label": "Trails" }, { "key": "color", "type": "color", "default": "#7FD1FF", "label": "Color" } ], "capabilities": { "network": [] } } ``` `hello-web/content/web/index.html`: ```html ``` Pack with `python3 pack.py hello-web` and import. **How the page runs (sandbox):** - **No internet.** Every network request is blocked. Bundle everything (scripts, fonts, images, Three.js, …) inside `content/web/`. No CDNs. - Files are served from `lwp://wallpaper/…`, rooted at the `entry`'s folder. Use relative paths (`./img/photo.jpg`); nothing outside that folder is reachable. - No `file://`, no navigating away, no camera/microphone/location. - `localStorage`/cookies don't persist between runs. - The wallpaper sits behind the desktop icons and gets no clicks or mouse input, so don't rely on interaction. - Develop by opening `index.html` in **Safari** (same engine, WebKit). Use `requestAnimationFrame`: the app pauses the page whenever the desktop isn't visible. --- ## 3. A video wallpaper (Premium) Easiest: **Installed → + (Import)** and pick an `.mp4`, `.mov` or `.m4v` directly. The app wraps it into a package for you (the title becomes the file name). To control title/author, package it yourself: ``` my-video/ manifest.json content/ video.mp4 ``` ```json { "schemaVersion": 1, "id": "com.yourname.waves", "version": "1.0.0", "title": "Waves", "author": { "handle": "@yourname" }, "type": "video", "entry": "content/video.mp4", "minMacOS": "26.0", "capabilities": { "network": [] } } ``` Tips: HEVC (H.265) saves battery; match the last frame to the first for an invisible loop; video plays **muted** and fills the screen (edges are cropped if the aspect ratio differs). Limit: **256 MB** per file. Video has no settings (`config` is ignored). --- ## 4. User settings (`config`) Each `config` entry becomes a control under **Customize…**: | `type` | Control | Value delivered | Fields | |---|---|---|---| | `float` | slider | number | `min` (default 0), `max` (default 1), `default` | | `int` | slider | number **with decimals** (round it yourself) | same as `float` | | `bool` | toggle | `true`/`false` | `default` | | `color` | color picker | string `"#RRGGBB"` | `default` | All take `key` (required, unique) and `label` (display text; defaults to `key`). `enum` is **not** supported yet; the entry is ignored. - **Web:** everything arrives in `window.LiveWallpaper.config` (a `{ key: value }` object) and `window.LiveWallpaper.onConfig(cfg)` is called on every change. - **Metal:** only `speed` (float) and `tint` (color) reach the shader, as `u.speed` and `u.tint`. --- ## 5. Packaging and the checksum `pack.py` does it all: validates, computes the `checksum`, writes the ZIP. **Re-run it whenever you change anything in `content/`**. If the checksum doesn't match, the app refuses the package. Doing it by hand? The v1 algorithm: 1. List every file under `content/`, path relative to `content/` (e.g. `web/index.html`), with `/`. 2. Sort by UTF-8 bytes, ascending. 3. For each, feed a SHA-256 with: the UTF-8 path, one `0x00` byte, the file's bytes. 4. `"checksum": "sha256-" + lowercase hex`. Then zip **`manifest.json` and `content/` at the root** (no wrapping folder is best): ```bash cd hello-web && zip -r -X ../hello-web.livewallpaper manifest.json content -x '*.DS_Store' ``` Avoid Finder's "Compress": it can add `__MACOSX/` and `.DS_Store`, which break the import or the checksum. An optional `thumbnail.png` at the ZIP root is used as the in-app thumbnail. --- ## 6. Import and share - **Import:** **Installed → + (Import)**. Accepts `.livewallpaper`, `.zip` and videos. Importing a package with the same `id` replaces the installed one. That's how you ship an update (bump `version`). - **Share:** in an imported wallpaper's menu, **Share…** writes a `.livewallpaper`. Send it by AirDrop, chat, email, GitHub… The recipient imports it with the same + button. - There's no server and no moderation: every file, wherever it came from, goes through the same checks and runs in the same sandbox. Only wallpapers **you imported** can be shared; catalog ones can't. Something failed? See the error table. --- ## Reference: the `.livewallpaper` v1 format Format version: **1** (frozen). Verified against PrimoEngine **0.4.11**, macOS 26. ## File layout A ZIP (*store* or *deflate* compression) containing: ``` manifest.json required, at the root (or inside ONE single top-level folder) content/ required, the wallpaper payload thumbnail.png optional, thumbnail shown in the app (not covered by the checksum) ``` Other root files are ignored. The extension may be `.livewallpaper` or `.zip`. ## `manifest.json` | Field | Type | Req. | Notes | |---|---|---|---| | `schemaVersion` | number | yes | Always `1`. | | `id` | string | yes | Non-empty. Stable identity; importing another package with the same `id` replaces the installed one. Use reverse-DNS (`com.yourname.name`) or a UUID. | | `version` | string | yes | e.g. `"1.0.0"`. Must be a string (not a number). | | `title` | string | yes | Non-empty. Shown in the app. | | `author` | object | no | `{ "id": string?, "handle": string? }`. | | `type` | string | yes | `"metal"`, `"web"` or `"video"`. | | `entry` | string | yes | Path of the main file **from the package root**, e.g. `content/shader.metal`. Must exist. | | `minMacOS` | string | no | e.g. `"26.0"`. On an older Mac the import fails. | | `checksum` | string | recommended | `sha256-…` over `content/` (algorithm below). If present it must match, on import and on every load. | | `config` | array | no | User settings (table below). | | `capabilities` | object | no | Table below. | Unknown fields are ignored. A `.metal` needs `type: "metal"`, an `.html` `type: "web"`, a video `type: "video"`. A mismatched type doesn't fail the import, but the wallpaper renders black. ### `config[]` | Field | Type | Notes | |---|---|---| | `key` | string | Required. Name of the value delivered to the wallpaper. | | `type` | string | `float`, `int`, `bool`, `color`. Anything else (including `enum`) is ignored. | | `label` | string | Control label. Default: `key`. | | `min`, `max` | number | `float`/`int`. Defaults 0 and 1. | | `default` | number / bool / string | `float`/`int`: number (default = `min`); `bool`: `true`/`false` (default `false`); `color`: `"#RRGGBB"` (default `"#FFFFFF"`). | `int` is shown as a continuous slider and delivered as a decimal number: round it in your code. ### `capabilities` | Field | Type | Notes | |---|---|---| | `network` | array of strings | Declare `[]`. **Currently all network access is blocked for web wallpapers**, even hosts listed here. Bundle everything. | | `audio` | bool | Reserved. Declare it honestly. Video always plays muted. | | `nowPlaying` | bool | Web only. When `true` the page receives the track playing in Music/Spotify (see below). | ## Checksum algorithm (normative) 1. Every regular file under `content/`, path relative to `content/`, using `/`. 2. Sort by UTF-8 bytes, ascending. 3. For each file, feed a SHA-256 with: `UTF-8 path` + one `0x00` byte + `file bytes`. 4. Result: `"sha256-" + lowercase hex`. ```python import hashlib, os def checksum(content_dir): rels = sorted((os.path.relpath(os.path.join(r, f), content_dir).replace(os.sep, "/") for r, _, fs in os.walk(content_dir) for f in fs if f != ".DS_Store"), key=lambda p: p.encode()) h = hashlib.sha256() for rel in rels: h.update(rel.encode()); h.update(b"\0") h.update(open(os.path.join(content_dir, rel), "rb").read()) return "sha256-" + h.hexdigest() ``` Careful: if the ZIP contains a `.DS_Store` inside `content/`, the app **includes** it in the hash. Don't ship one. ## Metal contract - A single `.metal` file of MSL source, compiled on the user's Mac. - Required functions: **`vertex … v_main`** and **`fragment float4 f_main`**. - Uniforms at fragment `buffer(0)`, using exactly this struct (32 bytes): ```metal struct Uniforms { float2 resolution; // drawable pixels (Retina included) float time; // seconds since start float speed; // config "speed" (float), default 1.0 float4 tint; // config "tint" (color) as RGB 0..1, a = 1, default white }; ``` - There is no `frame`, `mouse`, texture, or any other `config` value in the shader. - The vertex stage draws 3 vertices (a full-screen triangle); use the guide's `v_main`. - `fragCoord.xy` is in pixels, origin **top-left**. - Output is `bgra8Unorm`, 8-bit, opaque. - Frame rate follows the display (ProMotion); the app throttles or pauses for battery/visibility. **Forbidden** (textual check, case-sensitive, comments ignored): | Text | Reason | |---|---| | `kernel ` / `[[kernel` | compute kernels are not allowed | | `device ` | writable `device` buffers are not allowed | | `threadgroup ` | threadgroup memory is not allowed | | `atomic_` | atomic operations are not allowed | | `#include "` | local #include is not allowed (`#include ` is fine) | ## Web environment - `entry` must be an `.html` file. The `entry`'s folder becomes the root served at `lwp://wallpaper/`; only files inside it (and subfolders) are reachable. - Served with the right MIME type: html, js/mjs, css, json, png, jpg, gif, webp, svg, mp4/m4v, wasm, woff/woff2, ttf. Anything else is `application/octet-stream`. - Blocked: all network, `file://`, navigating away, camera/microphone/location. - Storage is ephemeral. No mouse/keyboard input. - `console.error`/`console.warn` and uncaught errors go to the app log (see Debugging). - On import the app logs non-blocking warnings if it finds `fetch(`, `XMLHttpRequest`, `WebSocket`, `EventSource`, `importScripts`, `http://`, `https://`, `eval(`, `new Function(`, `atob(`. Page API: ```js window.LiveWallpaper.config // { key: value }, ready before your scripts run window.LiveWallpaper.onConfig = cfg => { … } // called when the user changes settings // Only with "capabilities": { "nowPlaying": true } window.LiveWallpaper.onNowPlaying = np => { … } // np = { isPlaying, title, artist, album, position, duration, source, artwork? } // position/duration in seconds; artwork = data URI, sent only once per track; keep it. ``` ## Video - `.mp4`, `.mov` or `.m4v`, decoded by macOS (HEVC/H.264). Gapless loop, muted, fills the screen (edges cropped). No `config`. - Importing a bare video builds the package automatically (`id` derived from the content; re-importing the same video replaces it instead of duplicating). ## Limits | Limit | Value | |---|---| | Size per file (uncompressed) | 256 MB | | Files per package | 4096 | | Paths | no `..`, no leading `/` | ## Plans | | Free | Premium | |---|---|---| | Import/share `metal` and `web` wallpapers | ✅ | ✅ | | `video` wallpapers | ❌ | ✅ | ## Errors Exact messages as shown in the "Could not import wallpaper" alert, and how to fix each. The two "The data couldn't be read…" messages come from macOS and may appear in your system language. | Message | Cause / fix | |---|---| | `Not a valid .livewallpaper (ZIP) file.` | Not a ZIP (or truncated). Rebuild with `pack.py`. | | `Corrupt archive: unsafe path '…'.` | A path contains `..` or starts with `/`. | | `Corrupt archive: unsupported compression method N.` | Use *store* or *deflate* (the default for `zip` and `pack.py`). | | `Corrupt archive: …` (other) | Damaged ZIP. Rebuild it. | | `Archive or an entry exceeds the size limit.` | A file > 256 MB, or more than 4096 files. | | `Package has no manifest.json.` | `manifest.json` isn't at the ZIP root. Zip the folder's *contents*, not a folder with Finder junk (`__MACOSX`). | | `The data couldn't be read because it is missing.` | A required field is missing: `schemaVersion`, `id`, `version`, `title`, `type`, `entry` (or `key`/`type` in a `config` entry). | | `The data couldn't be read because it isn't in the correct format.` | Invalid JSON or wrong type: trailing comma, `"schemaVersion": "1"`, `"version": 1`, `type` not `metal`/`web`/`video`, `min` as a string… | | `Unsupported manifest schemaVersion N (expected 1).` | Use `"schemaVersion": 1`. | | `Manifest field 'id' is missing or empty.` (or `title`, `entry`) | Fill in the field. | | `Wallpaper requires macOS X, which is newer than this system.` | Lower `minMacOS` (e.g. `"26.0"`) or remove it. | | `Package checksum does not match its content (possibly corrupt or tampered).` | You changed `content/` after computing the checksum, or the ZIP has a `.DS_Store`. Re-run `pack.py`. | | `Package entry file '…' is missing.` | `entry` doesn't point to an existing file. It must include `content/` and match case. | | `Shader rejected: … .` | A forbidden construct (Metal table above). Remove/rename it. | | `Shader is missing the required 'f_main' fragment function.` | Name the fragment function `f_main`. | | `“.ext” isn't a supported video (use .mp4, .mov or .m4v).` | Convert the video. | | `That video is N MB — the limit is 256 MB.` | Compress (HEVC) or shorten it. | **Silent failures** (imports fine, but black/static): - Metal fails to compile, or `v_main` is missing → black. Change a little at a time. - `type` doesn't match the file → black. - Web: a JavaScript error, an asset outside the `entry`'s folder, or anything fetched from the internet (blocked). ## Debugging Watch the app log in Terminal while the wallpaper runs: ```bash log stream --level info --predicate 'subsystem == "com.livewallpaper.app"' ``` Look for `Shader compile/pipeline failed` (Metal compile error, with line number) and messages in the `WebConsole` category (JavaScript errors).