0xee514bd0…b86dsent to0x062a6f33…3dec·#25,857,084·0x9a97f0e0…a5d8fd
/* 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);