nandi/freeqsay-nimpublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/nandi/freeqsay-nim.git
git clone ssh://git@rickub.com/nandi/freeqsay-nim.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

avatar.nim · 279 lines · 8.6 KBNim Blame HistoryRaw
freeqsay: Nim port of the Clojure implementation fa53aa2 nandi 6h ago1## 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
6import std/[math, tables]
7import ./sha256
8
9type
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
27const 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
33const 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
55func 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
60type Prng* = object
61 a, b, c, d: uint32
62
63func 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
67func 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
72func 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
82func 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
86func canonicalSeed*(did: string): seq[byte] =
87 ## 32-byte canonical seed for a DID.
88 hkdf(did, "freeq-world-avatar", "avatar-v1", 32)
89
90func 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
107const
108 W* = 16
109 H* = 24
110
111const
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
121func 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
269func 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))