#pragma once // --------------------------------------------------------------------------- // Fibonacci affine image scrambling (RadiantWave scroller text obfuscation) // // This module implements the Fibonacci affine transformation as a reversible // image permutation. The RadiantWave scroller uses it to render proclamation // text as scrambled glyphs: every code point's glyph is permuted by the map // below so letterforms are destroyed and the text is never readable on screen, // while the exact inverse recovers the original glyph given the same key. // // --------------------------------------------------------------------------- // [Published: Williams 2007] -- concepts from the published research // // Ben Williams, "Some properties of digital image scrambling methods using // Fibonacci numbers", MATH491 Summer Research Project 2006-2007, Department // of Mathematics and Statistics, University of Canterbury, 2007 (mirrored in // this repository as algorithm.md). // // Mathematical foundations: // - The Fibonacci matrix (Definition 2.2) is // F = [ 0 1 ] // [ 1 1 ] // so consecutive Fibonacci numbers satisfy u(n+1) = F.u(n). It squares to // the Arnold matrix F^2 = [[1,1],[1,2]] (Remark 3). // - The Fibonacci affine transformation (Definition 4.1) acts on the square // grid Z_m^2 = {0..m-1}^2 with modular arithmetic (Section 3): // T(x) = F.x + b (mod m), b in Z_m^2. // - An affine map x -> A.x + b is a bijection of Z_m^2 iff gcd(det A, m) = 1 // (Theorem 3.1 / Corollary 3.2), and it is periodic under the same // condition (Theorem 3.4). // - det F = -1, which is coprime to every m. By Proposition 4.1 the // Fibonacci affine transformation is therefore a permutation of Z_m^2 for // EVERY modulus m: no constraint whatsoever on the grid size. // - The paper decrypts by re-applying T until the period recurs. Section 5 // notes the period of F mod m can reach 6m, so that route is slow; the // paper never uses the inverse transform. // // --------------------------------------------------------------------------- // [Adapted beyond the paper] -- generalization from algorithm.md's appendix // (lines 491-500) // // Two extensions turn the published square-only transform into an // arbitrary-size scrambler with a fast, exact round trip: // // 1. Arbitrary image sizes. Any W x H image is embedded in a square canvas // of side // N = canvasSize(W, H) = max(W, H) // and T acts on Z_N^2. Because det F = -1 is invertible for every N, no // Fibonacci-number, power-of-two, or other size constraint applies. (The // size restriction in the paper was specific to Zou's scaled transform, // Definition 4.3, not to the plain Fibonacci affine map.) // // 2. Exact inverse instead of period iteration. Since // F^-1 = [ -1 1 ] // [ 1 0 ] // exists for every modulus, decryption applies the inverse directly and // costs exactly as many rounds as encryption -- not up to 6N applications // of T. This also turns the key into (round count, offset sequence): // scramble applies the per-round offsets b1..bt in order, and unscramble // applies the inverse in reverse order. // // --------------------------------------------------------------------------- // [This implementation] -- project-specific engineering decisions // // Gather-form permutation. Each round is a single gather pass: // scramble: dst[y] = src[preimage(y)], preimage(y) = T^-1(y) // unscramble: dst[y] = src[image(y)], image(y) = T(y) // Every output pixel is produced from exactly one input pixel, so there are // never holes or collisions, and the padding convention is filled directly // where a preimage falls outside the source. (The paper describes the // forward scatter -- source pixel at x moves to T(x); gather is the // equivalent output-directed formulation.) // // Composition semantics. Scrambling applies the inverse maps in round order // (T1^-1, T2^-1, ..., Tt^-1); unscrambling applies the forward maps in // reverse order (Tt, ..., T1). The composition telescopes: // T1^-1( T2^-1( ... Tt^-1( Tt( ... T2( T1(y) ) ... ) ) ... ) ) = y // so the round trip is exact in exactly `rounds` passes, regardless of the // transform's period. Padding is created in round 1 and then permuted like // any other pixel; because the composition is a bijection, each W x H output // position of the final reverse pass maps to a canvas position holding a real // source pixel, so no padding value ever reaches the recovered W x H region. // (Intermediate reverse passes do read padding pixels -- they move the full // N x N canvas -- but they are routed out of the W x H output.) // // Key derivation. The key is the (seed, rounds) pair. Each round derives its // offset vector b = (b0, b1) from `seed` via splitmix64 -- two consecutive // pure-integer draws reduced mod N: // b0 = splitmix64(seed) mod N, b1 = splitmix64(seed) mod N. // Pure integer arithmetic makes the offsets bit-identical on every platform, // so a compile-time seed produces identical scrambling everywhere. The // scroller fixes the seed at compile time (kDefaultSeed), meaning the same // glyph scrambles identically on every device and every run, and no key // state is ever persisted or transmitted. // // Padding convention. During scramble the W x H source sits in the natural // top-left embedding of the N x N canvas. Canvas positions whose preimage // falls outside [0, W) x [0, H) are written as transparent black // (0, 0, 0, 0), which is invisible on a black background. // Because the map is a bijection, the W x H source pixels land on W x H // distinct canvas positions, and the remaining N^2 - W*H positions are // padding. unscramble() reads the whole N x N canvas and writes only the // W x H region, reproducing the source exactly. Display uses the matching // crop-window convention: render the top-left W x H sub-rectangle of the // scrambled canvas. // // Whole pixels. Each RGBA8888 pixel, alpha included, is permuted as a unit, // so a round trip preserves alpha exactly. // // In-place hazard / scratch. A gather pass is not safe in place: within one // pass, dst[y] = src[p(y)] reads source position p(y) after an earlier write // at that same position may have replaced it (whenever p(y) < y in iteration // order). Every pass therefore reads and writes distinct buffers. Two // ping-pong scratch buffers of N*N*4 bytes are reused across rounds, so read // and write never alias while memory stays bounded. // // Numerics. All coordinate products are accumulated in int64_t and reduced // mod N, which is exact for any N whose squares fit comfortably in int64_t // (N <= 2^30). Rounds below 1 are clamped to 1; N == 1 (a 1x1 image) yields // the identity map, which is correct. In practice N is the side of a glyph // tile, at most a few hundred pixels. // // Format & portability. The module is SDL-free and pure C++17: it operates // on tightly packed RGBA8888 pixels (4 bytes per pixel, row stride = width * // 4) and keeps no state between calls. Any caller can adopt it; the scroller // uses it per glyph (see scroller_page.cpp). // // Why CPU. Glyph tiles are tiny and every distinct code point's tile is // scrambled at most once per font size and then cached, so the total cost is // a one-time, amortized O(N^2 * rounds) integer permute per character. A GPU // path would need per-texture compute passes or shader support that buys // nothing here. // --------------------------------------------------------------------------- #include namespace radiantwave { namespace scramble { // Fixed compile-time seed so every device renders the same glyph scrambling. // The value is splitmix64's golden-ratio constant; any constant would do, the // requirement is only that it never changes across runs and platforms. inline constexpr uint64_t kDefaultSeed = 0x9E3779B97F4A7C15ULL; // Default round count. Each round applies the affine map once; more rounds mix // pixels across a larger fraction of the canvas. Values below 1 are clamped to // 1 by the API. inline constexpr int kDefaultRounds = 4; // Per-call configuration: how many rounds and which key (seed). struct ScrambleConfig { int rounds = kDefaultRounds; uint64_t seed = kDefaultSeed; }; // Square canvas side N for a W x H image: N = max(W, H). // Returns 0 when width or height is < 1 (the scramble/unscramble entry points // reject N == 0). int canvasSize(int width, int height); // Scramble a W x H RGBA8888 image (tightly packed, stride = width * 4) into // an N x N RGBA8888 canvas where N = canvasSize(width, height). `dst` must // hold at least N*N*4 bytes. Out-of-canvas source coordinates are written as // transparent black per the padding convention above. `src` and `dst` must not // alias. // // Returns false (writing nothing to `dst`) when width/height < 1, when either // pointer is null, or when N exceeds the int64-safe bound (2^30). bool scramble(const uint8_t* src, uint8_t* dst, int width, int height, const ScrambleConfig& config); // Exact inverse of scramble(): reads the N x N RGBA8888 canvas (stride = // N * 4) and writes back the top-left W x H RGBA8888 region (stride = width * // 4). `dst` must hold at least W*H*4 bytes. Must be called with the same // (width, height, config) as the matching scramble() -- the key is the same // tuple. `src` and `dst` must not alias. // // Returns false under the same conditions as scramble(). bool unscramble(const uint8_t* src, uint8_t* dst, int width, int height, const ScrambleConfig& config); } // namespace scramble } // namespace radiantwave