@safe-vanity-blockie/core 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/dist/address.d.ts +26 -0
  4. package/dist/address.d.ts.map +1 -0
  5. package/dist/address.js +61 -0
  6. package/dist/address.js.map +1 -0
  7. package/dist/blo.d.ts +15 -0
  8. package/dist/blo.d.ts.map +1 -0
  9. package/dist/blo.js +86 -0
  10. package/dist/blo.js.map +1 -0
  11. package/dist/hex.d.ts +4 -0
  12. package/dist/hex.d.ts.map +1 -0
  13. package/dist/hex.js +26 -0
  14. package/dist/hex.js.map +1 -0
  15. package/dist/index.d.ts +10 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +9 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/keccak.d.ts +8 -0
  20. package/dist/keccak.d.ts.map +1 -0
  21. package/dist/keccak.js +15 -0
  22. package/dist/keccak.js.map +1 -0
  23. package/dist/miner.d.ts +45 -0
  24. package/dist/miner.d.ts.map +1 -0
  25. package/dist/miner.js +97 -0
  26. package/dist/miner.js.map +1 -0
  27. package/dist/scoring.d.ts +27 -0
  28. package/dist/scoring.d.ts.map +1 -0
  29. package/dist/scoring.js +185 -0
  30. package/dist/scoring.js.map +1 -0
  31. package/dist/select.d.ts +47 -0
  32. package/dist/select.d.ts.map +1 -0
  33. package/dist/select.js +49 -0
  34. package/dist/select.js.map +1 -0
  35. package/dist/templates.d.ts +18 -0
  36. package/dist/templates.d.ts.map +1 -0
  37. package/dist/templates.js +140 -0
  38. package/dist/templates.js.map +1 -0
  39. package/dist/types.d.ts +60 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +2 -0
  42. package/dist/types.js.map +1 -0
  43. package/package.json +52 -0
  44. package/src/address.ts +89 -0
  45. package/src/blo.ts +100 -0
  46. package/src/hex.ts +25 -0
  47. package/src/index.ts +60 -0
  48. package/src/keccak.ts +17 -0
  49. package/src/miner.ts +133 -0
  50. package/src/scoring.ts +209 -0
  51. package/src/select.ts +77 -0
  52. package/src/templates.ts +163 -0
  53. package/src/types.ts +66 -0
