diff --git a/scripts/brand-assets/textures.html b/scripts/brand-assets/textures.html
new file mode 100644
index 0000000..ad95061
--- /dev/null
+++ b/scripts/brand-assets/textures.html
@@ -0,0 +1,121 @@
+
+
+
+
+
+ tera — surface library
+
+
+
+
+
+
diff --git a/scripts/brand-assets/textures.mjs b/scripts/brand-assets/textures.mjs
new file mode 100644
index 0000000..472dbed
--- /dev/null
+++ b/scripts/brand-assets/textures.mjs
@@ -0,0 +1,269 @@
+/**
+ * Photograph the surface library.
+ *
+ * node scripts/brand-assets/textures.mjs
+ *
+ * `/simulate/assets` on lumbridgecorp.com is a page whose whole argument is that
+ * the art in this repository is code — eight texture kinds drawn on a 2D canvas
+ * from seeded value noise, thirty-five surface roles that pair one of those with
+ * a colour and a roughness, and no binary art anywhere, enforced by a CI job.
+ * It made that argument in about four thousand pixels of prose with nothing on
+ * it to look at, which is a strange way to talk about the appearance of things.
+ *
+ * So this writes the library out: one tile per texture kind, drawn by
+ * `TextureBin`, multiplied by the colour of the first role that carries it, and
+ * one neutral tile beside it so a reader can see for themselves that the map has
+ * no hue of its own. The role table goes with them as data, so the site can set
+ * it in its own type rather than baking labels into an image.
+ *
+ * ### Why this one runs against Vite dev and the others run against `dist/`
+ *
+ * `shots.mjs` and `films.mjs` photograph the *application*, and the application
+ * a visitor gets is the built bundle, so that is what they point a camera at.
+ * This photographs a *module*. `TextureBin` is not reachable from the built
+ * bundle — it exposes no names — and making it reachable would mean a third Vite
+ * entry, which would ship a texture-sheet page to tera.lumbridgecorp.com so that
+ * a script could take a screenshot of it. Vite dev serves and transforms the
+ * source on request, so the page imports the same files a reader opens on the
+ * repository. That is a shorter chain of custody than the bundle, not a longer
+ * one, and the manifest records the commit either way.
+ *
+ * ### It needs no GPU
+ *
+ * Nothing here draws with WebGL. The textures are Canvas2D and the encoder is
+ * the browser's own WebP, so this runs in a couple of seconds on the software
+ * path and there is nothing to check a renderer string for. Chrome is still the
+ * right tool: `toDataURL("image/webp")` is the encoder, exactly as in
+ * `shots.mjs`, because there is no `sharp` on this box.
+ *
+ * ### Flags
+ *
+ * --site where lumbridge-v4 is (default ../../lumbridge-v4)
+ * --manifest-only rewrite the generated TS without drawing anything
+ */
+
+import { chromium } from "playwright";
+import { createServer } from "vite";
+import { mkdir, writeFile } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import { execFileSync } from "node:child_process";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const ROOT = join(HERE, "..", "..");
+
+const args = process.argv.slice(2);
+function flag(name, fallback = null) {
+ const i = args.indexOf(name);
+ return i === -1 ? fallback : args[i + 1];
+}
+const SITE = flag("--site", join(ROOT, "..", "lumbridge-v4"));
+const MANIFEST_ONLY = args.includes("--manifest-only");
+
+const PUBLIC_DIR = join(SITE, "apps", "web", "public", "textures");
+const DATA_DIR = join(SITE, "apps", "web", "src", "data");
+
+/**
+ * A sentence per texture kind, written here rather than on the website.
+ *
+ * Same rule the shot captions follow: the description lives next to the thing
+ * that produces it, so a texture that is rewritten cannot leave a paragraph
+ * behind on a marketing page describing the grain it used to have. The website
+ * renders these; it does not author them.
+ *
+ * Every claim below is checkable against `DRAW` in `src/assets/textures.ts`.
+ */
+const NOTES = {
+ carpetLoop:
+ "Two octaves of value noise for the pile, then a loop pattern punched over it on a half-offset grid — the same trick a real loop carpet uses to hide its seams.",
+ woodPlank:
+ "Planks of a random width, each with its own grain running the length of it, and a hairline gap where two meet.",
+ polishedConcrete:
+ "Broad, slow noise for the pour, a fine speckle for the aggregate, and a few pale trowel sweeps that stop it reading as a gradient.",
+ ceilingTile:
+ "A mineral-fibre face: dense fine noise, pinholes punched through it, and a chamfer drawn around the edge of the tile.",
+ plasterPaint:
+ "Almost nothing, on purpose. Roller texture at the threshold of visibility is what keeps a painted wall from reading as a flat fill under raking light.",
+ fabricWeave:
+ "A warp and a weft drawn as alternating lines, which is enough structure to catch a highlight without becoming a pattern anybody notices.",
+ tileGrid:
+ "A hard grid of grout lines over lightly varied tiles, so no two squares in a floor are quite the same value.",
+ whiteboard:
+ "A near-white gloss with a faint vertical wipe and a few ghosted horizontal strokes — a board that has been used and cleaned, not a board out of a box.",
+};
+
+function sha() {
+ try {
+ return execFileSync("git", ["rev-parse", "--short", "HEAD"], { cwd: ROOT })
+ .toString()
+ .trim();
+ } catch {
+ return "unknown";
+ }
+}
+
+function dirty() {
+ try {
+ return (
+ execFileSync("git", ["status", "--porcelain"], { cwd: ROOT }).toString().trim() !== ""
+ );
+ } catch {
+ return true;
+ }
+}
+
+async function draw() {
+ // `server.middlewareMode` is off: the page is fetched over HTTP like any
+ // other, so Vite's HTML transform runs and the inline module script is
+ // rewritten with resolved import URLs.
+ const server = await createServer({
+ root: ROOT,
+ logLevel: "warn",
+ server: { port: 5313, strictPort: true },
+ });
+ await server.listen();
+
+ const browser = await chromium.launch({ channel: "chrome" });
+ try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(String(error)));
+ await page.goto("http://localhost:5313/scripts/brand-assets/textures.html", {
+ waitUntil: "networkidle",
+ });
+ // The tiles are drawn during module evaluation, so this is the whole wait.
+ // `waitForFunction`, not `waitForSelector`: the page paints nothing, so its
+ // body has no height, and Playwright's visibility rule counts a zero-height
+ // element as hidden however present it is.
+ await page.waitForFunction(() => document.body.dataset.ready === "1", null, {
+ timeout: 30000,
+ });
+ if (errors.length) throw new Error(`page errors: ${errors.join("; ")}`);
+
+ const library = await page.evaluate(() => window.__library());
+ if (!library.textures.length) throw new Error("no textures came back");
+ return library;
+ } finally {
+ await browser.close();
+ await server.close();
+ }
+}
+
+function manifest(library) {
+ const rows = library.textures
+ .map(
+ (t) => ` {
+ kind: ${JSON.stringify(t.kind)},
+ src: ${JSON.stringify(`/textures/${t.kind}.webp`)},
+ neutralSrc: ${JSON.stringify(`/textures/${t.kind}-neutral.webp`)},
+ size: ${t.size},
+ leadRole: ${JSON.stringify(t.leadRole)},
+ leadColor: ${JSON.stringify(t.leadColor)},
+ carriedBy: ${JSON.stringify(t.carriedBy)},
+ note: ${JSON.stringify(NOTES[t.kind] ?? "")},
+ },`,
+ )
+ .join("\n");
+
+ const surfaces = library.surfaces
+ .map(
+ (s) =>
+ ` { role: ${JSON.stringify(s.role)}, color: ${JSON.stringify(s.color)}, texture: ${JSON.stringify(s.texture)}, roughness: ${s.roughness}, metalness: ${s.metalness} },`,
+ )
+ .join("\n");
+
+ return `/**
+ * Generated. Do not edit — \`scripts/brand-assets/textures.mjs\` in the tera repo
+ * rewrites this file wholesale, and the descriptions below live next to the code
+ * that draws each texture so the two cannot drift apart.
+ *
+ * Every tile named here was drawn by tera's own \`TextureBin\` at the commit
+ * recorded below, and every row in \`SURFACES\` is what \`MaterialRegistry\` hands
+ * the office for that role. Nothing is an illustration and nothing is retouched.
+ *
+ * To change a description: edit \`NOTES\` in that script and run it with
+ * \`--manifest-only\`, which redraws nothing.
+ */
+
+/** A union rather than \`string\`, so a page naming a texture that is gone fails typecheck. */
+export type TextureKind =
+${library.textures.map((t) => ` | ${JSON.stringify(t.kind)}`).join("\n")};
+
+export interface TextureTile {
+ kind: TextureKind;
+ /** The map multiplied by \`leadColor\` — what the office actually renders. */
+ src: string;
+ /** The map alone. Near-white by construction: the hue arrives from the palette. */
+ neutralSrc: string;
+ /** Pixels per side, which is also metres-per-repeat divided into resolution. */
+ size: number;
+ /** The first role in palette order that carries this texture. */
+ leadRole: string;
+ leadColor: string;
+ /** Every role that carries it. */
+ carriedBy: string[];
+ note: string;
+}
+
+/** One row per surface role, as \`MaterialRegistry\` builds it at \`high\` quality. */
+export interface Surface {
+ role: string;
+ color: string;
+ texture: string | null;
+ roughness: number | null;
+ metalness: number | null;
+}
+
+/** The tera commit these were drawn from. */
+export const TEXTURES_COMMIT = ${JSON.stringify(sha())};
+/** True if that commit is not the whole story — the tree had uncommitted work. */
+export const TEXTURES_DIRTY = ${dirty()};
+
+export const TEXTURES: TextureTile[] = [
+${rows}
+];
+
+export const SURFACES: Surface[] = [
+${surfaces}
+];
+
+/** How many metres one repeat covers. From \`TEXTURE_TILE_METRES\` in tera. */
+export const TEXTURE_TILE_METRES = 2;
+`;
+}
+
+const dataUrl = (value) => Buffer.from(value.split(",")[1], "base64");
+
+async function main() {
+ if (MANIFEST_ONLY) {
+ // Redrawing is the only way to know the role table, so a manifest-only run
+ // still needs the page — it just does not write any images.
+ const library = await draw();
+ await writeFile(join(DATA_DIR, "textures.generated.ts"), manifest(library));
+ console.log(`manifest only → ${join(DATA_DIR, "textures.generated.ts")}`);
+ return;
+ }
+
+ const library = await draw();
+ await mkdir(PUBLIC_DIR, { recursive: true });
+
+ let bytes = 0;
+ for (const tile of library.textures) {
+ const tinted = dataUrl(tile.tinted);
+ const neutral = dataUrl(tile.neutral);
+ await writeFile(join(PUBLIC_DIR, `${tile.kind}.webp`), tinted);
+ await writeFile(join(PUBLIC_DIR, `${tile.kind}-neutral.webp`), neutral);
+ bytes += tinted.length + neutral.length;
+ console.log(
+ `${tile.kind.padEnd(18)} ${tile.size}px ${tile.leadColor} ${tile.carriedBy.length} role(s) ${Math.round((tinted.length + neutral.length) / 1024)} kB`,
+ );
+ }
+
+ await writeFile(join(DATA_DIR, "textures.generated.ts"), manifest(library));
+ console.log(
+ `\n${library.textures.length} textures, ${library.surfaces.length} surface roles, ${Math.round(bytes / 1024)} kB total → ${SITE}`,
+ );
+ if (dirty()) console.log("warning: tera's tree is dirty; the manifest names a dirty sha");
+}
+
+await main();