| freeqsay: Nim port of the Clojure implementation fa53aa2 nandi 6h ago | 1 | ## Deterministic avatar system (avatar-v1). |
| 2 | ## |
| 3 | ## canonical seed = HKDF-SHA256(ikm=DID, salt="freeq-world-avatar", info="avatar-v1") |
| 4 | ## All traits are abstract — nothing is inferred from profile data. |
| 5 | |
| 6 | import std/[math, tables] |
| 7 | import ./sha256 |
| 8 | |
| 9 | type |
| 10 | Traits* = OrderedTable[string, string] |
| 11 | |
| 12 | Avatar* = object |
| 13 | schema*: string |
| 14 | did*: string |
| 15 | baseGenerator*: string |
| 16 | canonicalSeedHex*: string |
| 17 | traits*: Traits |
| 18 | |
| 19 | Facing* = enum |
| 20 | fSouth = "south", fNorth = "north", fEast = "east", fWest = "west" |
| 21 | |
| 22 | Sprite* = object |
| 23 | width*, height*: int |
| 24 | pixels*: seq[int] ## palette indices |
| 25 | palette*: seq[string] |
| 26 | |
| 27 | const traitKeys* = [ |
| 28 | "body_silhouette", "head_shape", "skin_palette", "hair_shape", "hair_palette", |
| 29 | "eye_pixels", "shirt_jacket", "pants_skirt", "shoes", "accessory", |
| 30 | "idle_movement", "walk_cadence", "speech_sound", "accent_palette", |
| 31 | "arrival_effect", "musical_leitmotif"] |
| 32 | |
| 33 | const options*: array[15, (string, seq[string])] = [ |
| 34 | ("body_silhouette", @["slim", "broad", "round", "tall", "compact", "angular"]), |
| 35 | ("head_shape", @["round", "square", "oval", "wide"]), |
| 36 | # stylized fictional palette — deliberately includes non-naturalistic hues |
| 37 | ("skin_palette", @["#e8b48c", "#c68863", "#8d5a3b", "#5c3a26", "#f4d6b8", |
| 38 | "#9fb8ad", "#b7a6d9", "#7d9c6f"]), |
| 39 | ("hair_shape", @["crop", "spikes", "bob", "long", "curls", "mohawk", "bald", "bun"]), |
| 40 | ("hair_palette", @["#2b2b3a", "#5a3825", "#c9a227", "#b8434e", "#3f7cac", |
| 41 | "#67c26b", "#e8e6df", "#8447ad"]), |
| 42 | ("eye_pixels", @["dot", "wide", "wink", "visor"]), |
| 43 | ("shirt_jacket", @["#3f7cac", "#b8434e", "#67c26b", "#c9a227", "#8447ad", |
| 44 | "#3a9188", "#d97b29", "#6d7680"]), |
| 45 | ("pants_skirt", @["#31394d", "#5a3825", "#3a5a40", "#6b3a55", "#444444", "#7a5c99"]), |
| 46 | ("shoes", @["#1e1e28", "#5a3825", "#b8434e", "#e8e6df"]), |
| 47 | ("accessory", @["none", "hat", "glasses", "scarf", "antenna", "flower", |
| 48 | "headphones", "badge"]), |
| 49 | ("idle_movement", @["bob", "sway", "blink", "tap"]), |
| 50 | ("walk_cadence", @["steady", "bouncy", "brisk", "ambling"]), |
| 51 | ("speech_sound", @["beep", "blip", "burble", "chirp", "hum"]), |
| 52 | ("accent_palette", @["#ffd166", "#06d6a0", "#ef476f", "#118ab2", "#f78c6b", "#9b5de5"]), |
| 53 | ("arrival_effect", @["sparkle", "dissolve", "drop", "teleport-rings"])] |
| 54 | |
| 55 | func optionsFor*(key: string): seq[string] = |
| 56 | for (k, v) in options: |
| 57 | if k == key: return v |
| 58 | raise newException(KeyError, "no options for trait " & key) |
| 59 | |
| 60 | type Prng* = object |
| 61 | a, b, c, d: uint32 |
| 62 | |
| 63 | func u32le(seed: openArray[byte], i: int): uint32 = |
| 64 | for k in 0 ..< 4: |
| 65 | result = result or (uint32(seed[i + k]) shl (8 * k)) |
| 66 | |
| 67 | func initPrng*(seed: openArray[byte]): Prng = |
| 68 | ## sfc32 seeded from the first 16 bytes of `seed` — bit-identical to the |
| 69 | ## JavaScript original the fixture was generated with. |
| 70 | Prng(a: u32le(seed, 0), b: u32le(seed, 4), c: u32le(seed, 8), d: u32le(seed, 12)) |
| 71 | |
| 72 | func next*(p: var Prng): float = |
| 73 | ## Next double in [0, 1). |
| 74 | let t = p.a + p.b + p.d |
| 75 | let d = p.d + 1 |
| 76 | let a = p.b xor (p.b shr 9) |
| 77 | let b = p.c + (p.c shl 3) |
| 78 | let c = ((p.c shl 21) or (p.c shr 11)) + t |
| 79 | p = Prng(a: a, b: b, c: c, d: d) |
| 80 | float(t) / 4294967296.0 |
| 81 | |
| 82 | func pick*(p: var Prng, opts: openArray[string]): string = |
| 83 | let i = int(floor(p.next() * float(opts.len))) |
| 84 | opts[min(i, opts.len - 1)] |
| 85 | |
| 86 | func canonicalSeed*(did: string): seq[byte] = |
| 87 | ## 32-byte canonical seed for a DID. |
| 88 | hkdf(did, "freeq-world-avatar", "avatar-v1", 32) |
| 89 | |
| 90 | func deriveAvatar*(did: string): Avatar = |
| 91 | let seed = canonicalSeed(did) |
| 92 | var rng = initPrng(seed) |
| 93 | var traits: Traits |
| 94 | for key in traitKeys: |
| 95 | traits[key] = |
| 96 | if key == "musical_leitmotif": |
| 97 | # pointer to the leitmotif derivation (own HKDF domain) |
| 98 | "motif-v1" |
| 99 | else: |
| 100 | pick(rng, optionsFor(key)) |
| 101 | Avatar(schema: "freeq.at/profile/avatar/v1", |
| 102 | did: did, |
| 103 | baseGenerator: "avatar-v1", |
| 104 | canonicalSeedHex: toHex(seed), |
| 105 | traits: traits) |
| 106 | |
| 107 | const |
| 108 | W* = 16 |
| 109 | H* = 24 |
| 110 | |
| 111 | const |
| 112 | SKIN = 1 |
| 113 | HAIR = 2 |
| 114 | SHIRT = 3 |
| 115 | PANTS = 4 |
| 116 | SHOE = 5 |
| 117 | INK = 6 |
| 118 | ACCENT = 7 |
| 119 | WHITE = 8 |
| 120 | |
| 121 | func renderSpritePixels*(av: Avatar, facing = fSouth, frame = 0): Sprite = |
| 122 | ## Procedural 16x24 pixel body. Rows: hair/head 0-9, torso 10-17, legs 18-21, |
| 123 | ## shoes 22-23. Facing changes the face; walk frames swing arms and legs. |
| 124 | let t = av.traits |
| 125 | var px = newSeq[int](W * H) |
| 126 | |
| 127 | proc put(x, y, c: int) = |
| 128 | if x >= 0 and x < W and y >= 0 and y < H: |
| 129 | px[y * W + x] = c |
| 130 | |
| 131 | proc hspan(x0, x1, y, c: int) = |
| 132 | for x in x0 .. x1: |
| 133 | put(x, y, c) |
| 134 | |
| 135 | let |
| 136 | silhouette = t["body_silhouette"] |
| 137 | bodyW = if silhouette in ["broad", "round"]: 10 |
| 138 | elif silhouette == "slim": 6 |
| 139 | else: 8 |
| 140 | bodyX0 = (W - bodyW) div 2 |
| 141 | bodyX1 = bodyX0 + bodyW - 1 |
| 142 | |
| 143 | headShape = t["head_shape"] |
| 144 | headW = if headShape == "wide": 10 else: 8 |
| 145 | headX0 = (W - headW) div 2 |
| 146 | headX1 = headX0 + headW - 1 |
| 147 | headTop = if silhouette == "tall": 1 else: 2 |
| 148 | hair = t["hair_shape"] |
| 149 | acc = t["accessory"] |
| 150 | |
| 151 | # head |
| 152 | for y in headTop ..< 10: |
| 153 | let shrink = if headShape in ["round", "oval"] and (y == headTop or y == 9): 1 else: 0 |
| 154 | hspan(headX0 + shrink, headX1 - shrink, y, SKIN) |
| 155 | |
| 156 | # hair |
| 157 | if hair != "bald": |
| 158 | hspan(headX0, headX1, headTop, HAIR) |
| 159 | hspan(headX0, headX1, headTop + 1, HAIR) |
| 160 | if hair in ["spikes", "mohawk"]: |
| 161 | let |
| 162 | mohawk = hair == "mohawk" |
| 163 | start = headX0 + (if mohawk: headW div 2 - 1 else: 0) |
| 164 | stop = if mohawk: headX0 + headW div 2 else: headX1 |
| 165 | step = if mohawk: 1 else: 2 |
| 166 | var x = start |
| 167 | while x <= stop: |
| 168 | put(x, headTop - 1, HAIR) |
| 169 | x += step |
| 170 | if hair in ["bob", "long", "curls"]: |
| 171 | put(headX0, headTop + 2, HAIR) |
| 172 | put(headX1, headTop + 2, HAIR) |
| 173 | put(headX0, headTop + 3, HAIR) |
| 174 | put(headX1, headTop + 3, HAIR) |
| 175 | if hair == "long": |
| 176 | put(headX0, headTop + 4, HAIR) |
| 177 | put(headX1, headTop + 4, HAIR) |
| 178 | if hair == "bun": |
| 179 | put(W div 2, headTop - 1, HAIR) |
| 180 | |
| 181 | # face (not shown when facing north) |
| 182 | if facing != fNorth: |
| 183 | let |
| 184 | eyeY = headTop + 4 |
| 185 | eyes = t["eye_pixels"] |
| 186 | mid = W div 2 |
| 187 | eyeL = case facing |
| 188 | of fEast: mid |
| 189 | of fWest: headX0 + 1 |
| 190 | else: headX0 + 2 |
| 191 | eyeR = case facing |
| 192 | of fEast: headX1 - 1 |
| 193 | of fWest: mid - 1 |
| 194 | else: headX1 - 2 |
| 195 | if eyes == "visor": |
| 196 | hspan(eyeL, eyeR, eyeY, ACCENT) |
| 197 | else: |
| 198 | put(eyeL, eyeY, INK) |
| 199 | if eyes != "wink": |
| 200 | put(eyeR, eyeY, INK) |
| 201 | if eyes == "wide": |
| 202 | put(eyeL, eyeY - 1, WHITE) |
| 203 | put(eyeR, eyeY - 1, WHITE) |
| 204 | elif hair != "bald": |
| 205 | # back of head is hair |
| 206 | for y in headTop ..< 9: |
| 207 | hspan(headX0, headX1, y, HAIR) |
| 208 | |
| 209 | # torso |
| 210 | for y in 10 ..< 18: |
| 211 | hspan(bodyX0, bodyX1, y, SHIRT) |
| 212 | # accent stripe |
| 213 | hspan(bodyX0, bodyX1, 13, ACCENT) |
| 214 | |
| 215 | # arms (swing with walk frame) |
| 216 | let swing = case frame |
| 217 | of 0: 0 |
| 218 | of 1: 1 |
| 219 | else: -1 |
| 220 | put(bodyX0 - 1, 11 + swing, SKIN) |
| 221 | put(bodyX0 - 1, 12 + swing, SKIN) |
| 222 | put(bodyX1 + 1, 11 - swing, SKIN) |
| 223 | put(bodyX1 + 1, 12 - swing, SKIN) |
| 224 | |
| 225 | # legs |
| 226 | let |
| 227 | legW = max(2, bodyW div 2 - 1) |
| 228 | gap = case frame |
| 229 | of 0: 2 |
| 230 | of 1: 4 |
| 231 | else: 0 |
| 232 | legL0 = W div 2 - gap div 2 - legW |
| 233 | legR0 = W div 2 + int(ceil(float(gap) / 2.0)) |
| 234 | for y in 18 ..< 22: |
| 235 | hspan(legL0, legL0 + legW - 1, y, PANTS) |
| 236 | hspan(legR0, legR0 + legW - 1, y, PANTS) |
| 237 | for y in 22 ..< 24: |
| 238 | hspan(legL0, legL0 + legW - 1, y, SHOE) |
| 239 | hspan(legR0, legR0 + legW - 1, y, SHOE) |
| 240 | |
| 241 | # accessory |
| 242 | case acc |
| 243 | of "hat": |
| 244 | hspan(headX0 - 1, headX1 + 1, headTop, ACCENT) |
| 245 | hspan(headX0, headX1, headTop - 1, ACCENT) |
| 246 | of "antenna": |
| 247 | put(W div 2, headTop - 1, INK) |
| 248 | put(W div 2, headTop - 2, ACCENT) |
| 249 | of "scarf": |
| 250 | hspan(bodyX0, bodyX1, 10, ACCENT) |
| 251 | of "glasses": |
| 252 | if facing != fNorth: |
| 253 | hspan(headX0 + 1, headX1 - 1, headTop + 4, INK) |
| 254 | of "headphones": |
| 255 | put(headX0 - 1, headTop + 3, ACCENT) |
| 256 | put(headX1 + 1, headTop + 3, ACCENT) |
| 257 | of "flower": |
| 258 | put(headX1, headTop + 1, ACCENT) |
| 259 | of "badge": |
| 260 | put(bodyX0 + 1, 11, ACCENT) |
| 261 | else: discard |
| 262 | |
| 263 | Sprite(width: W, height: H, pixels: px, |
| 264 | palette: @["transparent", t["skin_palette"], t["hair_palette"], |
| 265 | t["shirt_jacket"], t["pants_skirt"], t["shoes"], |
| 266 | "#14141c", # outline/eyes |
| 267 | t["accent_palette"], "#ffffff"]) |
| 268 | |
| 269 | func spriteHash*(av: Avatar): string = |
| 270 | ## Deterministic content hash over all four facings — conformance fixture value. |
| 271 | var bs: seq[byte] |
| 272 | for facing in [fSouth, fNorth, fEast, fWest]: |
| 273 | let s = renderSpritePixels(av, facing, 0) |
| 274 | for p in s.pixels: |
| 275 | bs.add byte(p and 255) |
| 276 | for color in s.palette: |
| 277 | for ch in color: |
| 278 | bs.add byte(ch) |
| 279 | toHex(digest(bs)) |