package/src/address.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { bytesToHex } from './hex.js'
2
+ import type { Keccak256 } from './keccak.js'
3
+
4
+ /**
5
+ * The three values that are constant for a given (owners, threshold, safeVersion) and therefore
6
+ * precomputed once on the main thread. Only the saltNonce varies per iteration.
7
+ */
8
+ export interface SafeConstants {
9
+ /** keccak256 of the ABI-encoded setup() calldata. 32 bytes. */
10
+ readonly initializerHash: Uint8Array
11
+ /** SafeProxyFactory address. 20 bytes. */
12
+ readonly factory: Uint8Array
13
+ /** keccak256(proxyCreationCode ++ abi.encode(address, singleton)). 32 bytes. */
14
+ readonly initCodeHash: Uint8Array
15
+ }
16
+
17
+ export interface AddressDeriver {
18
+ /** Fast path for saltNonce < 2^53. Returns a lowercase 0x address. */
19
+ derive(saltNonce: number): string
20
+ /** Full uint256 path, for verification and huge nonces. Returns a lowercase 0x address. */
21
+ deriveBig(saltNonce: bigint): string
22
+ }
23
+
24
+ const MAX_UINT256 = (1n << 256n) - 1n
25
+
26
+ /**
27
+ * Builds a deriver whose buffers are allocated once. Each derive() is exactly two keccaks:
28
+ * salt = keccak256(initializerHash ++ uint256(saltNonce))
29
+ * address = keccak256(0xff ++ factory ++ salt ++ initCodeHash)[12..32)
30
+ */
31
+ export function createAddressDeriver(
32
+ constants: SafeConstants,
33
+ keccak256: Keccak256,
34
+ ): AddressDeriver {
35
+ const { initializerHash, factory, initCodeHash } = constants
36
+ if (initializerHash.length !== 32) {
37
+ throw new Error(`initializerHash must be 32 bytes, got ${initializerHash.length}`)
38
+ }
39
+ if (factory.length !== 20) throw new Error(`factory must be 20 bytes, got ${factory.length}`)
40
+ if (initCodeHash.length !== 32) {
41
+ throw new Error(`initCodeHash must be 32 bytes, got ${initCodeHash.length}`)
42
+ }
43
+
44
+ // Bytes 32..64 are the big-endian uint256 saltNonce. Bytes 32..56 stay zero for derive(),
45
+ // so the fast path only writes the low 8 bytes.
46
+ const saltPreimage = new Uint8Array(64)
47
+ saltPreimage.set(initializerHash, 0)
48
+ const saltView = new DataView(saltPreimage.buffer)
49
+
50
+ const create2Preimage = new Uint8Array(85)
51
+ create2Preimage[0] = 0xff
52
+ create2Preimage.set(factory, 1)
53
+ create2Preimage.set(initCodeHash, 53)
54
+
55
+ function finish(): string {
56
+ const salt = keccak256(saltPreimage)
57
+ create2Preimage.set(salt, 21)
58
+ return bytesToHex(keccak256(create2Preimage), 12, 32)
59
+ }
60
+
61
+ return {
62
+ derive(saltNonce: number): string {
63
+ if (!Number.isSafeInteger(saltNonce) || saltNonce < 0) {
64
+ throw new Error(`derive() needs a non-negative safe integer, got ${saltNonce}`)
65
+ }
66
+ const high = Math.floor(saltNonce / 4294967296)
67
+ saltView.setUint32(56, high)
68
+ saltView.setUint32(60, saltNonce - high * 4294967296)
69
+ return finish()
70
+ },
71
+
72
+ deriveBig(saltNonce: bigint): string {
73
+ if (saltNonce < 0n || saltNonce > MAX_UINT256) {
74
+ throw new Error(`deriveBig() needs a uint256, got ${saltNonce}`)
75
+ }
76
+ let remaining = saltNonce
77
+ for (let i = 63; i >= 32; i--) {
78
+ saltPreimage[i] = Number(remaining & 0xffn)
79
+ remaining >>= 8n
80
+ }
81
+ try {
82
+ return finish()
83
+ } finally {
84
+ // Restore the invariant derive() relies on: bytes 32..56 are zero.
85
+ saltPreimage.fill(0, 32, 56)
86
+ }
87
+ },
88
+ }
89
+ }
package/src/blo.ts ADDED
@@ -0,0 +1,100 @@
1
+ import type { BloImage, Hsl, Palette } from './types.js'
2
+
3
+ /** = 1 / 2147483648. Parenthesise as ((1 << 31) >>> 0); `1 << (31 >>> 0)` is negative. */
4
+ const RANDOM_SCALE = 1 / ((1 << 31) >>> 0)
5
+
6
+ /** Number of nextRandom() draws consumed by the three randomColor() calls before the grid. */
7
+ const COLOR_DRAWS = 18
8
+
9
+ export function seedInto(seed: string, rseed: Uint32Array): void {
10
+ rseed[0] = 0
11
+ rseed[1] = 0
12
+ rseed[2] = 0
13
+ rseed[3] = 0
14
+ for (let i = 0; i < seed.length; i++) {
15
+ const slot = i & 3
16
+ rseed[slot] = (rseed[slot] << 5) - rseed[slot] + seed.charCodeAt(i)
17
+ }
18
+ }
19
+
20
+ export function randSeed(seed: string): Uint32Array {
21
+ const rseed = new Uint32Array(4)
22
+ seedInto(seed, rseed)
23
+ return rseed
24
+ }
25
+
26
+ export function nextRandom(rseed: Uint32Array): number {
27
+ const t = rseed[0] ^ (rseed[0] << 11)
28
+ rseed[0] = rseed[1]
29
+ rseed[1] = rseed[2]
30
+ rseed[2] = rseed[3]
31
+ rseed[3] = (rseed[3] ^ (rseed[3] >> 19) ^ t ^ (t >> 8)) >>> 0
32
+ return rseed[3] * RANDOM_SCALE
33
+ }
34
+
35
+ export function randomColor(rseed: Uint32Array): Hsl {
36
+ return [
37
+ Math.floor(nextRandom(rseed) * 360),
38
+ Math.floor(40 + nextRandom(rseed) * 60),
39
+ Math.floor(
40
+ (nextRandom(rseed) + nextRandom(rseed) + nextRandom(rseed) + nextRandom(rseed)) * 25,
41
+ ),
42
+ ]
43
+ }
44
+
45
+ export function bloImage(address: string): BloImage {
46
+ const rseed = randSeed(address.toLowerCase())
47
+ // blo assigns these to c, b, s in this order but returns them as [b, c, s].
48
+ const c = randomColor(rseed)
49
+ const b = randomColor(rseed)
50
+ const s = randomColor(rseed)
51
+ const data = new Uint8Array(32)
52
+ for (let i = 0; i < 32; i++) data[i] = Math.floor(nextRandom(rseed) * 2.3)
53
+ return { data, colors: [b, c, s] as Palette }
54
+ }
55
+
56
+ /**
57
+ * Hot-path grid generation. `lowercaseAddress` MUST already be lowercased and 0x-prefixed —
58
+ * the caller owns that so the loop never allocates a string. Writes 32 values into `data`
59
+ * and reuses `rseed` as scratch. Identical output to `bloImage(address).data`.
60
+ */
61
+ export function bloDataInto(lowercaseAddress: string, data: Uint8Array, rseed: Uint32Array): void {
62
+ seedInto(lowercaseAddress, rseed)
63
+ for (let i = 0; i < COLOR_DRAWS; i++) nextRandom(rseed)
64
+ for (let i = 0; i < 32; i++) data[i] = Math.floor(nextRandom(rseed) * 2.3)
65
+ }
66
+
67
+ export function bloData(address: string): Uint8Array {
68
+ const data = new Uint8Array(32)
69
+ bloDataInto(address.toLowerCase(), data, new Uint32Array(4))
70
+ return data
71
+ }
72
+
73
+ export function bloSvg(address: string, size = 64): string {
74
+ const {
75
+ data,
76
+ colors: [b, c, s],
77
+ } = bloImage(address)
78
+ const paths = ['', '']
79
+ for (let i = 0; i < 32; i++) {
80
+ if (data[i] === 0) continue
81
+ const x = i & 3
82
+ const y = i >> 2
83
+ const square = ',' + y + 'h1v1h-1z'
84
+ paths[data[i] - 1] += 'M' + x + square + 'M' + (7 - x) + square
85
+ }
86
+ const path = (color: Hsl, d: string) =>
87
+ '<path fill="hsl(' + color[0] + ' ' + color[1] + '% ' + color[2] + '%)" d="' + d + '"/>'
88
+ return (
89
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" shape-rendering="optimizeSpeed" ' +
90
+ 'width="' +
91
+ size +
92
+ '" height="' +
93
+ size +
94
+ '">' +
95
+ path(b, 'M0,0H8V8H0z') +
96
+ path(c, paths[0]) +
97
+ path(s, paths[1]) +
98
+ '</svg>'
99
+ )
100
+ }
package/src/hex.ts ADDED
@@ -0,0 +1,25 @@
1
+ const HEX_BYTES = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, '0'))
2
+
3
+ /** Lowercase 0x-prefixed hex for `bytes[start..end)`. */
4
+ export function bytesToHex(bytes: Uint8Array, start = 0, end = bytes.length): string {
5
+ let out = '0x'
6
+ for (let i = start; i < end; i++) out += HEX_BYTES[bytes[i]]
7
+ return out
8
+ }
9
+
10
+ export function hexToBytes(hex: string): Uint8Array {
11
+ const body = hex.startsWith('0x') || hex.startsWith('0X') ? hex.slice(2) : hex
12
+ if (body.length % 2 !== 0) {
13
+ throw new Error(`hexToBytes: odd-length hex string (${body.length} characters)`)
14
+ }
15
+ if (!/^[0-9a-fA-F]*$/.test(body)) {
16
+ throw new Error(`hexToBytes: invalid hex characters`)
17
+ }
18
+ const out = new Uint8Array(body.length / 2)
19
+ for (let i = 0; i < out.length; i++) {
20
+ const byte = Number.parseInt(body.slice(i * 2, i * 2 + 2), 16)
21
+ if (Number.isNaN(byte)) throw new Error(`hexToBytes: invalid hex at byte ${i}`)
22
+ out[i] = byte
23
+ }
24
+ return out
25
+ }
package/src/index.ts ADDED
@@ -0,0 +1,60 @@
1
+ export { type AddressDeriver, createAddressDeriver, type SafeConstants } from './address.js'
2
+ export {
3
+ bloData,
4
+ bloDataInto,
5
+ bloImage,
6
+ bloSvg,
7
+ nextRandom,
8
+ randomColor,
9
+ randSeed,
10
+ seedInto,
11
+ } from './blo.js'
12
+ export { bytesToHex, hexToBytes } from './hex.js'
13
+ export { createKeccak256, type Keccak256 } from './keccak.js'
14
+ export {
15
+ type Candidate,
16
+ compareCandidates,
17
+ createMiner,
18
+ Leaderboard,
19
+ type MineOptions,
20
+ type MineResult,
21
+ } from './miner.js'
22
+ export {
23
+ apportion,
24
+ colorContrast,
25
+ compileFace,
26
+ describeMatch,
27
+ hslToRgb,
28
+ isTwoColor,
29
+ makeScorer,
30
+ } from './scoring.js'
31
+ export {
32
+ filterCandidates,
33
+ formatScore,
34
+ type SelectReportedResult,
35
+ scorePercent,
36
+ selectReported,
37
+ } from './select.js'
38
+ export {
39
+ BASE_TARGET,
40
+ BASE_WEIGHTS,
41
+ faceWithMouths,
42
+ getTemplate,
43
+ MOUTH_BG_WEIGHT,
44
+ MOUTH_BUDGET,
45
+ MOUTH_INDICES,
46
+ MOUTH_STROKE_WEIGHT,
47
+ MOUTHS,
48
+ parseFaceSpec,
49
+ TEMPLATES,
50
+ } from './templates.js'
51
+ export type {
52
+ BloImage,
53
+ CompiledFace,
54
+ FaceRegion,
55
+ FaceSpec,
56
+ FixedCell,
57
+ Hsl,
58
+ Palette,
59
+ RegionAlternative,
60
+ } from './types.js'
package/src/keccak.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { createKeccak } from 'hash-wasm'
2
+
3
+ export type Keccak256 = (input: Uint8Array) => Uint8Array
4
+
5
+ /**
6
+ * Creates a keccak-256 function backed by WebAssembly. Real WASM, so the identical module works
7
+ * in Node worker_threads and browser Web Workers. Call once per worker at startup — instantiation
8
+ * is the expensive part, hashing afterwards is not.
9
+ */
10
+ export async function createKeccak256(): Promise<Keccak256> {
11
+ const hasher = await createKeccak(256)
12
+ return (input: Uint8Array): Uint8Array => {
13
+ hasher.init()
14
+ hasher.update(input)
15
+ return hasher.digest('binary')
16
+ }
17
+ }
package/src/miner.ts ADDED
@@ -0,0 +1,133 @@
1
+ import { createAddressDeriver, type SafeConstants } from './address.js'
2
+ import { bloDataInto, bloImage } from './blo.js'
3
+ import type { Keccak256 } from './keccak.js'
4
+ import { colorContrast, describeMatch, isTwoColor, makeScorer } from './scoring.js'
5
+ import type { CompiledFace } from './types.js'
6
+
7
+ export interface Candidate {
8
+ /** Decimal string: a saltNonce is a uint256 and may exceed 2^53. */
9
+ saltNonce: string
10
+ address: string
11
+ score: number
12
+ maxScore: number
13
+ twoColor: boolean
14
+ contrast: number
15
+ /** Winning alternative per region, e.g. `{ mouth: 'smile' }`. */
16
+ regions: Record<string, string>
17
+ }
18
+
19
+ /** Ranking from spec §5.6: score desc, two-colour first, contrast desc, then saltNonce for stability. */
20
+ export function compareCandidates(a: Candidate, b: Candidate): number {
21
+ if (a.score !== b.score) return b.score - a.score
22
+ if (a.twoColor !== b.twoColor) return a.twoColor ? -1 : 1
23
+ if (a.contrast !== b.contrast) return b.contrast - a.contrast
24
+ if (a.saltNonce === b.saltNonce) return 0
25
+ return a.saltNonce.length - b.saltNonce.length || (a.saltNonce < b.saltNonce ? -1 : 1)
26
+ }
27
+
28
+ export class Leaderboard {
29
+ readonly capacity: number
30
+ private items: Candidate[] = []
31
+ private seen = new Set<string>()
32
+
33
+ constructor(capacity: number) {
34
+ if (!Number.isInteger(capacity) || capacity < 1) {
35
+ throw new Error(`Leaderboard capacity must be a positive integer, got ${capacity}`)
36
+ }
37
+ this.capacity = capacity
38
+ }
39
+
40
+ /** Score a candidate must reach to be worth building. -1 until the board is full. */
41
+ get threshold(): number {
42
+ return this.items.length < this.capacity ? -1 : this.items[this.items.length - 1].score
43
+ }
44
+
45
+ offer(candidate: Candidate): boolean {
46
+ if (this.seen.has(candidate.address)) return false
47
+ if (this.items.length >= this.capacity && candidate.score < this.threshold) return false
48
+ this.seen.add(candidate.address)
49
+ this.items.push(candidate)
50
+ this.items.sort(compareCandidates)
51
+ if (this.items.length > this.capacity) this.items.length = this.capacity
52
+ this.seen = new Set(this.items.map((item) => item.address))
53
+ return true
54
+ }
55
+
56
+ merge(candidates: Candidate[]): void {
57
+ for (const candidate of candidates) this.offer(candidate)
58
+ }
59
+
60
+ entries(): Candidate[] {
61
+ return this.items.slice()
62
+ }
63
+ }
64
+
65
+ export interface MineOptions {
66
+ start: number
67
+ count: number
68
+ /** Leaderboard size. Default 20. */
69
+ keep?: number
70
+ /** Iterations between onProgress callbacks. Default 250_000. */
71
+ chunkSize?: number
72
+ /** Return false to stop early. `scanned` is cumulative for this call. */
73
+ // biome-ignore lint/suspicious/noConfusingVoidType: deliberate union with void so callers may return nothing; narrowing to `boolean | undefined` breaks consumers of the published package whose callback bodies infer a `void` return.
74
+ onProgress?: (scanned: number, best: Candidate[]) => boolean | void
75
+ }
76
+
77
+ export interface MineResult {
78
+ scanned: number
79
+ candidates: Candidate[]
80
+ }
81
+
82
+ export function createMiner(
83
+ constants: SafeConstants,
84
+ face: CompiledFace,
85
+ keccak256: Keccak256,
86
+ ): { mine(options: MineOptions): MineResult } {
87
+ const deriver = createAddressDeriver(constants, keccak256)
88
+ const score = makeScorer(face)
89
+
90
+ /** Off the hot path — only runs when a candidate reaches the leaderboard. */
91
+ function buildCandidate(nonce: number, address: string, value: number): Candidate {
92
+ const { data, colors } = bloImage(address)
93
+ return {
94
+ saltNonce: String(nonce),
95
+ address,
96
+ score: value,
97
+ maxScore: face.maxScore,
98
+ twoColor: isTwoColor(data),
99
+ contrast: Math.round(colorContrast(colors[0], colors[1])),
100
+ regions: describeMatch(face, data).regions,
101
+ }
102
+ }
103
+
104
+ return {
105
+ mine(options: MineOptions): MineResult {
106
+ const chunkSize = options.chunkSize ?? 250_000
107
+ if (!Number.isInteger(chunkSize) || chunkSize <= 0) {
108
+ throw new Error(`chunkSize must be a positive integer, got ${chunkSize}`)
109
+ }
110
+ // keep is validated by the Leaderboard constructor below.
111
+ const board = new Leaderboard(options.keep ?? 20)
112
+ // Allocated once for the whole run; the hot loop allocates nothing else.
113
+ const data = new Uint8Array(32)
114
+ const rseed = new Uint32Array(4)
115
+ let scanned = 0
116
+
117
+ while (scanned < options.count) {
118
+ const from = options.start + scanned
119
+ const to = from + Math.min(chunkSize, options.count - scanned)
120
+ for (let nonce = from; nonce < to; nonce++) {
121
+ const address = deriver.derive(nonce)
122
+ bloDataInto(address, data, rseed)
123
+ const value = score(data)
124
+ if (value >= board.threshold) board.offer(buildCandidate(nonce, address, value))
125
+ }
126
+ scanned = to - options.start
127
+ if (options.onProgress?.(scanned, board.entries()) === false) break
128
+ }
129
+
130
+ return { scanned, candidates: board.entries() }
131
+ },
132
+ }
133
+ }
package/src/scoring.ts ADDED
@@ -0,0 +1,209 @@
1
+ import type { CompiledFace, FaceSpec, Hsl } from './types.js'
2
+
3
+ /**
4
+ * Largest-remainder apportionment: split `budget` across `rawWeights` proportionally using
5
+ * integers that sum to exactly `budget`.
6
+ *
7
+ * The spec's `Math.round(raw / total * budget)` does not do this — it yields 57 for a 3-stroke
8
+ * mouth and 62 for a 2-stroke one, silently making some expressions worth more than others.
9
+ */
10
+ export function apportion(rawWeights: number[], budget: number): number[] {
11
+ if (rawWeights.length === 0) return []
12
+ const total = rawWeights.reduce((a, b) => a + b, 0)
13
+ if (total <= 0) throw new Error('apportion: raw weights must sum to a positive number')
14
+ const exact = rawWeights.map((weight) => (weight * budget) / total)
15
+ const shares = exact.map((value) => Math.floor(value))
16
+ const remainder = budget - shares.reduce((a, b) => a + b, 0)
17
+ const order = exact
18
+ .map((value, index) => ({ index, fraction: value - Math.floor(value) }))
19
+ .sort((a, b) => b.fraction - a.fraction || a.index - b.index)
20
+ for (let i = 0; i < remainder; i++) shares[order[i].index] += 1
21
+ return shares
22
+ }
23
+
24
+ export function compileFace(spec: FaceSpec): CompiledFace {
25
+ const used = new Set<number>()
26
+ const claim = (index: number, where: string) => {
27
+ if (!Number.isInteger(index) || index < 0 || index > 31) {
28
+ throw new Error(`${where}: cell index ${index} must be an integer between 0 and 31`)
29
+ }
30
+ if (used.has(index)) throw new Error(`${where}: cell index ${index} is used more than once`)
31
+ used.add(index)
32
+ }
33
+
34
+ const fixedIndex = new Uint8Array(spec.fixed.length)
35
+ const fixedTarget = new Uint8Array(spec.fixed.length)
36
+ const fixedWeight = new Int32Array(spec.fixed.length)
37
+ let maxScore = 0
38
+ spec.fixed.forEach((cell, i) => {
39
+ claim(cell.index, `fixed cell ${i}`)
40
+ fixedIndex[i] = cell.index
41
+ fixedTarget[i] = cell.value
42
+ fixedWeight[i] = cell.weight
43
+ maxScore += cell.weight
44
+ })
45
+
46
+ const regionIndexStart = new Int32Array(spec.regions.length)
47
+ const regionLength = new Int32Array(spec.regions.length)
48
+ const regionAltCount = new Int32Array(spec.regions.length)
49
+ const regionCellStart = new Int32Array(spec.regions.length)
50
+ const indices: number[] = []
51
+ const cells: number[] = []
52
+ const weights: number[] = []
53
+ const regionNames: string[] = []
54
+ const regionAltNames: string[][] = []
55
+
56
+ spec.regions.forEach((region, r) => {
57
+ regionIndexStart[r] = indices.length
58
+ regionCellStart[r] = cells.length
59
+ regionLength[r] = region.indices.length
60
+ regionAltCount[r] = region.alternatives.length
61
+ regionNames.push(region.name)
62
+ regionAltNames.push(region.alternatives.map((alternative) => alternative.name))
63
+
64
+ if (region.alternatives.length === 0) {
65
+ throw new Error(`region "${region.name}": needs at least one alternative`)
66
+ }
67
+ for (const index of region.indices) claim(index, `region "${region.name}"`)
68
+ indices.push(...region.indices)
69
+
70
+ for (const alternative of region.alternatives) {
71
+ if (alternative.cells.length !== region.indices.length) {
72
+ throw new Error(
73
+ `region "${region.name}", alternative "${alternative.name}": expected ` +
74
+ `${region.indices.length} cells, got ${alternative.cells.length}`,
75
+ )
76
+ }
77
+ const raw = alternative.cells.map((cell) =>
78
+ cell === 1 ? region.strokeWeight : region.bgWeight,
79
+ )
80
+ cells.push(...alternative.cells)
81
+ weights.push(...apportion(raw, region.budget))
82
+ }
83
+ maxScore += region.budget
84
+ })
85
+
86
+ return {
87
+ name: spec.name,
88
+ maxScore,
89
+ nFixed: spec.fixed.length,
90
+ fixedIndex,
91
+ fixedTarget,
92
+ fixedWeight,
93
+ nRegions: spec.regions.length,
94
+ regionIndexStart,
95
+ regionLength,
96
+ regionAltCount,
97
+ regionCellStart,
98
+ regionIndex: Uint8Array.from(indices),
99
+ regionCells: Uint8Array.from(cells),
100
+ regionWeights: Int32Array.from(weights),
101
+ regionNames,
102
+ regionAltNames,
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Builds the hot-path scorer. Everything is hoisted into locals so the returned closure is
108
+ * monomorphic: integer-only, no allocation, no division, no property lookups per candidate.
109
+ */
110
+ export function makeScorer(face: CompiledFace): (data: Uint8Array) => number {
111
+ const {
112
+ nFixed,
113
+ fixedIndex,
114
+ fixedTarget,
115
+ fixedWeight,
116
+ nRegions,
117
+ regionIndexStart,
118
+ regionLength,
119
+ regionAltCount,
120
+ regionCellStart,
121
+ regionIndex,
122
+ regionCells,
123
+ regionWeights,
124
+ } = face
125
+
126
+ return function score(data: Uint8Array): number {
127
+ let total = 0
128
+ for (let i = 0; i < nFixed; i++) {
129
+ if (data[fixedIndex[i]] === fixedTarget[i]) total += fixedWeight[i]
130
+ }
131
+ for (let r = 0; r < nRegions; r++) {
132
+ const indexBase = regionIndexStart[r]
133
+ const length = regionLength[r]
134
+ const altCount = regionAltCount[r]
135
+ const cellBase = regionCellStart[r]
136
+ let best = 0
137
+ for (let a = 0; a < altCount; a++) {
138
+ const offset = cellBase + a * length
139
+ let got = 0
140
+ for (let j = 0; j < length; j++) {
141
+ if (data[regionIndex[indexBase + j]] === regionCells[offset + j]) {
142
+ got += regionWeights[offset + j]
143
+ }
144
+ }
145
+ if (got > best) best = got
146
+ }
147
+ total += best
148
+ }
149
+ return total
150
+ }
151
+ }
152
+
153
+ /** Off the hot path: which alternative won in each region, plus the total score. */
154
+ export function describeMatch(
155
+ face: CompiledFace,
156
+ data: Uint8Array,
157
+ ): { score: number; regions: Record<string, string> } {
158
+ let total = 0
159
+ for (let i = 0; i < face.nFixed; i++) {
160
+ if (data[face.fixedIndex[i]] === face.fixedTarget[i]) total += face.fixedWeight[i]
161
+ }
162
+ const regions: Record<string, string> = {}
163
+ for (let r = 0; r < face.nRegions; r++) {
164
+ const indexBase = face.regionIndexStart[r]
165
+ const length = face.regionLength[r]
166
+ const cellBase = face.regionCellStart[r]
167
+ let best = -1
168
+ let bestAlt = 0
169
+ for (let a = 0; a < face.regionAltCount[r]; a++) {
170
+ const offset = cellBase + a * length
171
+ let got = 0
172
+ for (let j = 0; j < length; j++) {
173
+ if (data[face.regionIndex[indexBase + j]] === face.regionCells[offset + j]) {
174
+ got += face.regionWeights[offset + j]
175
+ }
176
+ }
177
+ if (got > best) {
178
+ best = got
179
+ bestAlt = a
180
+ }
181
+ }
182
+ total += best
183
+ regions[face.regionNames[r]] = face.regionAltNames[r][bestAlt]
184
+ }
185
+ return { score: total, regions }
186
+ }
187
+
188
+ /** True when the grid never uses blo's spot colour, i.e. the blockie renders in two colours. */
189
+ export function isTwoColor(data: Uint8Array): boolean {
190
+ for (let i = 0; i < data.length; i++) if (data[i] === 2) return false
191
+ return true
192
+ }
193
+
194
+ /** blo emits h 0-360, s 0-100, l 0-100. Returns r/g/b in 0-255. */
195
+ export function hslToRgb(h: number, s: number, l: number): [number, number, number] {
196
+ const saturation = s / 100
197
+ const lightness = l / 100
198
+ const k = (n: number) => (n + h / 30) % 12
199
+ const a = saturation * Math.min(lightness, 1 - lightness)
200
+ const f = (n: number) => lightness - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))
201
+ return [f(0) * 255, f(8) * 255, f(4) * 255]
202
+ }
203
+
204
+ /** Euclidean RGB distance between two HSL colours. 0 = identical, ~441.7 = black vs white. */
205
+ export function colorContrast(a: Hsl, b: Hsl): number {
206
+ const [r1, g1, b1] = hslToRgb(a[0], a[1], a[2])
207
+ const [r2, g2, b2] = hslToRgb(b[0], b[1], b[2])
208
+ return Math.hypot(r1 - r2, g1 - g2, b1 - b2)
209
+ }