From d21f483a19b1a7d1c6456a3d27151c70331d6c54 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 09:26:34 +0700 Subject: [PATCH 1/2] t27f: idea factory -- any idea in any style preset, every element a particle (Closes #10771) particle_ds.t27 is the particle design system: five presets (noir-gold, neon-vortex, trit-charge, paper-ink, rain) as rows of integers -- palette, blend, radius, gain, spring, dashpot, force law, kick, card -- and one Q8 integer step, kernel, tone curve and finish. idea_factory.t27 is the factory: (idea, preset) -> 1080x1920 RGB24 frames. It wraps the idea into lines and scenes, sets each scene in the 3x5 font, gives every particle a target cell or a card-border point at each cut, and closes on the address. Lab: test-report 12/12 and 8/8 pass, 0 vacuous; seals saved and verified; dupe_scan clean; 5-preset preview rendered at 1080x1920. Co-Authored-By: Claude Opus 5.5 --- .trinity/seals/t27f_idea_factory.json | 37 ++ .trinity/seals/t27f_particle_ds.json | 33 ++ specs/t27f/idea_factory.t27 | 564 ++++++++++++++++++++++++++ specs/t27f/particle_ds.t27 | 419 +++++++++++++++++++ 4 files changed, 1053 insertions(+) create mode 100644 .trinity/seals/t27f_idea_factory.json create mode 100644 .trinity/seals/t27f_particle_ds.json create mode 100644 specs/t27f/idea_factory.t27 create mode 100644 specs/t27f/particle_ds.t27 diff --git a/.trinity/seals/t27f_idea_factory.json b/.trinity/seals/t27f_idea_factory.json new file mode 100644 index 0000000000..fff853f045 --- /dev/null +++ b/.trinity/seals/t27f_idea_factory.json @@ -0,0 +1,37 @@ +{ + "built_by": "t27c-bootstrap@0.5.2+355d44a39", + "closure": [ + { + "spec": "specs/t27f/particle_ds.t27", + "spec_hash": "sha256:00e84a7492536bf54ac69aa0cd852dea4727477acee12538f9c4b145c3d9c8f3" + }, + { + "spec": "specs/t27f/scene_raster.t27", + "spec_hash": "sha256:7be7118d865aa65fd1ccb989ab160df2feb46c9ded0a15ca6510b747301ee4a6" + } + ], + "config": "gen=c,rust,verilog,zig;tests=zig", + "gen_hash_c": "sha256:49e275c87108c8621d521226b89afb5f6c6a39f74de8e8bba502fbd1e39ad843", + "gen_hash_rust": "sha256:7d03e6e99a15344a1c8272b7f96ffc8ddcbfdb1cf44ab7f70f7a0e37847529bc", + "gen_hash_verilog": "sha256:0f761cb6e48d10ac555e51b8e5722eb8b02074a87ca2055aaec9241754140c49", + "gen_hash_zig": "sha256:0730d911d7047dc707075d16c8ff04afe222f4422a24344eff10b90e72edfdf0", + "module": "idea_factory", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-19T02:25:39Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:34f59b06d780be9cbbae1b0577e7c96ed5b6b7d2451e61ada1b1dde2fd58b6b8", + "spec_path": "specs/t27f/idea_factory.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 8, + "total": 8, + "vacuous": 0 + }, + "toolchain": { + "test_runner": "sha256:8512b80da533b79289110fca4a65f3336abd7a059ca4b72ea595d5e33ff8ad10", + "zig": "0.16.0" + } +} \ No newline at end of file diff --git a/.trinity/seals/t27f_particle_ds.json b/.trinity/seals/t27f_particle_ds.json new file mode 100644 index 0000000000..5797784dfd --- /dev/null +++ b/.trinity/seals/t27f_particle_ds.json @@ -0,0 +1,33 @@ +{ + "built_by": "t27c-bootstrap@0.5.2+355d44a39", + "closure": [ + { + "spec": "specs/t27f/scene_raster.t27", + "spec_hash": "sha256:7be7118d865aa65fd1ccb989ab160df2feb46c9ded0a15ca6510b747301ee4a6" + } + ], + "config": "gen=c,rust,verilog,zig;tests=zig", + "gen_hash_c": "sha256:03823e75050d119fede755d7e3b218cd924f47ad3d310bcc39f5a00ea315a715", + "gen_hash_rust": "sha256:33f633fdef8835e3d69e6e758e6bb180de9e103fca81005405999c391bec82f4", + "gen_hash_verilog": "sha256:c5ef23af6415734e2f11a6dd645de11cd86a4f040c331595fa3f11c4765a71db", + "gen_hash_zig": "sha256:262eb3241c84d2de64e67b59ea700299f8cf513d1d3a3e2ecf25279f5421f63f", + "module": "particle_ds", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-19T02:25:36Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:00e84a7492536bf54ac69aa0cd852dea4727477acee12538f9c4b145c3d9c8f3", + "spec_path": "specs/t27f/particle_ds.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 12, + "total": 12, + "vacuous": 0 + }, + "toolchain": { + "test_runner": "sha256:8512b80da533b79289110fca4a65f3336abd7a059ca4b72ea595d5e33ff8ad10", + "zig": "0.16.0" + } +} \ No newline at end of file diff --git a/specs/t27f/idea_factory.t27 b/specs/t27f/idea_factory.t27 new file mode 100644 index 0000000000..8affe44e9f --- /dev/null +++ b/specs/t27f/idea_factory.t27 @@ -0,0 +1,564 @@ +// SPDX-License-Identifier: Apache-2.0 +// specs/t27f/idea_factory.t27 -- the content factory: (idea, preset) -> frames. Any idea, in any +// style of the particle design system, and every element on screen is a particle. Issue +// gHashTag/t27#10771, epic #10560. +// +// WHY: the owner, 2026-10-19: one factory, on the factory pattern, that unpacks any idea in any +// style preset, with the factory and all its elements formed by particle physics -- the visual +// design system of the OS, so it has to be able to run on the FPGA too. Before this, every reel +// was a hand-built module with its own f64 constants (token_floor, reel_particles, lesson_reel). +// +// THE FACTORY, in four stages, each a pure function the tests pin: +// wrap the idea is cut into lines of at most MAXC characters (greedy, by words; a longer +// word is split) and lines into scenes of at most MAXL; an explicit `|` cuts a scene. +// layout a scene's lines are set in the 3x5 font of scene_raster, centred, at the largest cell +// size S that fits the text box; every lit font pixel is a cell, a target for particles. +// assign at each scene cut every particle gets a new target: a sub-slot of a cell, or -- when +// the preset has a card -- a point on the card's border around the text. The closing +// scene is the address, CTA. +// render particle_ds moves every particle (spring, dashpot, the preset's law, the cut's kick) +// and paints it as a soft disc; main writes 1080x1920 RGB24 frames to stderr for ffmpeg. +// The preset is a number. Nothing in this file names a colour, a law or a speed. +// +// THE NUMBERS: layout and assignment run once per scene cut and may divide by a variable (the cell +// size); the per-step and per-pixel paths are particle_ds's -- integer, Q8, table lookups. +// +// NOT CLAIMED: the font is ASCII (no Cyrillic); no voice or music (lesson_reel mode 5 has those); +// gen-verilog has not been run on this module and no board has drawn it. +// phi^2 + 1/phi^2 = 3 | TRINITY + +module idea_factory; + +use t27f::scene_raster::glyph_on; +use t27f::scene_raster::rnd; +use t27f::scene_raster::lcg; +use t27f::scene_raster::clamp; +use t27f::scene_raster::imin; +use t27f::scene_raster::imax; +use t27f::scene_raster::iabs; +use t27f::scene_raster::fmod; +use t27f::scene_raster::red; +use t27f::scene_raster::green; +use t27f::scene_raster::blue; +use t27f::particle_ds::ONE; +use t27f::particle_ds::BG; +use t27f::particle_ds::KEY_COL; +use t27f::particle_ds::GRAIN; +use t27f::particle_ds::CARD; +use t27f::particle_ds::TONE_N; +use t27f::particle_ds::live; +use t27f::particle_ds::law_ax; +use t27f::particle_ds::law_ay; +use t27f::particle_ds::step_v; +use t27f::particle_ds::kick_x; +use t27f::particle_ds::kick_y; +use t27f::particle_ds::start_x; +use t27f::particle_ds::start_y; +use t27f::particle_ds::start_vx; +use t27f::particle_ds::start_vy; +use t27f::particle_ds::particle_col; +use t27f::particle_ds::channel_of; +use t27f::particle_ds::reach; +use t27f::particle_ds::kern; +use t27f::particle_ds::tone; +use t27f::particle_ds::vignette; +use t27f::particle_ds::key_lift; +use t27f::particle_ds::finish; + +pub const ID : str = "idea-factory"; +pub const VERSION : u8 = 1; + +// ---- the factory's input: the lab recipe rewrites these two lines per render ---- +pub const IDEA : str = "PARTICLES BUILD EVERY FRAME|ONE SPEC MANY STYLES"; +pub const PRESET : usize = 0; +pub const CTA : str = "T27.AI"; + +// ---- the frame ---- +pub const FRAME_W : i32 = 1080; +pub const FRAME_H : i32 = 1920; +pub const FPS : i32 = 30; +pub const SCENE_F : i32 = 72; // 2.4 s a scene: the law's clock runs out at frame 43 +pub const STEPS : i32 = 2; // integrator steps a frame +pub const NP : i32 = 3072; // particles; main's arrays are this long +pub const CX : i32 = 540; // the text block's centre +pub const CY : i32 = 864; // 0.45 of the height, under the key light + +// ---- the wrap and the layout ---- +pub const MAXC : i32 = 9; // characters a line +pub const MAXL : i32 = 4; // lines a scene +pub const LINE_PITCH : i32 = 7; // font pixels from one line's top to the next's +pub const TEXT_W : i32 = 907; // 0.84 of the width +pub const TEXT_H : i32 = 768; // 0.40 of the height +pub const CELL_MAX : i32 = 40; // the largest font pixel, px +pub const MAXCELLS : i32 = 1024; // MAXL * MAXC * 15 = 540 is the most a scene can light + +// ---- wrap ---- + +// The k-th line of text t: what 0 = its first byte, 1 = its length in bytes, 2 = its scene; -1 when +// there is no such line. what 3 = the number of lines, what 4 = the number of scenes (k unused). +pub fn wrap_walk(t: []const u8, k: i32, what: i32) -> i32 { + const n = @as(i32, @intCast(t.len)); + var line : i32 = 0; + var scene : i32 = 0; + var in_scene : i32 = 0; + var ls : i32 = 0 - 1; + var le : i32 = 0; + var i : i32 = 0; + var done : bool = false; + while (!done) { + var flush : bool = false; + var cut : bool = false; + if (i >= n) { + flush = true; + done = true; + } else { + const c = @as(i32, t[@as(usize, @intCast(i))]); + if (c == 32) { + i = i + 1; + } else if (c == 124) { + flush = true; + cut = true; + i = i + 1; + } else { + var j : i32 = i; + while (j < n && j - i < MAXC && t[@as(usize, @intCast(j))] != 32 && t[@as(usize, @intCast(j))] != 124) { + j = j + 1; + } + if (ls >= 0 && j - ls > MAXC) { + flush = true; + } else { + if (ls < 0) { ls = i; } + le = j; + i = j; + } + } + } + if (flush && ls >= 0) { + if (line == k) { + if (what == 0) { return ls; } + if (what == 1) { return le - ls; } + if (what == 2) { return scene; } + } + line = line + 1; + in_scene = in_scene + 1; + ls = 0 - 1; + if (in_scene == MAXL) { + scene = scene + 1; + in_scene = 0; + } + } + if (cut && in_scene > 0) { + scene = scene + 1; + in_scene = 0; + } + } + if (what == 3) { return line; } + if (what == 4) { + if (in_scene > 0) { return scene + 1; } + return scene; + } + return 0 - 1; +} +pub fn line_count(t: []const u8) -> i32 { + return wrap_walk(t, 0, 3); +} +pub fn scene_count(t: []const u8) -> i32 { + return wrap_walk(t, 0, 4); +} + +// ---- layout ---- + +// Scene s of text t, set in the font: what 2 = the number of lit cells; 0, 1 = the c-th cell's +// centre x, y in px; 3, 4, 5, 6 = the text block's left, top, right, bottom; 7 = the cell size S. +// An empty scene has no cells and a zero block. +pub fn scene_cell(t: []const u8, s: i32, c: i32, what: i32) -> i32 { + const lines = line_count(t); + var nl : i32 = 0; + var maxlen : i32 = 0; + var first : i32 = 0 - 1; + var k : i32 = 0; + while (k < lines) { + if (wrap_walk(t, k, 2) == s) { + if (first < 0) { first = k; } + nl = nl + 1; + maxlen = imax(maxlen, wrap_walk(t, k, 1)); + } + k = k + 1; + } + if (nl == 0) { return 0; } + const cols = maxlen * 4 - 1; + const rows = nl * LINE_PITCH - 2; + const cs = imin(imin(@divTrunc(TEXT_W, cols), @divTrunc(TEXT_H, rows)), CELL_MAX); + const ox = CX - @divTrunc(cols * cs, 2); + const oy = CY - @divTrunc(rows * cs, 2); + if (what == 3) { return ox; } + if (what == 4) { return oy; } + if (what == 5) { return ox + cols * cs; } + if (what == 6) { return oy + rows * cs; } + if (what == 7) { return cs; } + var n : i32 = 0; + var li : i32 = 0; + while (li < nl) { + const st = wrap_walk(t, first + li, 0); + const ln = wrap_walk(t, first + li, 1); + const lox = CX - @divTrunc((ln * 4 - 1) * cs, 2); + var ci : i32 = 0; + while (ci < ln) { + const ch = @as(i32, t[@as(usize, @intCast(st + ci))]); + var gy : i32 = 0; + while (gy < 5) { + var gx : i32 = 0; + while (gx < 3) { + if (glyph_on(ch, gx, gy)) { + if (n == c) { + if (what == 0) { return lox + (ci * 4 + gx) * cs + @divTrunc(cs, 2); } + if (what == 1) { return oy + (li * LINE_PITCH + gy) * cs + @divTrunc(cs, 2); } + } + n = n + 1; + } + gx = gx + 1; + } + gy = gy + 1; + } + ci = ci + 1; + } + li = li + 1; + } + if (what == 2) { return n; } + return 0 - 1; +} + +// The reel's scene s: the idea's scenes, then the address. +pub fn reel_scenes(t: []const u8) -> i32 { + return scene_count(t) + 1; +} +pub fn reel_cell(t: []const u8, s: i32, c: i32, what: i32) -> i32 { + if (s < scene_count(t)) { return scene_cell(t, s, c, what); } + return scene_cell(CTA, 0, c, what); +} +pub fn reel_frames(t: []const u8) -> i32 { + return reel_scenes(t) * SCENE_F; +} +pub fn frame_bytes() -> i32 { + return FRAME_W * FRAME_H * 3; +} + +// ---- assign ---- + +// Where card particle m of mm sits on the border of the rectangle (x0, y0)-(x1, y1): what 0 = x, +// 1 = y. The particles are spread evenly along the perimeter, clockwise from the top left. +pub fn card_point(m: i32, mm: i32, x0: i32, y0: i32, x1: i32, y1: i32, what: i32) -> i32 { + const w = x1 - x0; + const h = y1 - y0; + const d = @divTrunc(m * 2 * (w + h), mm); + var x : i32 = x0; + var y : i32 = y0; + if (d < w) { + x = x0 + d; + } else if (d < w + h) { + x = x1; + y = y0 + d - w; + } else if (d < 2 * w + h) { + x = x1 - (d - w - h); + y = y1; + } else { + y = y1 - (d - 2 * w - h); + } + if (what == 0) { return x; } + return y; +} +// The offset of text particle i inside its cell of size cs, for the m-th particle on that cell: +// one of nine sub-slots, plus a seeded jitter of up to cs / 8 so a dense cell does not stack. +pub fn slot_off(i: i32, m: i32, c: i32, cs: i32, axis: i32) -> i32 { + const slot = fmod(m * 4 + c, 9); + var k : i32 = fmod(slot, 3); + if (axis == 1) { k = @divTrunc(slot, 3); } + const j = @divTrunc(cs, 4) + 1; + return (k - 1) * @divTrunc(cs, 3) + rnd(i * 17 + axis * 5 + m, j) - @divTrunc(j, 2); +} + +// ---- render ---- + +pub fn main() -> void { + var x : [3072]i32 = undefined; + var y : [3072]i32 = undefined; + var vx : [3072]i32 = undefined; + var vy : [3072]i32 = undefined; + var tx : [3072]i32 = undefined; + var ty : [3072]i32 = undefined; + var sx : [3072]i32 = undefined; + var sy : [3072]i32 = undefined; + var cr : [3072]i32 = undefined; + var cg : [3072]i32 = undefined; + var cb : [3072]i32 = undefined; + var cellx : [1024]i32 = undefined; + var celly : [1024]i32 = undefined; + var kt : [1024]i32 = undefined; + var tt : [1024]i32 = undefined; + var accr : [1080]i32 = undefined; + var accg : [1080]i32 = undefined; + var accb : [1080]i32 = undefined; + var row : [3240]u8 = undefined; + + const p = PRESET; + const rr = reach(p); + var d2 : i32 = 0; + while (d2 <= rr * rr) { + kt[@as(usize, @intCast(d2))] = kern(p, d2); + d2 = d2 + 1; + } + var tv : i32 = 0; + while (tv < TONE_N) { + tt[@as(usize, @intCast(tv))] = tone(tv); + tv = tv + 1; + } + const bgr = red(BG[p]); + const bgg = green(BG[p]); + const bgb = blue(BG[p]); + const kr = red(KEY_COL[p]); + const kg = green(KEY_COL[p]); + const kb = blue(KEY_COL[p]); + const gmax = GRAIN[p]; + const ncards = @divTrunc(NP, 4); + + const frames = reel_frames(IDEA); + var frame : i32 = 0; + while (frame < frames) { + const s = @divTrunc(frame, SCENE_F); + const fis = frame - s * SCENE_F; + + // ---- a cut: every particle gets a target in the new scene ---- + if (fis == 0) { + const nc = imin(reel_cell(IDEA, s, 0, 2), MAXCELLS); + var c : i32 = 0; + while (c < nc) { + cellx[@as(usize, @intCast(c))] = reel_cell(IDEA, s, c, 0); + celly[@as(usize, @intCast(c))] = reel_cell(IDEA, s, c, 1); + c = c + 1; + } + const cs = reel_cell(IDEA, s, 0, 7); + const bx0 = reel_cell(IDEA, s, 0, 3) - cs; + const by0 = reel_cell(IDEA, s, 0, 4) - cs; + const bx1 = reel_cell(IDEA, s, 0, 5) + cs; + const by1 = reel_cell(IDEA, s, 0, 6) + cs; + var ia : i32 = 0; + while (ia < NP) { + const u = @as(usize, @intCast(ia)); + var gx : i32 = 0; + var gy : i32 = 0; + if (CARD[p] == 1 && fmod(ia, 4) == 3 && nc > 0) { + gx = card_point(@divTrunc(ia, 4), ncards, bx0, by0, bx1, by1, 0); + gy = card_point(@divTrunc(ia, 4), ncards, bx0, by0, bx1, by1, 1); + } else if (nc > 0) { + const ce = fmod(ia, nc); + const m = @divTrunc(ia, nc); + gx = cellx[@as(usize, @intCast(ce))] + slot_off(ia, m, ce, cs, 0); + gy = celly[@as(usize, @intCast(ce))] + slot_off(ia, m, ce, cs, 1); + } else { + gx = rnd(ia * 5 + s, FRAME_W); + gy = rnd(ia * 7 + s, FRAME_H); + } + tx[u] = gx * ONE; + ty[u] = gy * ONE; + if (s == 0) { + x[u] = start_x(p, ia, gx, CX, FRAME_W) * ONE; + y[u] = start_y(p, ia, gy, CY, FRAME_H) * ONE; + vx[u] = start_vx(p, ia); + vy[u] = start_vy(p, ia); + } else { + const dx = clamp(x[u] - CX * ONE, 0 - 1200 * ONE, 1200 * ONE); + const dy = clamp(y[u] - CY * ONE, 0 - 1200 * ONE, 1200 * ONE); + const h = rnd(ia * 29 + s, 256); + vx[u] = vx[u] + kick_x(p, dx, dy, h); + vy[u] = vy[u] + kick_y(p, dx, dy, h); + } + ia = ia + 1; + } + } + + // ---- physics ---- + const l = live(fis); + var st : i32 = 0; + while (st < STEPS) { + var ib : i32 = 0; + while (ib < NP) { + const u = @as(usize, @intCast(ib)); + const dx = clamp(x[u] - CX * ONE, 0 - 1200 * ONE, 1200 * ONE); + const dy = clamp(y[u] - CY * ONE, 0 - 1200 * ONE, 1200 * ONE); + const h = rnd(ib * 31 + frame * 2 + st, 256); + vx[u] = step_v(p, x[u], vx[u], tx[u], law_ax(p, dx, dy, l, h)); + vy[u] = step_v(p, y[u], vy[u], ty[u], law_ay(p, dx, dy, l, h, ty[u] - y[u])); + x[u] = x[u] + vx[u]; + y[u] = y[u] + vy[u]; + ib = ib + 1; + } + st = st + 1; + } + + // ---- paint: each particle's pixel and colour ---- + var ic : i32 = 0; + while (ic < NP) { + const u = @as(usize, @intCast(ic)); + sx[u] = @divTrunc(x[u], ONE); + sy[u] = @divTrunc(y[u], ONE); + const col = particle_col(p, ic, iabs(vx[u]) + iabs(vy[u])); + cr[u] = channel_of(p, red(col), bgr); + cg[u] = channel_of(p, green(col), bgg); + cb[u] = channel_of(p, blue(col), bgb); + ic = ic + 1; + } + + // ---- raster: one row at a time ---- + var gseed : i32 = 1000003 + frame * 7919; + var py : i32 = 0; + while (py < FRAME_H) { + var q : i32 = 0; + while (q < FRAME_W) { + const k = @as(usize, @intCast(q)); + accr[k] = 0; + accg[k] = 0; + accb[k] = 0; + q = q + 1; + } + var j : i32 = 0; + while (j < NP) { + const u = @as(usize, @intCast(j)); + const dy = py - sy[u]; + if (dy <= rr && dy >= 0 - rr) { + q = imax(sx[u] - rr, 0); + const qe = imin(sx[u] + rr, FRAME_W - 1); + while (q <= qe) { + const dx = q - sx[u]; + const dd = dx * dx + dy * dy; + if (dd <= rr * rr) { + const w = kt[@as(usize, @intCast(dd))]; + const k = @as(usize, @intCast(q)); + accr[k] = accr[k] + w * cr[u]; + accg[k] = accg[k] + w * cg[u]; + accb[k] = accb[k] + w * cb[u]; + } + q = q + 1; + } + } + j = j + 1; + } + q = 0; + while (q < FRAME_W) { + const k = @as(usize, @intCast(q)); + const vig = vignette(p, q, py, FRAME_W, FRAME_H); + const lift = key_lift(p, q, py, FRAME_W, FRAME_H); + gseed = lcg(gseed); + const g = fmod(@divTrunc(gseed, 4096), 2 * gmax + 1) - gmax; + const lr = tt[@as(usize, @intCast(clamp(@divTrunc(accr[k], ONE), 0, TONE_N - 1)))]; + const lg = tt[@as(usize, @intCast(clamp(@divTrunc(accg[k], ONE), 0, TONE_N - 1)))]; + const lb = tt[@as(usize, @intCast(clamp(@divTrunc(accb[k], ONE), 0, TONE_N - 1)))]; + row[k * 3] = @as(u8, @intCast(finish(p, bgr, kr, lift, lr, vig, g))); + row[k * 3 + 1] = @as(u8, @intCast(finish(p, bgg, kg, lift, lg, vig, g))); + row[k * 3 + 2] = @as(u8, @intCast(finish(p, bgb, kb, lift, lb, vig, g))); + q = q + 1; + } + std.debug.print("{s}", .{row}); + py = py + 1; + } + frame = frame + 1; + } +} + +// ---- tests ---- + +test "the sample idea wraps into seven lines and two scenes, and the reel adds the address" { + assert(line_count(IDEA) == 7); + assert(scene_count(IDEA) == 2); + assert(reel_scenes(IDEA) == 3); + assert(wrap_walk(IDEA, 0, 1) == 9); // PARTICLES + assert(wrap_walk(IDEA, 3, 2) == 0); // FRAME closes scene 0 + assert(wrap_walk(IDEA, 4, 1) == 8); // ONE SPEC + assert(wrap_walk(IDEA, 4, 2) == 1); + assert(wrap_walk(IDEA, 7, 0) == 0 - 1); + assert(reel_frames(IDEA) == 216); +} + +test "a 40-character idea with no cut wraps into lines of at most 9 and scenes of at most 4" { + const t = "SPRINGS PULL EVERY DOT TO ITS LETTER NOW"; + assert(t.len == 40); + const n = line_count(t); + assert(n >= 5); + var k : i32 = 0; + var per : i32 = 0; + var last : i32 = 0; + while (k < n) { + assert(wrap_walk(t, k, 1) <= MAXC); + assert(wrap_walk(t, k, 1) > 0); + const s = wrap_walk(t, k, 2); + if (s == last) { per = per + 1; } else { per = 1; last = s; } + assert(per <= MAXL); + k = k + 1; + } + assert(scene_count(t) == 2); +} + +test "a word longer than a line is split, and empty cuts make no scene" { + assert(line_count("ABCDEFGHIJKLMNOPQRSTUVWXYZ") == 3); + assert(wrap_walk("ABCDEFGHIJKLMNOPQRSTUVWXYZ", 2, 1) == 8); + assert(scene_count("A||B") == 2); + assert(scene_count("") == 0); + assert(reel_scenes("") == 1); +} + +test "cells are the font's lit pixels: AA lights twice A, a space lights none" { + const a = scene_cell("A", 0, 0, 2); + assert(a > 0); + assert(scene_cell("AA", 0, 0, 2) == 2 * a); + assert(scene_cell("A A", 0, 0, 2) == 2 * a); + assert(scene_cell(" ", 0, 0, 2) == 0); + assert(scene_cell("a", 0, 0, 2) == a); +} + +test "every target of every scene lies inside the frame, and the card frames the text" { + var s : i32 = 0; + while (s < reel_scenes(IDEA)) { + const n = reel_cell(IDEA, s, 0, 2); + assert(n > 0); + assert(n <= MAXCELLS); + const cs = reel_cell(IDEA, s, 0, 7); + assert(cs >= 8 && cs <= CELL_MAX); + var c : i32 = 0; + while (c < n) { + const cx = reel_cell(IDEA, s, c, 0); + const cy = reel_cell(IDEA, s, c, 1); + assert(cx > reel_cell(IDEA, s, 0, 3) && cx < reel_cell(IDEA, s, 0, 5)); + assert(cy > reel_cell(IDEA, s, 0, 4) && cy < reel_cell(IDEA, s, 0, 6)); + c = c + 1; + } + assert(reel_cell(IDEA, s, 0, 3) - cs >= 0); + assert(reel_cell(IDEA, s, 0, 5) + cs < FRAME_W); + assert(reel_cell(IDEA, s, 0, 4) - cs >= 0); + assert(reel_cell(IDEA, s, 0, 6) + cs < FRAME_H); + s = s + 1; + } +} + +test "the card's particles walk the whole border once" { + assert(card_point(0, 100, 10, 20, 110, 70, 0) == 10); + assert(card_point(0, 100, 10, 20, 110, 70, 1) == 20); + assert(card_point(25, 100, 10, 20, 110, 70, 0) == 85); + assert(card_point(50, 100, 10, 20, 110, 70, 0) == 110); + assert(card_point(50, 100, 10, 20, 110, 70, 1) == 70); + assert(card_point(99, 100, 10, 20, 110, 70, 0) == 10); +} + +test "sub-slots stay inside their cell" { + var i : i32 = 0; + while (i < 200) { + const ox = slot_off(i, fmod(i, 13), fmod(i, 7), 25, 0); + const oy = slot_off(i, fmod(i, 13), fmod(i, 7), 25, 1); + assert(iabs(ox) <= 12); + assert(iabs(oy) <= 12); + i = i + 1; + } +} + +test "a frame is 1080x1920 RGB24, 6220800 bytes" { + assert(frame_bytes() == 6220800); + assert(FPS == 30); +} diff --git a/specs/t27f/particle_ds.t27 b/specs/t27f/particle_ds.t27 new file mode 100644 index 0000000000..890e47375c --- /dev/null +++ b/specs/t27f/particle_ds.t27 @@ -0,0 +1,419 @@ +// SPDX-License-Identifier: Apache-2.0 +// specs/t27f/particle_ds.t27 -- the particle design system of the OS: every visible element is a +// particle on a spring, and a style is a row of integers. Issue gHashTag/t27#10771, epic #10560. +// +// WHY: the owner, 2026-10-19: one content factory, on the factory pattern, that unpacks any idea in +// any style preset; the factory and every element are formed by particle physics; and in the end +// this behaviour runs inside the OS itself, on the FPGA too -- it is the visual design system of +// the OS. Until now each reel (token_floor, reel_particles, lesson_reel) carried its own constants +// in f64, so a style could not be swapped and nothing could move to hardware. +// +// THE DESIGN SYSTEM, in three parts: +// tokens PRESETS rows of integers: three colours, a blend, a particle radius and gain, a +// spring, a dashpot, a force law with its strength, a transition kick, a card flag and +// a colour rule. A new style is a new column in these tables, never new code. +// physics one integrator step for one particle, per axis: a clamped linear spring toward its +// target plus a dashpot (semi-implicit Euler: v += a, then x += v), plus the preset's +// law -- gravity (drop, rain), a swirl (vortex), a seeded jitter (drift) -- that fades +// with the scene's `live` clock, and a kick when the scene cuts. +// paint a particle is a soft disc w = GAIN r^4 / (r^2 + d^2)^2, summed per pixel and rolled +// off by a tone curve; an ink preset subtracts from a light page instead of adding light. +// +// THE NUMBERS: positions and velocities are Q8 fixed point (256 = one pixel, one pixel per integrator +// step), colours are 0xRRGGBB in an i32, angles are a 16-step table. The per-step and per-pixel paths +// use only add, multiply, compare, table lookup and division by a power of two (q8, q16): no float, +// no square root, no division by a variable, so the same law can follow the FPGA path. kern() and +// tone() divide, but only to fill a ROM once (idea_factory builds the tables before the first frame). +// +// NOT CLAIMED: the spring rates, laws and colours are design choices, labelled as such; no constant +// here is a physical measurement. This module has not been run through gen-verilog yet, and no +// board has drawn it; it is written so that it can be. +// phi^2 + 1/phi^2 = 3 | TRINITY + +module particle_ds; + +use t27f::scene_raster::rgb; +use t27f::scene_raster::red; +use t27f::scene_raster::green; +use t27f::scene_raster::blue; +use t27f::scene_raster::clamp; +use t27f::scene_raster::iabs; +use t27f::scene_raster::rnd; +use t27f::scene_raster::fmod; + +pub const ID : str = "particle-ds"; +pub const VERSION : u8 = 1; + +// ---- fixed point ---- +pub const ONE : i32 = 256; // one pixel, Q8 +pub const AMAX : i32 = 768; // the largest acceleration, 3 px per step^2 +pub const VMAX : i32 = 12288; // the largest speed, 48 px per step +pub const LIVE_STEP : i32 = 6; // the law's clock falls 6/256 a frame, gone after 43 frames + +// ---- the presets (design tokens) ---- +pub const PRESETS : usize = 5; +pub const P_NOIR : usize = 0; // gold on near-black, letters fall into place +pub const P_NEON : usize = 1; // cyan and magenta on blue-black, a vortex winds them in +pub const P_TRIT : usize = 2; // -1, 0, +1 charges burst from the centre +pub const P_PAPER : usize = 3; // ink on a cream page, drifting like dust +pub const P_RAIN : usize = 4; // green rain falls and holds as letters +pub const PRESET_NAMES : str = "noir-gold,neon-vortex,trit-charge,paper-ink,rain"; + +pub const LAW_DROP : i32 = 0; +pub const LAW_VORTEX : i32 = 1; +pub const LAW_BURST : i32 = 2; +pub const LAW_DRIFT : i32 = 3; +pub const LAW_RAIN : i32 = 4; + +pub const BLEND_ADD : i32 = 0; // light adds on a dark field +pub const BLEND_INK : i32 = 1; // ink subtracts from a light page + +pub const MIX_HEAT : i32 = 0; // colour from A to B as the particle speeds up +pub const MIX_TRIT : i32 = 1; // colour from the particle's trit: A = -1, B = 0, C = +1 + +// noir neon trit paper rain +pub const BG : [5]i32 = [0x0A0A0A, 0x06060F, 0x080808, 0xECE6DA, 0x000602]; +pub const COL_A : [5]i32 = [0xFFC45C, 0x00E5FF, 0xFF5A5A, 0x18161C, 0x00FF66]; +pub const COL_B : [5]i32 = [0xFFF1DC, 0xFF2BD6, 0xEBEBEB, 0xC41E28, 0xBEFFD2]; +pub const COL_C : [5]i32 = [0x8C5A1E, 0x7850FF, 0x50A0FF, 0x5A5650, 0x008C3C]; +pub const KEY_COL : [5]i32 = [0xFFF1DC, 0x7850FF, 0xEBEBEB, 0xFFFFFF, 0x00FF66]; +pub const KEY_LIFT : [5]i32 = [18, 14, 10, 8, 10]; // levels the key light adds at its centre +pub const VIGN : [5]i32 = [20, 22, 18, 8, 22]; // how much the corners darken +pub const GRAIN : [5]i32 = [3, 3, 3, 4, 4]; // levels, peak to peak +pub const BLEND : [5]i32 = [0, 0, 0, 1, 0]; +pub const MIX : [5]i32 = [0, 0, 1, 0, 0]; +pub const RADIUS : [5]i32 = [5, 5, 6, 4, 4]; // px +pub const GAIN : [5]i32 = [200, 210, 190, 230, 190]; // a lone particle's peak, of 256 +pub const SPRING : [5]i32 = [5, 4, 6, 3, 7]; // Q8 per step^2 per Q8 of distance +pub const DAMP : [5]i32 = [46, 42, 51, 42, 55]; // Q8 per step: damping ratio 0.65-0.75 +pub const LAW : [5]i32 = [0, 1, 2, 3, 4]; +pub const FORCE : [5]i32 = [300, 50, 0, 200, 500]; // the law's strength, see law_ax/law_ay +pub const KICK : [5]i32 = [2600, 3000, 220, 1600, 1200]; +pub const CARD : [5]i32 = [1, 1, 0, 1, 0]; // a card border of particles behind the text + +// cos of k * 2 pi / 16, Q8; sin(k) = COS16[(k + 12) % 16]. +pub const COS16 : [16]i32 = [256, 237, 181, 98, 0, -98, -181, -237, -256, -237, -181, -98, 0, 98, 181, 237]; + +// ---- integer helpers ---- + +// Division by 256 and by 65536, rounding toward zero (a shift and a sign fix in hardware). +pub fn q8(a: i32) -> i32 { + return @divTrunc(a, 256); +} +pub fn q16(a: i32) -> i32 { + return @divTrunc(a, 65536); +} +pub fn dir_cos(k: i32) -> i32 { + return COS16[@as(usize, @intCast(fmod(k, 16)))]; +} +pub fn dir_sin(k: i32) -> i32 { + return COS16[@as(usize, @intCast(fmod(k + 12, 16)))]; +} + +// ---- physics: one step of one particle ---- + +// The scene's law clock, of 256: full when the scene cuts, gone LIVE_STEP frames later per level. +pub fn live(frame_in_scene: i32) -> i32 { + return clamp(ONE - LIVE_STEP * frame_in_scene, 0, ONE); +} + +// The spring toward t and the dashpot on v, Q8 px per step^2, clamped to AMAX. +pub fn spring_a(p: usize, x: i32, v: i32, t: i32) -> i32 { + const a = q8(SPRING[p] * (t - x)) - q8(DAMP[p] * v); + return clamp(a, 0 - AMAX, AMAX); +} + +// The preset's law along x and y. dx, dy: the particle from the scene's centre, Q8 px; h: a seeded +// byte 0..255 for this particle and step; l: the law clock; gap: the target below the particle, Q8 +// (gravity pulls only a particle still above its place, so a settled letter does not sag). +pub fn law_ax(p: usize, dx: i32, dy: i32, l: i32, h: i32) -> i32 { + const law = LAW[p]; + if (law == LAW_VORTEX) { return q8(q16(0 - dy * FORCE[p]) * l); } + if (law == LAW_DRIFT) { return q16((h - 128) * FORCE[p] * l); } + return 0; +} +pub fn law_ay(p: usize, dx: i32, dy: i32, l: i32, h: i32, gap: i32) -> i32 { + const law = LAW[p]; + if (law == LAW_DROP || law == LAW_RAIN) { + if (gap > 4 * ONE) { return q8(FORCE[p] * l); } + return 0; + } + if (law == LAW_VORTEX) { return q8(q16(dx * FORCE[p]) * l); } + if (law == LAW_DRIFT) { return q16((128 - h) * FORCE[p] * l); } + return 0; +} + +// One step of one axis: the new velocity (semi-implicit Euler; the caller then adds it to x). +pub fn step_v(p: usize, x: i32, v: i32, t: i32, law_a: i32) -> i32 { + return clamp(v + spring_a(p, x, v, t) + law_a, 0 - VMAX, VMAX); +} + +// The kick when a scene cuts, per axis: a hop (drop), a turn (vortex), a blast out of the centre +// (burst), a shove (drift), a push down (rain). +pub fn kick_x(p: usize, dx: i32, dy: i32, h: i32) -> i32 { + const law = LAW[p]; + if (law == LAW_VORTEX) { return q16(0 - dy * KICK[p]); } + if (law == LAW_BURST) { return @divTrunc(q8(dx * KICK[p]), 16); } + if (law == LAW_DRIFT) { return q8((h - 128) * KICK[p]); } + return 0; +} +pub fn kick_y(p: usize, dx: i32, dy: i32, h: i32) -> i32 { + const law = LAW[p]; + if (law == LAW_DROP) { return 0 - KICK[p]; } + if (law == LAW_VORTEX) { return q16(dx * KICK[p]); } + if (law == LAW_BURST) { return @divTrunc(q8(dy * KICK[p]), 16); } + if (law == LAW_DRIFT) { return q8((128 - h) * KICK[p]); } + if (law == LAW_RAIN) { return KICK[p]; } + return 0; +} + +// Where particle i starts, in px, given its first target (tx, ty) and the frame's centre (cx, cy). +pub fn start_x(p: usize, i: i32, tx: i32, cx: i32, w: i32) -> i32 { + const law = LAW[p]; + if (law == LAW_VORTEX) { return cx + q8(dir_cos(i) * (300 + rnd(i * 3 + 1, 420))); } + if (law == LAW_BURST) { return cx + q8(dir_cos(i) * rnd(i * 3 + 1, 40)); } + if (law == LAW_DRIFT) { return rnd(i * 5 + 2, w); } + return tx + rnd(i * 7 + 3, 9) - 4; +} +pub fn start_y(p: usize, i: i32, ty: i32, cy: i32, h: i32) -> i32 { + const law = LAW[p]; + if (law == LAW_VORTEX) { return cy + q8(dir_sin(i) * (300 + rnd(i * 3 + 1, 420))); } + if (law == LAW_BURST) { return cy + q8(dir_sin(i) * rnd(i * 3 + 1, 40)); } + if (law == LAW_DRIFT) { return rnd(i * 5 + 4, h); } + if (law == LAW_RAIN) { return 0 - 40 - rnd(i * 11 + 5, 2400); } + return 0 - 200 - rnd(i * 11 + 5, 1100); +} +// A burst starts already flying outward. +pub fn start_vx(p: usize, i: i32) -> i32 { + if (LAW[p] == LAW_BURST) { return q8(dir_cos(i) * (KICK[p] * 20 + rnd(i * 13 + 6, 2000))); } + return 0; +} +pub fn start_vy(p: usize, i: i32) -> i32 { + if (LAW[p] == LAW_BURST) { return q8(dir_sin(i) * (KICK[p] * 20 + rnd(i * 13 + 6, 2000))); } + return 0; +} + +// ---- paint ---- + +// The colour of particle i at speed s (|vx| + |vy|, Q8). +pub fn particle_col(p: usize, i: i32, s: i32) -> i32 { + if (MIX[p] == MIX_TRIT) { + const t = fmod(i, 3); + if (t == 0) { return COL_A[p]; } + if (t == 1) { return COL_B[p]; } + return COL_C[p]; + } + if (fmod(i, 7) == 0) { return COL_C[p]; } + const k = clamp(q8(s * 64), 0, 256); + const r = red(COL_A[p]) + q8((red(COL_B[p]) - red(COL_A[p])) * k); + const g = green(COL_A[p]) + q8((green(COL_B[p]) - green(COL_A[p])) * k); + const b = blue(COL_A[p]) + q8((blue(COL_B[p]) - blue(COL_A[p])) * k); + return rgb(r, g, b); +} + +// What one channel of a particle adds to the pixel sum: light for ADD, the ink's depth for INK. +pub fn channel_of(p: usize, col_ch: i32, bg_ch: i32) -> i32 { + if (BLEND[p] == BLEND_INK) { return bg_ch - col_ch; } + return col_ch; +} + +// The kernel's reach: past 3 r a particle adds under GAIN / 100. +pub fn reach(p: usize) -> i32 { + return 3 * RADIUS[p]; +} +// The kernel at squared distance d2 (px^2), of 256: GAIN r^4 / (r^2 + d2)^2. Fills a ROM once. +pub fn kern(p: usize, d2: i32) -> i32 { + const r2 = RADIUS[p] * RADIUS[p]; + const q = r2 + d2; + return @divTrunc(GAIN[p] * r2 * r2, q * q); +} +// The tone curve: the identity up to 200, then a roll-off toward 255. Fills a ROM once. +pub fn tone(x: i32) -> i32 { + if (x <= 200) { return x; } + const e = x - 200; + return 200 + @divTrunc(55 * e, e + 55); +} +pub const TONE_N : i32 = 1024; + +// The darkening toward the corners, of 256, at (x, y) in px on a w x h frame. +pub fn vignette(p: usize, x: i32, y: i32, w: i32, h: i32) -> i32 { + const dx = x - @divTrunc(w, 2); + const dy = y - @divTrunc(h, 2); + const d = @divTrunc(q8(dx * dx + dy * dy), 16); + return ONE - q8(clamp(d, 0, 300) * VIGN[p]) * 3; +} +// The key light's lift at (x, y): KEY_LIFT at its centre (w / 2, 0.42 h), gone 700 px away. +pub fn key_lift(p: usize, x: i32, y: i32, w: i32, h: i32) -> i32 { + const dx = x - @divTrunc(w, 2); + const dy = y - q8(h * 108); + const d = q16(dx * dx + dy * dy); + return clamp(KEY_LIFT[p] - q8(d * KEY_LIFT[p] * 34), 0, KEY_LIFT[p]); +} +// The final level of one channel: background, key light, the toned particle sum t, vignette, grain. +pub fn finish(p: usize, bg_ch: i32, key_ch: i32, lift: i32, t: i32, vig: i32, grain: i32) -> i32 { + var v : i32 = 0; + if (BLEND[p] == BLEND_INK) { + v = bg_ch - t; + } else { + v = bg_ch + q8(key_ch * lift) + t; + } + return clamp(q8(v * vig) + grain, 0, 255); +} + +// ---- tests ---- + +test "five presets, five names, and every table has a row for each" { + var commas : i32 = 0; + var k : usize = 0; + while (k < PRESET_NAMES.len) { + if (PRESET_NAMES[k] == 44) { commas = commas + 1; } + k = k + 1; + } + assert(commas + 1 == 5); + assert(PRESETS == 5); + assert(LAW[P_NOIR] == LAW_DROP); + assert(LAW[P_NEON] == LAW_VORTEX); + assert(LAW[P_TRIT] == LAW_BURST); + assert(LAW[P_PAPER] == LAW_DRIFT); + assert(LAW[P_RAIN] == LAW_RAIN); + assert(BLEND[P_PAPER] == BLEND_INK); + assert(MIX[P_TRIT] == MIX_TRIT); +} + +test "no two presets share a palette" { + var a : usize = 0; + while (a < PRESETS) { + var b : usize = a + 1; + while (b < PRESETS) { + assert(BG[a] != BG[b] || COL_A[a] != COL_A[b]); + b = b + 1; + } + a = a + 1; + } +} + +test "q8 rounds toward zero on both sides, so a dashpot never drifts one way" { + assert(q8(255) == 0); + assert(q8(0 - 255) == 0); + assert(q8(256) == 1); + assert(q8(0 - 256) == 0 - 1); + assert(q16(0 - 65535) == 0); +} + +test "the direction table is a circle: cos^2 + sin^2 is 256^2 within 1%" { + var k : i32 = 0; + while (k < 16) { + const c = dir_cos(k); + const s = dir_sin(k); + const n = c * c + s * s; + assert(n > 64880 && n < 66190); + k = k + 1; + } + assert(dir_sin(4) == 256); + assert(dir_cos(8) == 0 - 256); +} + +test "every preset's spring settles a particle 1000 px away inside one 72-frame scene, overshooting under 120 px" { + var p : usize = 0; + while (p < PRESETS) { + var x : i32 = 0; + var v : i32 = 0; + const t : i32 = 1000 * ONE; + var over : i32 = 0; + var st : i32 = 0; + while (st < 144) { + v = step_v(p, x, v, t, 0); + x = x + v; + if (x - t > over) { over = x - t; } + st = st + 1; + } + assert(iabs(x - t) < 2 * ONE); + assert(iabs(v) < ONE); + assert(over < 120 * ONE); + p = p + 1; + } +} + +test "the law clock is full at the cut and gone before the next" { + assert(live(0) == 256); + assert(live(20) == 136); + assert(live(43) == 0); + assert(live(71) == 0); +} + +test "laws push the way they say" { + assert(law_ay(P_NOIR, 0, 0, 256, 0, 1000 * ONE) == 300); + assert(law_ay(P_RAIN, 0, 0, 256, 0, 1000 * ONE) > law_ay(P_NOIR, 0, 0, 256, 0, 1000 * ONE)); + assert(law_ax(P_NOIR, 1000, 1000, 256, 77) == 0); + assert(law_ay(P_NOIR, 0, 0, 0, 0, 1000 * ONE) == 0); + // the vortex turns: a particle right of centre is pushed down, one below it is pushed left + assert(law_ay(P_NEON, 400 * ONE, 0, 256, 0, 1000 * ONE) > 0); + assert(law_ax(P_NEON, 0, 400 * ONE, 256, 0) < 0); + // a burst kicks outward + assert(kick_x(P_TRIT, 100 * ONE, 0, 0) > 0); + assert(kick_y(P_TRIT, 0, 0 - 100 * ONE, 0) < 0); + assert(kick_y(P_NOIR, 0, 0, 0) < 0); + assert(kick_y(P_RAIN, 0, 0, 0) > 0); +} + +test "starts: rain and drop above the frame, burst at the centre, drift inside it" { + var i : i32 = 0; + while (i < 64) { + assert(start_y(P_NOIR, i, 900, 960, 1920) < 0); + assert(start_y(P_RAIN, i, 900, 960, 1920) < 0); + assert(iabs(start_x(P_TRIT, i, 0, 540, 1080) - 540) <= 40); + const dx = start_x(P_PAPER, i, 0, 540, 1080); + assert(dx >= 0 && dx < 1080); + assert(iabs(start_vx(P_TRIT, i)) + iabs(start_vy(P_TRIT, i)) > 0); + assert(start_vx(P_NOIR, i) == 0); + i = i + 1; + } +} + +test "colours: a still particle is colour A, a fast one is colour B, a trit picks its own" { + assert(particle_col(P_NOIR, 1, 0) == COL_A[P_NOIR]); + assert(particle_col(P_NOIR, 1, 4096) == COL_B[P_NOIR]); + assert(particle_col(P_NOIR, 7, 0) == COL_C[P_NOIR]); + assert(particle_col(P_TRIT, 0, 9999) == COL_A[P_TRIT]); + assert(particle_col(P_TRIT, 1, 0) == COL_B[P_TRIT]); + assert(particle_col(P_TRIT, 2, 0) == COL_C[P_TRIT]); + assert(channel_of(P_PAPER, 24, 236) == 212); + assert(channel_of(P_NOIR, 196, 10) == 196); +} + +test "the kernel peaks at GAIN, falls with distance, and is under 1% of it at its reach" { + var p : usize = 0; + while (p < PRESETS) { + assert(kern(p, 0) == GAIN[p]); + var d2 : i32 = 1; + var last : i32 = kern(p, 0); + while (d2 <= reach(p) * reach(p)) { + const k = kern(p, d2); + assert(k <= last); + last = k; + d2 = d2 + 1; + } + assert(last * 100 <= GAIN[p] + 100); + p = p + 1; + } +} + +test "tone keeps the dark levels and never clips past 255" { + assert(tone(0) == 0); + assert(tone(200) == 200); + assert(tone(255) == 227); + assert(tone(TONE_N - 1) <= 255); + assert(tone(TONE_N - 1) >= 250); +} + +test "finish: the dark field stays near-black at the corners, the page stays cream" { + const vc = vignette(P_NOIR, 0, 0, 1080, 1920); + assert(vc < 256 && vc > 150); + assert(vignette(P_NOIR, 540, 960, 1080, 1920) == 256); + assert(key_lift(P_NOIR, 540, 806, 1080, 1920) == KEY_LIFT[P_NOIR]); + assert(key_lift(P_NOIR, 540, 1900, 1080, 1920) == 0); + assert(finish(P_NOIR, 10, 255, 0, 0, 256, 0) == 10); + assert(finish(P_PAPER, 236, 255, 0, 0, 256, 0) == 236); + assert(finish(P_PAPER, 236, 255, 0, 212, 256, 0) == 24); + assert(finish(P_NOIR, 10, 255, 0, 400, 256, 0) == 255); +} From 2c619fde88f29f859cb3c2d25837775cafa65f96 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 09:29:12 +0700 Subject: [PATCH 2/2] t27f skill: idea factory and the particle design system, preset sweep on the lab, legibility by luma (Refs #10771) Co-Authored-By: Claude Opus 5.5 --- .claude/skills/t27f/SKILL.md | 46 ++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/.claude/skills/t27f/SKILL.md b/.claude/skills/t27f/SKILL.md index d1f16ef389..b2475da521 100644 --- a/.claude/skills/t27f/SKILL.md +++ b/.claude/skills/t27f/SKILL.md @@ -396,6 +396,52 @@ Measured at 355d44a39: each still has 9 to 14 flat colours, and the PNGs are 4 t `foreign-budget` hook on master's own Rust lines (t27#10414). Merge the old branch into a master-based branch instead. The result still fast-forwards the old branch. +## Application: idea factory, the OS particle design system (t27#10771) + +The owner wants one factory that unpacks any idea in any style preset, with every element made +of particles, and runnable in the OS itself and on FPGA. Two specs do this: + +- **`specs/t27f/particle_ds.t27` is the design system.** A preset is a column in integer tables: + `BG`, `COL_A/B/C`, `RADIUS`, `GAIN`, `SPRING`, `DAMP`, `LAW`, `FORCE`, `KICK`, `CARD`. The five + are noir, neon, trit, paper and rain. **Add a preset by adding a column, never a branch in code.** +- **`specs/t27f/idea_factory.t27` is the factory**, `(IDEA, PRESET) -> RGB24 frames`. It wraps + (at most 9 characters per line, 4 lines per scene, `|` cuts a scene), lays out in the 3x5 font, + assigns particles to glyph sub-slots and the card border, and closes on `CTA`. + +**Keep it FPGA-shaped.** Use Q8 fixed point. The per-step and per-pixel paths use only add, +multiply, compare, table lookup (`kt` kernel ROM, `tt` tone ROM) and power-of-two division. +Divide by a variable at most once per scene cut, never per pixel. The seals then carry +`gen_hash_verilog`. That proves Verilog is emitted, not that it was synthesised. Say so. + +**Render the preset sweep on the lab:** + +1. For each preset, `sed` the `PRESET` constant. Also rename the module (for example + `idea_factory_p`), so the gen output does not overwrite the committed module's. +2. Run `t27c gen`, then `zig build-exe -O ReleaseFast`. +3. Run `./f 2>&1 1>fo.txt | ffmpeg -f rawvideo -pix_fmt rgb24 -s 1080x1920 -r 30 -i - ...` + (libx264, crf 19). The frames go to stderr. Send stdout to a file, **never to `/dev/null`**: + the lab replays `/dev/null` writes. +4. Concatenate the five, scale to 720 and make a tile sheet. Fetch them as `B64:...:END` and + check sha256 on both ends. + +Measured: each preset takes 14 to 35 s for 216 frames, with 3072 particles. + +**Check legibility by numbers, because the Mac cannot read images.** Crop the settled frame at +t = 2.2 s with ffmpeg and read `signalstats` YAVG for three regions: the text block, a gap row +between lines, and the empty bottom. On a dark preset you want block > gap > bottom (rain: +44.8 > 25.4 > 19.0). On paper the order flips (block 187 < gap 211). A flat triple means the +particles never settled into letters. + +**Traps:** + +- **Read the `test-report` tail, not a grep.** A grep for pass and fail counts miscounts the + summary lines (`tests N`, `pass N`, `FAIL 0`, `vacuous passes 0 of N`). +- **Rename loop variables by line range.** A rename that only changes the declaration leaves the + body pointing at the old name. Give each top-level loop in `main` its own name (`ia`, `ib`, + `ic`). +- **Base the branch on master's tip before the PR.** If the base PR (R3) merged first, reset the branch onto + `origin/master`. Untracked new specs survive the reset. + ## Check the whole corpus for import cycles (t27#10693) dependency-cruiser's `no-circular` and madge's `--circular` list every module on a cycle.