memoscan
← all memos

Memo 0x9a97f0e0…a5d8fd on Ethereum

/* KYS engine — generated by build_bundle.js. Do not edit. */ (function(root){ 'use strict'; /* keccak256 — minimal, dependency-free, browser + node. * Replaces js-sha3 (20.8KB) for the one function we need. * Must agree byte-for-byte with Solidity's keccak256. */ (function (root) { 'use strict'; var RC = [ 0x00000001,0x00000000, 0x00008082,0x00000000, 0x0000808a,0x80000000, 0x80008000,0x80000000, 0x0000808b,0x00000000, 0x80000001,0x00000000, 0x80008081,0x80000000, 0x00008009,0x80000000, 0x0000008a,0x00000000, 0x00000088,0x00000000, 0x80008009,0x00000000, 0x8000000a,0x00000000, 0x8000808b,0x00000000, 0x0000008b,0x80000000, 0x00008089,0x80000000, 0x00008003,0x80000000, 0x00008002,0x80000000, 0x00000080,0x80000000, 0x0000800a,0x00000000, 0x8000000a,0x80000000, 0x80008081,0x80000000, 0x00008080,0x80000000, 0x80000001,0x00000000, 0x80008008,0x80000000 ]; function keccakf(s) { var n, i, j, c0,c1,c2,c3,c4,c5,c6,c7,c8,c9, h, l; var b = new Array(50); for (n = 0; n < 24; n++) { c0 = s[0]^s[10]^s[20]^s[30]^s[40]; c1 = s[1]^s[11]^s[21]^s[31]^s[41]; c2 = s[2]^s[12]^s[22]^s[32]^s[42]; c3 = s[3]^s[13]^s[23]^s[33]^s[43]; c4 = s[4]^s[14]^s[24]^s[34]^s[44]; c5 = s[5]^s[15]^s[25]^s[35]^s[45]; c6 = s[6]^s[16]^s[26]^s[36]^s[46]; c7 = s[7]^s[17]^s[27]^s[37]^s[47]; c8 = s[8]^s[18]^s[28]^s[38]^s[48]; c9 = s[9]^s[19]^s[29]^s[39]^s[49]; h = c8 ^ ((c2 << 1) | (c3 >>> 31)); l = c9 ^ ((c3 << 1) | (c2 >>> 31)); for (i = 0; i < 50; i += 10) { s[i] ^= h; s[i+1] ^= l; } h = c0 ^ ((c4 << 1) | (c5 >>> 31)); l = c1 ^ ((c5 << 1) | (c4 >>> 31)); for (i = 0; i < 50; i += 10) { s[i+2] ^= h; s[i+3] ^= l; } h = c2 ^ ((c6 << 1) | (c7 >>> 31)); l = c3 ^ ((c7 << 1) | (c6 >>> 31)); for (i = 0; i < 50; i += 10) { s[i+4] ^= h; s[i+5] ^= l; } h = c4 ^ ((c8 << 1) | (c9 >>> 31)); l = c5 ^ ((c9 << 1) | (c8 >>> 31)); for (i = 0; i < 50; i += 10) { s[i+6] ^= h; s[i+7] ^= l; } h = c6 ^ ((c0 << 1) | (c1 >>> 31)); l = c7 ^ ((c1 << 1) | (c0 >>> 31)); for (i = 0; i < 50; i += 10) { s[i+8] ^= h; s[i+9] ^= l; } b[0]=s[0]; b[1]=s[1]; b[32]=(s[11]<<4)|(s[10]>>>28); b[33]=(s[10]<<4)|(s[11]>>>28); b[14]=(s[20]<<3)|(s[21]>>>29); b[15]=(s[21]<<3)|(s[20]>>>29); b[46]=(s[31]<<9)|(s[30]>>>23); b[47]=(s[30]<<9)|(s[31]>>>23); b[28]=(s[40]<<18)|(s[41]>>>14); b[29]=(s[41]<<18)|(s[40]>>>14); b[20]=(s[2]<<1)|(s[3]>>>31); b[21]=(s[3]<<1)|(s[2]>>>31); b[2]=(s[13]<<12)|(s[12]>>>20); b[3]=(s[12]<<12)|(s[13]>>>20); b[34]=(s[22]<<10)|(s[23]>>>22); b[35]=(s[23]<<10)|(s[22]>>>22); b[16]=(s[33]<<13)|(s[32]>>>19); b[17]=(s[32]<<13)|(s[33]>>>19); b[48]=(s[42]<<2)|(s[43]>>>30); b[49]=(s[43]<<2)|(s[42]>>>30); b[40]=(s[5]<<30)|(s[4]>>>2); b[41]=(s[4]<<30)|(s[5]>>>2); b[22]=(s[14]<<6)|(s[15]>>>26); b[23]=(s[15]<<6)|(s[14]>>>26); b[4]=(s[25]<<11)|(s[24]>>>21); b[5]=(s[24]<<11)|(s[25]>>>21); b[36]=(s[34]<<15)|(s[35]>>>17); b[37]=(s[35]<<15)|(s[34]>>>17); b[18]=(s[45]<<29)|(s[44]>>>3); b[19]=(s[44]<<29)|(s[45]>>>3); b[10]=(s[6]<<28)|(s[7]>>>4); b[11]=(s[7]<<28)|(s[6]>>>4); b[42]=(s[17]<<23)|(s[16]>>>9); b[43]=(s[16]<<23)|(s[17]>>>9); b[24]=(s[26]<<25)|(s[27]>>>7); b[25]=(s[27]<<25)|(s[26]>>>7); b[6]=(s[36]<<21)|(s[37]>>>11); b[7]=(s[37]<<21)|(s[36]>>>11); b[38]=(s[47]<<24)|(s[46]>>>8); b[39]=(s[46]<<24)|(s[47]>>>8); b[30]=(s[8]<<27)|(s[9]>>>5); b[31]=(s[9]<<27)|(s[8]>>>5); b[12]=(s[18]<<20)|(s[19]>>>12); b[13]=(s[19]<<20)|(s[18]>>>12); b[44]=(s[29]<<7)|(s[28]>>>25); b[45]=(s[28]<<7)|(s[29]>>>25); b[26]=(s[38]<<8)|(s[39]>>>24); b[27]=(s[39]<<8)|(s[38]>>>24); b[8]=(s[48]<<14)|(s[49]>>>18); b[9]=(s[49]<<14)|(s[48]>>>18); for (i = 0; i < 50; i += 10) { for (j = 0; j < 10; j++) c0 = 0; s[i] = b[i] ^ (~b[i+2] & b[i+4]); s[i+1] = b[i+1] ^ (~b[i+3] & b[i+5]); s[i+2] = b[i+2] ^ (~b[i+4] & b[i+6]); s[i+3] = b[i+3] ^ (~b[i+5] & b[i+7]); s[i+4] = b[i+4] ^ (~b[i+6] & b[i+8]); s[i+5] = b[i+5] ^ (~b[i+7] & b[i+9]); s[i+6] = b[i+6] ^ (~b[i+8] & b[i]); s[i+7] = b[i+7] ^ (~b[i+9] & b[i+1]); s[i+8] = b[i+8] ^ (~b[i] & b[i+2]); s[i+9] = b[i+9] ^ (~b[i+1] & b[i+3]); } s[0] ^= RC[n*2]; s[1] ^= RC[n*2+1]; } } /* bytes (Uint8Array) -> 32-byte Uint8Array */ function keccak256(msg) { var s = new Int32Array(50), R = 136, i, n = msg.length, off = 0; while (n - off >= R) { absorb(s, msg, off, R); keccakf(s); off += R; } var tail = new Uint8Array(R); tail.set(msg.subarray(off)); tail[n - off] = 0x01; tail[R - 1] |= 0x80; absorb(s, tail, 0, R); keccakf(s); var out = new Uint8Array(32); for (i = 0; i < 32; i++) out[i] = (s[i >> 2] >>> (8 * (i & 3))) & 0xff; return out; } function absorb(s, buf, off, R) { for (var i = 0; i < R; i++) s[i >> 2] ^= buf[off + i] << (8 * (i & 3)); } function toHex(u8) { var h = ''; for (var i = 0; i < u8.length; i++) h += (u8[i] < 16 ? '0' : '') + u8[i].toString(16); return h; } function hexToBytes(h) { /* Accept a number or bigint as well as a hex string: the studio and the * curation tools identify variants by small integer seeds, and derive() * must treat those the same way it treats a 32-byte token seed. */ if (typeof h === 'number' || typeof h === 'bigint') { h = h.toString(16); if (h.length % 2) h = '0' + h; } h = String(h).replace(/^0x/, ''); if (h.length % 2) h = '0' + h; var u = new Uint8Array(h.length / 2); for (var i = 0; i < u.length; i++) u[i] = parseInt(h.substr(i * 2, 2), 16); return u; } var api = { keccak256: keccak256, toHex: toHex, hexToBytes: hexToBytes }; root.KECCAK = api; })(root); /* --- kys.js --- */ /* KYS on-chain renderer — model + substrate + algorithms. * Must produce bit-identical output to substrate_chain.py + roster.py. * No dependencies. Deterministic: no Math.random, no Date, no float drift * beyond IEEE-754 double which both JS and numpy float64 share. */ 'use strict'; /* ---------------------------------------------------------------- model */ function loadModel(buf) { const dv = new DataView(buf); if (dv.getUint8(0) !== 0x4b || dv.getUint8(1) !== 0x59 || dv.getUint8(2) !== 0x53 || dv.getUint8(3) !== 0x4d) throw new Error('bad magic'); const K = dv.getUint8(5); const nvert = dv.getUint32(6, true), ntri = dv.getUint32(10, true); const vscale = dv.getFloat32(14, true); const vmin = [dv.getFloat32(18, true), dv.getFloat32(22, true), dv.getFloat32(26, true)]; let o = 30; const vq = new Int16Array(buf, o, nvert * 3); o += nvert * 6; const tris = new Uint16Array(buf, o, ntri * 3); o += ntri * 6; const basis = f16Array(buf, o, K * nvert * 3); const V0 = new Float64Array(nvert * 3); for (let i = 0; i < nvert; i++) for (let j = 0; j < 3; j++) V0[i * 3 + j] = vq[i * 3 + j] * vscale + vmin[j]; return { K, nvert, ntri, V0, tris, basis }; } /* float16 decode — JS has no Float16Array in older engines */ function f16Array(buf, off, n) { const u = new Uint16Array(buf, off, n), out = new Float32Array(n); for (let i = 0; i < n; i++) out[i] = f16(u[i]); return out; } function f16(h) { const s = (h & 0x8000) >> 15, e = (h & 0x7c00) >> 10, f = h & 0x03ff; if (e === 0) return (s ? -1 : 1) * Math.pow(2, -14) * (f / 1024); if (e === 0x1f) return f ? NaN : (s ? -Infinity : Infinity); return (s ? -1 : 1) * Math.pow(2, e - 15) * (1 + f / 1024); } /* ------------------------------------------------------------ identity */ /* PCA-tapered identity draw — mirrors params.identity() */ function identity(seed, faceShape, dims) { const rng = gauss(seed); const sigma = 2.2 * Math.pow(clamp(faceShape, 0, 1), 1.3); const out = new Float64Array(dims); for (let k = 0; k < dims; k++) out[k] = rng() * (1 / (1 + k / 40)) * sigma; return out; } /* deterministic PRNG: splitmix64 -> uniform -> Box-Muller. * NOTE: this does NOT match numpy's PCG64. See determinism note in README. */ function splitmix64(seed) { let s = BigInt.asUintN(64, BigInt(seed)); return function () { s = BigInt.asUintN(64, s + 0x9e3779b97f4a7c15n); let z = s; z = BigInt.asUintN(64, (z ^ (z >> 30n)) * 0xbf58476d1ce4e5b9n); z = BigInt.asUintN(64, (z ^ (z >> 27n)) * 0x94d049bb133111ebn); z = z ^ (z >> 31n); return Number(z >> 11n) / 9007199254740992; // 53-bit uniform }; } function gauss(seed) { const u = splitmix64(seed); let spare = null; return function () { if (spare !== null) { const v = spare; spare = null; return v; } let a = u(), b = u(); if (a < 1e-300) a = 1e-300; const r = Math.sqrt(-2 * Math.log(a)), th = 2 * Math.PI * b; spare = r * Math.sin(th); return r * Math.cos(th); }; } const clamp = (v, a, b) => v < a ? a : v > b ? b : v; /* ---------------------------------------------------------------- verts */ function verts(M, ident) { /* Accumulate in float32 to match numpy's einsum on a float32 basis. * Math.fround() forces each step through single precision, which is what * makes this bit-identical to the Python reference. */ const n = M.nvert, N = n * 3; const acc = new Float32Array(N); for (let k = 0; k < M.K; k++) { const c = Math.fround(ident[k]); if (c === 0) continue; const off = k * N; for (let i = 0; i < N; i++) acc[i] = Math.fround(acc[i] + Math.fround(c * M.basis[off + i])); } const out = new Float64Array(N); for (let i = 0; i < N; i++) out[i] = M.V0[i] + acc[i]; return out; } function lookAt(V, yawDeg, pitchDeg) { const n = V.length / 3; let cx = 0, cy = 0, cz = 0; for (let i = 0; i < n; i++) { cx += V[i*3]; cy += V[i*3+1]; cz += V[i*3+2]; } cx /= n; cy /= n; cz /= n; const ry = yawDeg * Math.PI / 180, rx = pitchDeg * Math.PI / 180; const cy_ = Math.cos(ry), sy = Math.sin(ry), cp = Math.cos(rx), sp = Math.sin(rx); const out = new Float64Array(n * 3); for (let i = 0; i < n; i++) { const x = V[i*3] - cx, y = V[i*3+1] - cy, z = V[i*3+2] - cz; // Ry then Rx, matching numpy P @ Ry.T @ Rx.T const x1 = cy_*x + sy*z, y1 = y, z1 = -sy*x + cy_*z; out[i*3] = x1; out[i*3+1] = cp*y1 - sp*z1; out[i*3+2] = sp*y1 + cp*z1; } return out; } /* ----------------------------------------------------------- rasterize */ function rasterize(P, tris, size, persp, margin) { margin = margin === undefined ? 0.90 : margin; const nt = tris.length / 3, n = P.length / 3; // face normals const fn = new Float64Array(nt * 3); for (let t = 0; t < nt; t++) { const a = tris[t*3]*3, b = tris[t*3+1]*3, c = tris[t*3+2]*3; const ux = P[b]-P[a], uy = P[b+1]-P[a+1], uz = P[b+2]-P[a+2]; const vx = P[c]-P[a], vy = P[c+1]-P[a+1], vz = P[c+2]-P[a+2]; let nx = uy*vz-uz*vy, ny = uz*vx-ux*vz, nz = ux*vy-uy*vx; const L = Math.hypot(nx, ny, nz) || 1; fn[t*3] = nx/L; fn[t*3+1] = ny/L; fn[t*3+2] = nz/L; } // projection const Q = new Float64Array(P.length); Q.set(P); if (persp > 0) { let zmin = Infinity, zmax = -Infinity; for (let i = 0; i < n; i++) { const z = P[i*3+2]; if (z<zmin) zmin=z; if (z>zmax) zmax=z; } const span = zmax - zmin, d = span / Math.max(persp, 1e-6) * 3.0; for (let i = 0; i < n; i++) { const s = d / (d - P[i*3+2] + span); Q[i*3] = P[i*3]*s; Q[i*3+1] = P[i*3+1]*s; } } let lox=Infinity, loy=Infinity, hix=-Infinity, hiy=-Infinity; for (let i=0;i<n;i++){const x=Q[i*3],y=Q[i*3+1]; if(x<lox)lox=x; if(x>hix)hix=x; if(y<loy)loy=y; if(y>hiy)hiy=y;} const scale = margin * size / Math.max(hix-lox, hiy-loy); const ctrx = (lox+hix)/2, ctry = (loy+hiy)/2; const X = new Float64Array(n), Y = new Float64Array(n), Z = new Float64Array(n); for (let i=0;i<n;i++){ X[i] = (Q[i*3]-ctrx)*scale + size/2; Y[i] = size - ((Q[i*3+1]-ctry)*scale + size/2); Z[i] = Q[i*3+2]; } const zbuf = new Float64Array(size*size).fill(-Infinity); const nbuf = new Float64Array(size*size*3); const mask = new Uint8Array(size*size); // painter order: far -> near, matching the numpy reference const idx = [], area = new Float64Array(nt); for (let t=0;t<nt;t++){ const a=tris[t*3],b=tris[t*3+1],c=tris[t*3+2]; const ar=(X[b]-X[a])*(Y[c]-Y[a])-(X[c]-X[a])*(Y[b]-Y[a]); area[t]=ar; if(ar<0) idx.push(t); } idx.sort((p,q)=>{ const zp=Z[tris[p*3]]+Z[tris[p*3+1]]+Z[tris[p*3+2]]; const zq=Z[tris[q*3]]+Z[tris[q*3+1]]+Z[tris[q*3+2]]; return zq-zp; // descending, == numpy argsort(-z) }); for (const t of idx) { const a=tris[t*3],b=tris[t*3+1],c=tris[t*3+2]; const x0=X[a],x1=X[b],x2=X[c], y0=Y[a],y1=Y[b],y2=Y[c]; let xmin=Math.max(Math.floor(Math.min(x0,x1,x2)),0); let xmax=Math.min(Math.ceil(Math.max(x0,x1,x2)),size-1); let ymin=Math.max(Math.floor(Math.min(y0,y1,y2)),0); let ymax=Math.min(Math.ceil(Math.max(y0,y1,y2)),size-1); if(xmax<xmin||ymax<ymin) continue; const d=area[t]; for(let py=ymin;py<=ymax;py++){ const gy=py+0.5; for(let px=xmin;px<=xmax;px++){ const gx=px+0.5; const w0=((x1-x0)*(gy-y0)-(gx-x0)*(y1-y0))/d; const w1=((x2-x1)*(gy-y1)-(gx-x1)*(y2-y1))/d; const w2=1-w0-w1; if(w0<0||w1<0||w2<0) continue; const zz=w2*Z[a]+w0*Z[b]+w1*Z[c]; const o=py*size+px; if(zz>zbuf[o]){ zbuf[o]=zz; mask[o]=1; nbuf[o*3]=fn[t*3]; nbuf[o*3+1]=fn[t*3+1]; nbuf[o*3+2]=fn[t*3+2]; } } } } // normalise depth over the mask let zlo=Infinity, zhi=-Infinity; for(let i=0;i<size*size;i++) if(mask[i]){const z=zbuf[i]; if(z<zlo)zlo=z; if(z>zhi)zhi=z;} const depth=new Float64Array(size*size); const rng2=Math.max(zhi-zlo,1e-9); for(let i=0;i<size*size;i++) if(mask[i]) depth[i]=(zbuf[i]-zlo)/rng2; return {mask, depth, normal:nbuf, size}; } var KYS={loadModel:loadModel,identity:identity,verts:verts,lookAt:lookAt,rasterize:rasterize,splitmix64:splitmix64,gauss:gauss,clamp:clamp,f16:f16}; /* --- marks.js --- */ /* Canonical mark-scale mapping. Shared by node tools AND the browser studio. * * Rules learned the hard way: * - cell/grid parameters MUST be integers (cellMean's integral image collapses * on fractional box sizes -> 0% ink) * - counts scale by 1/sc^2 (area), spacings by sc (length) * - every value is clamped so no algorithm degenerates at the extremes */ /* Each method's NATIVE pitch, as originally designed. markParams scales these * rather than handing every method the same number — a shared `spacing` blew * out methods designed tight (crosshatch wants 6, not 14). */ const BASE = { crosshatch:6, engraving:6, dashes:5, vertical:7, moire:7, woodcut:9, domainwarp:10, chevron:11, warpbands:11, diagonal:12, sinebands:12, hachure:9, quiver:7, rain:3, bars:9, seismo:13, ridgeline:13, }; function pitchFor(name, sc){ const b = BASE[name]; return b === undefined ? undefined : Math.max(b*sc, 1.5); } function markParams(sc){ const I=(v,lo,hi)=>Math.max(lo,Math.min(hi,Math.round(v))); // integer, clamped const Fl=(v,lo,hi)=>Math.max(lo,Math.min(hi,v)); // float, clamped return { // --- integer cell / grid sizes (MUST be whole numbers) --- cell: I(7*sc, 2, 40), cw: I(14*sc, 3, 60), chh: I(7*sc, 2, 30), mod: I(8*sc, 2, 40), step: I(7*sc, 2, 30), minc: I(4*sc, 2, 24), dot: I(2*sc, 1, 10), band: I(13*sc, 3, 50), // --- float spacings (fractional is fine) --- spacing: Fl(14*sc, 2, 60), sp: Fl(7*sc, 1.5, 30), dash: Fl(9*sc, 2, 40), pitch: Fl(3.1*sc, 0.8, 14), // --- inverse: fewer/larger as scale rises --- rings: Fl(30/sc, 4, 90), turns: Fl(34/sc, 4, 100), bands: Fl(17/sc, 3, 50), rows: I(60/sc, 6, 140), levels: I(26/sc, 4, 60), count: I(150/sc, 12, 400), petals: I(20/sc, 3, 60), sides: I(6, 3, 12), // --- counts scale by area --- sites: I(1500/(sc*sc), 40, 4000), lines: I(2600/(sc*sc), 60, 6000), blots: I(1500/(sc*sc), 40, 4000), walkers: I(2600/(sc*sc), 80, 6000), curves: I(150/(sc*sc), 10, 400), // --- density-style (need explicit handling) --- density: Fl(0.42*sc*sc*0.55, 0.06, 1.0), grain: Fl(0.55, 0.2, 1.6), cols: I(3*sc, 2, 20), }; } /* --- render.js --- */ /* Canonical render entry point. * * RESOLUTION AND MARK SCALE ARE COUPLED. A saved composition is defined at a * reference size (REF). Rendering at any other size multiplies the mark scale * by size/REF, so marks keep the same PROPORTION of the head — the image looks * the same at thumbnail size, but has more pixels describing each mark when * you zoom in. * * Without this, doubling the canvas halves the apparent mark size and you get * a different picture, not a sharper one. */ 'use strict'; const REF=300; // the size mark scales were tuned against function paramsFor(v, size, name){ const k = size/REF; // resolution multiplier /* `mark` and `scale` are the same axis under two names: derive() emits * `mark`, the studio and canonical.json store `scale`. Accept BOTH. * * They were previously not reconciled here, so every seed-derived token — * batches, simulate, render_onchain and the on-chain entry point — silently * rendered at mark 1.0 no matter what its seed said, while the studio (which * stores `scale`) honoured it. That is why studio output and token output * diverged, and why sweeping `mark` appeared to do nothing. */ const ms = (v.scale !== undefined ? v.scale : v.mark !== undefined ? v.mark : 1) * k; // marks grow with the canvas const p = markParams(ms); /* markParams divides counts by ms^2 so that BIGGER MARKS mean FEWER of them. * But when ms grew only because the canvas grew, the count must be restored: * the canvas has k^2 more pixels, so multiply the area-based counts back by * k^2. Otherwise a 1080px render has 13x fewer voronoi sites than a 300px * one and turns into a different (much emptier) picture. */ const a = k*k; for (const key of ['sites','lines','blots','walkers','curves']) { if (p[key] !== undefined) p[key] = Math.round(p[key]*a); } /* stipple/phyllotaxis/spiraldots use ms directly for dot radius; their * density must likewise not thin out with resolution. */ if (p.density !== undefined) p.density = Math.min(p.density*a, 1); /* isoline-family: level count must not fall with resolution, or contours * spread apart and the image empties out. */ for (const key of ['levels','rows','count','petals']) { if (p[key] !== undefined) p[key] = Math.max(Math.round(p[key]*k), 3); } /* per-method native pitch overrides the shared spacing */ const pf = name ? pitchFor(name, ms) : undefined; if (pf !== undefined) { p.spacing = pf; p.sp = pf; } return Object.assign(p, { ms, light_dir:v.light_dir, light_elev:v.light_elev, soft:v.soft, white:v.white, gamma:v.gamma, seed:v.seed, flowseeds: Math.round(4200*a), }); } /* Line-drawing methods hardcode ~1px stroke widths. When the canvas grows, * those strokes stay 1px and the image thins out. Rather than thread a line * width through 80+ algorithms, dilate the result by the resolution factor — * a 1px line at 300px becomes a 2px line at 600px, which is what "same picture, * more pixels" actually means. Only applies to methods that draw thin strokes; * area-filling methods (halftone, dither) are already invariant. */ /* Derived EMPIRICALLY: these are the methods whose coverage tracks the * reference more closely WITH dilation than without. Do not hand-edit — * re-measure if algorithms change (see _thinaudit.js). * * RE-MEASURED after the canvas gained padding. The old 17-method set was * calibrated when artwork == canvas (full bleed). Once the artwork is inset * — 556px inside a 1000px canvas for a 50% head — strokeRadius() jumps from * 0 to 1, so dilation switches on for the first time and FATTENS these * methods by 1.5x-2.7x. Re-running the same coverage test at 556px artwork * against the 420px full-bleed reference, 15 of the 17 land CLOSER to the * reference with dilation OFF. Confirmed by eye, not just by coverage: * ridgeline's 1px scanlines merged into blobs, seismo lost its structure, * bubbles' circles closed up, scales went nearly solid. * * Only isolines and splatter still earn it — both draw sparse strokes with * real whitespace between them, which is the case dilation was meant for. * * Note strokeRadius() is a STEP function (round(size/REF)-1): it flips 0->1 * at 450px artwork and 1->2 at 750px, so quality changes discontinuously * with the inset. Re-measure if the default inset moves. */ const THIN = new Set(["isolines", "splatter"]); function dilate(ink, S, r){ if(r<1) return ink; const out=new Float64Array(S*S); for(let y=0;y<S;y++)for(let x=0;x<S;x++){ if(ink[y*S+x]<=0.5) continue; for(let dy=-r;dy<=r;dy++)for(let dx=-r;dx<=r;dx++){ if(dx*dx+dy*dy>r*r+0.5) continue; const yy=y+dy,xx=x+dx; if(yy<0||yy>=S||xx<0||xx>=S) continue; out[yy*S+xx]=1; } } return out; } function strokeRadius(name, size){ if(!THIN.has(name)) return 0; return Math.max(Math.round(size/REF)-1, 0); } var KYSR={paramsFor:paramsFor,REF:REF,dilate:dilate,strokeRadius:strokeRadius,THIN:THIN}; /* --- derive.js --- */ /* Seed -> full parameter set. Pure function, deterministic, matches on-chain. * * seed = keccak256(tokenId, burner, blockhash) -- 32 bytes * Every parameter is drawn from successive slices of an expanded keystream, * so the whole artwork is a deterministic function of one 32-byte value. */ 'use strict'; /* keccak256 — the SAME function Solidity uses. In node this resolves the * bundled implementation; in the browser build the bundler inlines it and this * require line is stripped, leaving the global KECCAK. */ const KECCAK=root.KECCAK; /* Keystream: keccak256(seed ++ uint32be(counter)), consumed 4 bytes at a time. * * This MUST match the contract. It previously used a sha256 stand-in, which * agreed with Solidity's keccak256 on only 1.3% of seeds -- i.e. chance -- so * the Method trait named a different method than the artwork drew. */ function keystream(seedHex, n) { const seed = KECCAK.hexToBytes(seedHex); const out = []; let ctr = 0; while (out.length < n) { const msg = new Uint8Array(seed.length + 4); msg.set(seed, 0); msg[seed.length] = (ctr >>> 24) & 255; msg[seed.length + 1] = (ctr >>> 16) & 255; msg[seed.length + 2] = (ctr >>> 8) & 255; msg[seed.length + 3] = ctr & 255; const h = KECCAK.keccak256(msg); for (let i = 0; i + 4 <= h.length && out.length < n; i += 4) { const v = ((h[i] << 24) | (h[i+1] << 16) | (h[i+2] << 8) | h[i+3]) >>> 0; out.push(v / 4294967296); } ctr++; } return out; } /* Parameter ranges — must match the contract exactly. */ const RANGES = { /* RESTORED to the full [0.50, 2.25]. It was briefly narrowed to * [0.70, 1.40] because the top end looked "sparse and washed out" — but * that was measured while render.js's THIN set was dilating 17 methods by * 1.5x-2.7x at the inset artwork size. Coarse marks were being fattened * into blobs and then read as degenerate. With THIN corrected to just * {isolines, splatter}, the full range is clean: swept across all 80 * methods at the token's 556px artwork, NOTHING clogs solid (>97% ink) at * 0.50 and nothing washes out (<3%) at 2.25 except `maze`, which already * carries a per-method CLAMPS entry. * * The range is the expressive axis of the whole roster — fine texture at * the low end, coarse abstraction at the high end. Narrowing it to +/-40% * around 1.0 collapses most of that. Re-validate with a full sweep before * changing it again, and check what dilation is doing first. */ mark : [0.50, 2.25], // mark scale — fine texture <-> coarse abstraction face_shape : [0.15, 0.95], rotate : [-70, 85 ], pitch : [ -8, 8 ], perspective: [0.0, 0.90], light_dir : [-100, 100], light_elev : [-15, 75 ], soft : [0.05, 0.70], /* FLOORS RAISED 0.02 -> 0.12 (white) and 0.60 -> 0.95 (gamma). * * The floor governs how DARK a token may get: `tone()` computes * (t-white)/(1-white) then pow(.,gamma), so a low floor lets the head fill * in as a featureless white mass. At the old floor, pinning all 80 methods * to it gave a median of 48.6% ink with 18 methods over 70% — solid * silhouettes with texture only at the edges. * * Raising the FLOOR is a much stronger lever than widening the ceiling: * ceiling work moved over-filled tokens 7 -> 4, this moves 18 -> 3 at the * floor itself. Measured over all 80 methods pinned to the floor: * * floor median max >70% * w0.02 g0.60 48.6% 93.0% 18 (old) * w0.12 g0.95 34.4% 83.3% 3 (this) * w0.22 g1.30 22.1% 75.0% 2 (breaks up shadow detail) * * Chosen for keeping full tonal range — tokens can still be genuinely dark, * just not featureless. Per-method CLAMPS below may still floor LOWER where * a method needs it (tally, morse, crossgrid, maze, diamond). */ white : [0.12, 0.30], gamma : [0.95, 1.60], }; const ORDER = ['mark','face_shape','rotate','pitch','perspective', 'light_dir','light_elev','soft','white','gamma']; /* Per-algorithm clamps for the few whose legibility is fragile. */ /* Per-method mark ceilings, as ABSOLUTE values against RANGES.mark. * These were remapped when the range was briefly narrowed to [0.70,1.40] and * are now restored alongside it. `maze` is the one method that genuinely * degenerates at the top of the global range (2.2% ink at 2.25), which is * what its 1.40 ceiling is for. */ const CLAMPS = { tally:{white:[0.02,0.14],soft:[0.05,0.40]}, morse:{white:[0.02,0.20]}, crossgrid:{white:[0.02,0.20], mark:[0.50,1.90]}, randomwalk:{mark:[0.50,1.90]}, splatter:{mark:[0.50,1.90]}, /* these six lose legibility above the midpoint of the mark range */ maze:{white:[0.02,0.18], mark:[0.50,1.40]}, truchet:{mark:[0.50,1.40]}, ascii:{mark:[0.50,1.40]}, lowpoly:{mark:[0.50,1.40]}, quadtree:{mark:[0.50,1.40]}, wolfram:{mark:[0.50,1.40]}, /* ---- coverage clamps, measured at the token's own 500px inner canvas * (1000px @ inset 0.50) over 8 seeds x the axis quartiles. * These shift the white/gamma WINDOW toward the corrective end while * keeping it a window — a fixed pair would make every token of the * method identical. Only methods a clamp actually rescues are listed; * maze, rain and scales resist white/gamma and need algorithm work. */ diamond:{white:[0.02,0.15], gamma:[0.60,1.05]}, /* 13.2% -> 19.0% */ offsets:{white:[0.06,0.30], gamma:[0.75,1.60]}, /* 55.1% -> 53.2% */ ridgeline:{white:[0.06,0.30], gamma:[0.75,1.60]}, /* 56.8% -> 54.0% */ /* high mark thins these out */ scribble:{mark:[0.50,1.80]}, lissajous:{mark:[0.50,1.80]}, rain:{mark:[0.50,1.90]}, }; /* keccak256(seed ++ uint32(0)) % n -- the contract's method selector. */ function methodIndex(seedHex, n) { const seed = KECCAK.hexToBytes(seedHex); const msg = new Uint8Array(seed.length + 4); msg.set(seed, 0); const h = KECCAK.keccak256(msg); let r = 0n; for (let i = 0; i < 32; i++) r = (r << 8n) | BigInt(h[i]); return Number(r % BigInt(n)); } const ASCII_RAMPS = ['classic','code','letters','blocks','dense','numeric','minimal']; /* ---- ink floor ----------------------------------------------------------- * white and gamma both push the same way: LOW white + LOW gamma = far more ink * = a mostly-white image (measured 58-80% coverage when both bottom out). * * Leave both alone unless their COMBINED position is too low, then lift the * pair. Most tokens are untouched; only the ones that would wash out get * nudged. * * EXPORTED and shared. This logic was previously inline here AND duplicated in * studio.html, with nothing testing that the two agreed — see REVIEW.md 4b. * Mutates `p` in place and returns it. */ const INK_FLOOR = 0.70; // min combined position (of 2.0) -> ~44% ink ceiling function applyInkFloor(p, rr) { const [wlo, whi] = rr.white, [glo, ghi] = rr.gamma; const wPos = (p.white - wlo) / (whi - wlo); const gPos = (p.gamma - glo) / (ghi - glo); const sum = wPos + gPos; if (sum >= INK_FLOOR) return p; /* Lift GAMMA first. Some methods have a deliberately narrow white range (tally, maze, morse, crossgrid need low white space to stay legible), so white often has little room and gamma must carry the shortfall. */ let w = wPos, g = gPos, need = INK_FLOOR - sum; const gTake = Math.min(need, 1 - g); g += gTake; need -= gTake; if (need > 0) w = Math.min(w + need, 1); p.white = wlo + w * (whi - wlo); p.gamma = glo + g * (ghi - glo); return p; } /* Ranges for one method, with its clamps applied. */ function rangesFor(algo) { return Object.assign({}, RANGES, CLAMPS[algo] || {}); } function derive(seedHex, algoNames) { const k = keystream(seedHex, 20); /* Method index is an INTEGER reduction of the first keccak word, matching * KYSRenderer's `keccak256(seed, uint32(0)) % N` exactly. Do not reintroduce * float arithmetic here: floor(k[0]*N) does NOT agree with a modulo. */ const algo = algoNames[methodIndex(seedHex, algoNames.length)]; const rr = rangesFor(algo); const p = { algo, identity_seed: Math.floor(k[1] * 2147483647) }; ORDER.forEach((key, i) => { const [lo, hi] = rr[key]; p[key] = lo + k[2 + i] * (hi - lo); }); applyInkFloor(p, rr); /* per-method sub-parameters */ if (algo === 'ascii') { p.ramp = ASCII_RAMPS[Math.floor(k[15] * ASCII_RAMPS.length) % ASCII_RAMPS.length]; } return p; } /* How many distinct visually-meaningful outcomes? Quantise each axis to the * granularity at which a change is actually perceptible. */ const QUANT = { face_shape:0.08, rotate:6, pitch:4, perspective:0.15, light_dir:10, light_elev:8, soft:0.08, white:0.04, gamma:0.20, }; function bucket(p) { return p.algo + '|' + ORDER.map(k => Math.round(p[k] / QUANT[k])).join(','); } var KYSD={derive:derive,keystream:keystream,methodIndex:methodIndex,RANGES:RANGES,ORDER:ORDER,CLAMPS:CLAMPS,ASCII_RAMPS:ASCII_RAMPS}; root.KYS=KYS; root.KYSR=KYSR; root.KYSD=KYSD; })(typeof self!=="undefined"?self:this); /* --- entry.js --- */ /* KYS token page entry point. * * The page arrives with three globals set by KYSRenderer._page(): * window.KYS_SEED 0x-prefixed 32-byte seed * window.KYS_STORE EthFS FileStore address * window.KYS_MODEL model filename, e.g. "gnm-k32.bin" * * It fetches the model straight from chain -- getFile() for the pointer list, * then one eth_getCode per chunk. eth_getCode is UNMETERED, so this costs the * viewer nothing and no node has to serve a large eth_call. (EthFS.read() on * the whole 2.4MB file measures 31.8 BILLION gas and is unservable.) * * Requires kys-engine.js and kys-roster.js to have loaded first. */ (function (root) { 'use strict'; var SEL_GETFILE = '0xe0876aa8'; // getFile(string) function el(id) { return root.document.getElementById(id); } function status(msg) { var s = el('s'); if (s) s.textContent = msg; } /* ---------------------------------------------------------------- rpc */ /* Provider order: an injected wallet first (no rate limits, no third party), * then public fallbacks. The artwork must not depend on any single host. */ function providers() { var out = []; if (root.ethereum) out.push({ kind: 'injected', p: root.ethereum }); var urls = root.KYS_RPC ? [].concat(root.KYS_RPC) : []; urls.push('https://ethereum-rpc.publicnode.com'); urls.push('https://eth.llamarpc.com'); urls.push('https://rpc.ankr.com/eth'); for (var i = 0; i < urls.length; i++) out.push({ kind: 'http', url: urls[i] }); return out; } function call(prov, method, params) { if (prov.kind === 'injected') { return prov.p.request({ method: method, params: params }); } return root.fetch(prov.url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: method, params: params }) }).then(function (r) { return r.json(); }) .then(function (j) { if (j.error) throw new Error(j.error.message); return j.result; }); } /* --------------------------------------------------------------- abi */ function encGetFile(name) { var hex = '', i; for (i = 0; i < name.length; i++) { var c = name.charCodeAt(i); hex += (c < 16 ? '0' : '') + c.toString(16); } var byteLen = name.length; var pad = (32 - (byteLen % 32)) % 32; return SEL_GETFILE + pad64(32) + pad64(byteLen) + hex + repeat('00', pad); } function pad64(n) { var h = n.toString(16); return repeat('0', 64 - h.length) + h; } function repeat(s, n) { var o = ''; while (n-- > 0) o += s; return o; } /* File = (uint256 size, Content[] contents); Content = (address,uint32,uint32) */ function decFile(hex) { var h = hex.slice(2); function w(i) { return h.slice(i * 64, (i + 1) * 64); } function n(i) { return parseInt(w(i), 16); } var structOff = n(0) / 32; var size = n(structOff); var arrOff = structOff + n(structOff + 1) / 32; var len = n(arrOff); var contents = []; for (var i = 0; i < len; i++) { var b = arrOff + 1 + i * 3; contents.push({ pointer: '0x' + w(b).slice(24), start: n(b + 1), end: n(b + 2) }); } return { size: size, contents: contents }; } function hexToBytes(hex, start, end) { var s = start * 2 + 2, e = end * 2 + 2; var out = new Uint8Array(end - start); for (var i = 0; i < out.length; i++) { out[i] = parseInt(hex.substr(s + i * 2, 2), 16); } return out; } /* -------------------------------------------------------------- fetch */ function fetchModel(prov, name, onProgress) { return call(prov, 'eth_call', [{ to: root.KYS_STORE, data: encGetFile(name) }, 'latest']) .then(function (raw) { var file = decFile(raw); var parts = new Array(file.contents.length); var total = file.contents.length; /* Sequential: 101 parallel requests get rate-limited by every public * node. This is slower but it actually completes. */ var chain = Promise.resolve(); file.contents.forEach(function (c, i) { chain = chain.then(function () { return call(prov, 'eth_getCode', [c.pointer, 'latest']); }).then(function (code) { parts[i] = hexToBytes(code, c.start, c.end); if (onProgress) onProgress(i + 1, total); }); }); return chain.then(function () { var out = new Uint8Array(file.size), off = 0; for (var i = 0; i < parts.length; i++) { out.set(parts[i], off); off += parts[i].length; } if (off !== file.size) throw new Error('size mismatch ' + off + '/' + file.size); return out; }); }); } /* Try each provider in turn. A dead RPC must not mean a dead artwork. */ function fetchWithFallback(name, onProgress) { var provs = providers(), i = 0; function attempt() { if (i >= provs.length) throw new Error('all RPC providers failed'); var p = provs[i++]; return fetchModel(p, name, onProgress).catch(function (e) { if (i < provs.length) { status('retrying via another node...'); return attempt(); } throw e; }); } return attempt(); } /* ------------------------------------------------------------- render */ /* The payload is "KYSL" + uint32 headerLen + licence text + the KYSM model. * Skip the header; the licence rides along on chain but is not model data. */ function modelBytes(payload) { if (payload[0] === 0x4b && payload[1] === 0x59 && payload[2] === 0x53 && payload[3] === 0x4c) { var len = payload[4] | (payload[5] << 8) | (payload[6] << 16) | (payload[7] << 24); return payload.subarray(8 + len); } return payload; // raw KYSM, no licence header } function draw(payload) { var bytes = modelBytes(payload); var ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); var M = root.KYS.loadModel(ab); var names = Object.keys(root.KYS_A); var p = root.KYSD.derive(root.KYS_SEED, names); var canvas = el('c'); var S = canvas.width; var ident = root.KYS.identity(p.identity_seed, p.face_shape, M.K); var V = root.KYS.verts(M, ident); var F = root.KYS.rasterize(root.KYS.lookAt(V, p.rotate, p.pitch), M.tris, S, p.perspective); var ink = root.KYS_A[p.algo](F, root.KYSR.paramsFor(p, S, p.algo)); var ctx = canvas.getContext('2d'); var img = ctx.createImageData(S, S); var d = img.data; for (var i = 0; i < S * S; i++) { var v = ink[i] > 0.5 ? 0 : 255; d[i * 4] = d[i * 4 + 1] = d[i * 4 + 2] = v; d[i * 4 + 3] = 255; } ctx.putImageData(img, 0, 0); root.KYS_METHOD = p.algo; root.KYS_PARAMS = p; root.KYS_DONE = true; // render_onchain / headless tests poll this status(''); } function main() { if (!root.KYS_SEED) { status('no seed'); return; } status('fetching model from chain...'); fetchWithFallback(root.KYS_MODEL || 'gnm-k32.bin', function (i, n) { if (i % 10 === 0 || i === n) status('chunk ' + i + ' / ' + n); }).then(function (payload) { status('rendering...'); /* yield once so the status paints before the synchronous render blocks */ root.setTimeout(function () { try { draw(payload); } catch (e) { status('render failed: ' + e.message); root.KYS_ERROR = String(e); } }, 0); }).catch(function (e) { status('failed: ' + e.message); root.KYS_ERROR = String(e); }); } if (root.document && root.document.readyState === 'loading') { root.document.addEventListener('DOMContentLoaded', main); } else if (root.document) { main(); } root.KYS_MAIN = main; })(typeof self !== 'undefined' ? self : this);