nx_hairdyn_lib.nx source
↩ module page · 556 lines · 27947 B
1// nx_hairdyn_lib.nx -- LIB: HAIR AS A DRIVEN CHAIN ON THE ONE SOLVER, WITH ITS PLANT MEASURED
2// IN THE UNITS OF ITS OWN CITED BAND (2026-08-25).
3//
4// WHY THIS EXISTS. knowledge/gamefeel_oracle.conf has carried, since the oracle table was banked:
5// hair_k_q10 120 420 per1024 secondary-motion-hair-2to5hz
6// and NOTHING WAS EVER DERIVED FROM IT. The shipping hair plant is two hand-picked integers in
7// nx_softbind (SB_K_HAIR=200, SB_C_HAIR=60) that sit inside that band by inspection, driving a
8// SINGLE point -- so hair on /world/beach is one lagging joint offset underneath a painted skull
9// band, and the cited row is a BAR WITH NO PLANT BEHIND IT. This lib is the plant, and its own
10// gate measures it rather than asserting it.
11//
12// THE CHAIN. Hair is a strand, not a point. The game-convention lineage for this class is the
13// SPRING-BONE CHAIN -- a bone pulled toward its rest pose RELATIVE TO ITS PARENT under a spring
14// and a damper, with the parent's motion propagating down the chain (artellblender/springbones,
15// https://github.com/artellblender/springbones, position and rotation over bone chains). hd_tick
16// is exactly that, and it is why a strand reads as hair instead of as a wobbling head. That is a
17// CONVENTION citation, not a physiological one, and it is labelled as such: it licenses the SHAPE
18// of the model, never any number in it.
19//
20// ONE SOLVER, TWO CONSUMERS. Every segment is stepped by nx_softdyn's sd_step_axq -- the SAME
21// integrator the soft-tissue lane reaches through sd_step_ax. A second spring integrator for hair
22// would be the duplicate-ruler defect the estate has already paid for twice. K comes from
23// sb_k_sym (nx_softbind's frequency->stiffness ruler) and C from sb_c_for_zeta (its damping
24// ruler), so a hair constant CANNOT drift from a chest constant: both are the same two functions
25// evaluated at different arguments.
26//
27// license_tier: ORIGINAL No hw writes (Rule 26).
28import "nx_syscalls.nx"
29import "nx_softdyn.nx"
30import "nx_softbind.nx"
31import "nx_ringdown_lib.nx"
32import "nx_vecmath.nx" // vm_capsule_push -- the ONE collider, not a fourth private copy
33
34// ---- THE PROVEN OPERATING RANGE. NOT A CITED SUBJECT BAND, AND DELIBERATELY NOT CALLED ONE. ----
35// hair_k_q10's source field reads "secondary-motion-hair-2to5hz", and THAT SLUG RESOLVES TO
36// NOTHING. Measured 2026-08-25, each with complete coverage: absent from the 9,762-paper graphics
37// index knowledge/library/gfx_papers_index.txt (corpus_complete=1); absent from the realism-
38// oracles brief the conf header itself points at, sites/nishifamily/code/research_realism_
39// oracles.html, which contains no hair at all (corpus_complete=1); absent from the buildroot/
40// knowledge twin (corpus_complete=1). It is also the ONLY row in that file carrying no author, no
41// year, no journal, no arXiv id and no product name, where every neighbour carries at least one
42// (scurr2011, haake-scurr-2010, cai2018, mills2025, intellabs-...-arxiv2506.11546).
43//
44// SO THESE TWO CONSTANTS ARE NOT A CITATION AND MUST NEVER BE PINNED TO ONE. Transcribing "2to5hz"
45// into a hair_fn_mhz row and citing that same slug would launder an uncited number into a row that
46// LOOKS cited -- precisely the "taste wearing a citation" the oracle file's own header forbids, and
47// it would be worse than the gap because the next reader would stop checking.
48//
49// What they ARE is the range over which THIS solver's frequency derivation is PROVEN to round-trip:
50// ask hd_ringdown for a frequency anywhere in here and the ringdown reads it back, within the
51// analyser's own resolution. That is a property of our plant, measurable by us, owing nobody a
52// citation -- and nx_hairdyn_gate proves it at BOTH endpoints rather than asserting it.
53const HD_FN_LO_MHZ: i64 = 2000
54const HD_FN_HI_MHZ: i64 = 5000
55
56// ---- THE TICK RATE IS DERIVED, NOT DECLARED. ----
57// nx_softbind's SB_MHZ_TICK_SQ is (mhz_per_hz * ticks_per_second)^2 -- the constant that carries
58// the units in its frequency->stiffness conversion. Taking the tick rate back OUT of it, rather
59// than writing 60 here, means the sampling period this lib hands the ringdown analyser and the
60// rate sb_k_sym assumes cannot disagree. A hand-written 60 beside it would be a second copy of
61// one fact, free to drift silently, and the drift would show up as a wrong frequency with no
62// wrong number anywhere to find.
63func hd_ticks_per_s() -> i64 { return sd_isqrt(SB_MHZ_TICK_SQ)/RD_MHZ_PER_HZ }
64func hd_dt_us() -> i64 { return RD_US_PER_S/hd_ticks_per_s() }
65
66// ---- PG14 (2026-09-06): THE STRAND GROOM -- guide curves plus interpolation, hierarchical by construction ----------
67// gr_strand(seed, cx, cy, cz, scalp_r, nguides, nchild, nseg, seglen, jitter_permil) grows a groom: NGUIDES guide
68// curves rooted on the upper hemisphere of the scalp sphere (rejection-sampled directions, no trig), each hanging
69// down with its own seeded sway so no two guides are alike; NCHILD child strands rooted on the same cap, each an
70// inverse-distance convex combination of its GR_KNN nearest guides' segment OFFSETS plus a bounded per-strand
71// jitter that grows toward the tip (a clump that frays). A child rooted ON a guide reproduces that guide exactly
72// (weights 1024,0,0), a child between guides lies inside the box its guides span -- both are gate teeth, not
73// hopes. Level 0 = the guides, level 1 = the children: rendering level 0 alone is the coarsest LOD, which is what
74// PG6's hierarchy consumes. Positions are Q8 like the chain state below, so gr_to_chain seats a groomed strand into
75// nx_softdyn's layout with zero velocity and the dynamics run from the groom's rest shape instead of a straight
76// hang. Integer, seeded, bit-exact. Every bound is named and every refusal is announced.
77const GR_MAXS: i64 = 512 // strands in the table
78const GR_MAXG: i64 = 64 // guides
79const GR_MAXSEG: i64 = 32
80const GR_KNN: i64 = 3 // guides a child interpolates
81const GR_WQ: i64 = 1024 // weight scale
82const GR_SWAY_PERMIL: i64 = 400 // a guide's sway at the tip, permil of seglen
83const GR_HANG_PERMIL: i64 = 980 // vertical run per segment, permil of seglen (the rest is sway)
84const GR_JIT_CAP_PERMIL: i64 = 300 // the most jitter a caller may ask for, permil of seglen at the tip
85const GR_LCG_A: i64 = 48271
86const GR_LCG_M: i64 = 2147483647
87const GR_DIR_Q: i64 = 1024
88const GR_DIR_MIN2: i64 = 262144 // |p|^2 >= (0.5 Q)^2: rejection floor so a normalised direction is well conditioned
89const GR_DIR_MAX2: i64 = 1048576 // |p|^2 <= Q^2
90const GR_ERRFD: i64 = 2
91const GR_E_ARG: i64 = 3
92static GR_BUF: i64 // strands * GR_MAXSEG * 3 (x, y, z Q8), segment 0 = the first joint below the root
93static GR_ROOT: i64 // strands * 3
94static GR_LVL: i64 // strands
95static GR_N: i64
96static GR_NG: i64
97static GR_NSEG: i64
98static GR_SEGLEN: i64
99static GR_SEED: i64
100func gr_err(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } sys_write(GR_ERRFD, s, n); return 0 }
101func gr_rand() -> i64 {
102 if GR_SEED <= 0 { GR_SEED = 1 }
103 GR_SEED = (GR_SEED * GR_LCG_A) % GR_LCG_M
104 return GR_SEED
105}
106func gr_range(lo: i64, hi: i64) -> i64 { return lo + gr_rand() % (hi - lo + 1) }
107func gr_alloc() -> i64 {
108 if GR_BUF == 0 {
109 GR_BUF = sys_mmap(GR_MAXS*GR_MAXSEG*3*8) as i64
110 GR_ROOT = sys_mmap(GR_MAXS*3*8) as i64
111 GR_LVL = sys_mmap(GR_MAXS*8) as i64
112 }
113 return 0
114}
115func gr_count() -> i64 { return GR_N }
116func gr_guides() -> i64 { return GR_NG }
117func gr_nseg() -> i64 { return GR_NSEG }
118func gr_level(k: i64) -> i64 { let l: *i64 = GR_LVL as *i64; return l[k] }
119func gr_rootx(k: i64) -> i64 { let r: *i64 = GR_ROOT as *i64; return r[k*3] }
120func gr_rooty(k: i64) -> i64 { let r: *i64 = GR_ROOT as *i64; return r[k*3+1] }
121func gr_rootz(k: i64) -> i64 { let r: *i64 = GR_ROOT as *i64; return r[k*3+2] }
122func gr_x(k: i64, j: i64) -> i64 { let b: *i64 = GR_BUF as *i64; return b[(k*GR_MAXSEG + j)*3] }
123func gr_y(k: i64, j: i64) -> i64 { let b: *i64 = GR_BUF as *i64; return b[(k*GR_MAXSEG + j)*3 + 1] }
124func gr_z(k: i64, j: i64) -> i64 { let b: *i64 = GR_BUF as *i64; return b[(k*GR_MAXSEG + j)*3 + 2] }
125func gr_set(k: i64, j: i64, x: i64, y: i64, z: i64) -> i64 {
126 let b: *i64 = GR_BUF as *i64
127 b[(k*GR_MAXSEG + j)*3] = x; b[(k*GR_MAXSEG + j)*3 + 1] = y; b[(k*GR_MAXSEG + j)*3 + 2] = z
128 return 0
129}
130// a seeded root on the upper hemisphere of the scalp: rejection-sampled direction, normalised without trig
131func gr_scalp_root(cx: i64, cy: i64, cz: i64, r: i64, out: *i64) -> i64 {
132 var tries: i64 = 0
133 var done: i64 = 0
134 while tries < 64 {
135 if done == 0 {
136 let px: i64 = gr_range(0 - GR_DIR_Q, GR_DIR_Q)
137 let py: i64 = gr_range(0, GR_DIR_Q)
138 let pz: i64 = gr_range(0 - GR_DIR_Q, GR_DIR_Q)
139 let l2: i64 = px*px + py*py + pz*pz
140 if l2 >= GR_DIR_MIN2 { if l2 <= GR_DIR_MAX2 {
141 let l: i64 = sd_isqrt(l2)
142 out[0] = cx + px*r/l
143 out[1] = cy + py*r/l
144 out[2] = cz + pz*r/l
145 done = 1
146 } }
147 }
148 tries = tries + 1
149 }
150 if done == 0 { out[0] = cx; out[1] = cy + r; out[2] = cz }
151 return done
152}
153// a guide's shape: hang down GR_HANG_PERMIL per segment with a seeded sway that grows quadratically toward the tip
154func gr_shape_guide(k: i64, nseg: i64, seglen: i64) -> i64 {
155 let swmax: i64 = seglen*GR_SWAY_PERMIL/1000
156 let sx: i64 = gr_range(0 - swmax, swmax)
157 let sz: i64 = gr_range(0 - swmax, swmax)
158 var j: i64 = 0
159 while j < nseg {
160 let t: i64 = (j + 1)*(j + 1)
161 let nn: i64 = nseg*nseg
162 gr_set(k, j, gr_rootx(k) + sx*t/nn, gr_rooty(k) - seglen*GR_HANG_PERMIL*(j + 1)/1000, gr_rootz(k) + sz*t/nn)
163 j = j + 1
164 }
165 return 0
166}
167// interpolate one child at a root against the guides now in the table: inverse-distance weights over the GR_KNN
168// nearest guide roots, applied to the guides' OFFSETS from their own roots, plus a bounded jitter drawn from jseed
169func gr_child_at(rx: i64, ry: i64, rz: i64, jitter_permil: i64, jseed: i64, out: *i64) -> i64 {
170 if GR_NG < 1 { return 0 - 1 }
171 let idx: *i64 = sys_mmap(GR_KNN*8) as *i64
172 let dst: *i64 = sys_mmap(GR_KNN*8) as *i64
173 var kn: i64 = 0
174 var g: i64 = 0
175 while g < GR_NG {
176 let dx: i64 = rx - gr_rootx(g)
177 let dy: i64 = ry - gr_rooty(g)
178 let dz: i64 = rz - gr_rootz(g)
179 let d: i64 = sd_isqrt(dx*dx + dy*dy + dz*dz)
180 // insert into the sorted nearest-list
181 var pos: i64 = kn
182 while pos > 0 { if dst[pos - 1] > d { pos = pos - 1 } else { pos = 0 - pos } }
183 if pos < 0 { pos = 0 - pos }
184 if pos < GR_KNN {
185 var m: i64 = kn
186 if m >= GR_KNN { m = GR_KNN - 1 }
187 while m > pos { idx[m] = idx[m - 1]; dst[m] = dst[m - 1]; m = m - 1 }
188 idx[pos] = g
189 dst[pos] = d
190 if kn < GR_KNN { kn = kn + 1 }
191 }
192 g = g + 1
193 }
194 // weights: Q / (d + 1), normalised to GR_WQ; a zero distance takes the whole weight
195 let w: *i64 = sys_mmap(GR_KNN*8) as *i64
196 var wsum: i64 = 0
197 var i: i64 = 0
198 while i < kn {
199 if dst[i] == 0 { w[i] = GR_WQ*GR_WQ } else { w[i] = GR_WQ*GR_WQ/(dst[i] + 1) }
200 wsum = wsum + w[i]
201 i = i + 1
202 }
203 if dst[0] == 0 { i = 1; while i < kn { w[i] = 0; i = i + 1 } wsum = w[0] }
204 var jit: i64 = jitter_permil
205 if jit > GR_JIT_CAP_PERMIL { jit = GR_JIT_CAP_PERMIL }
206 if jit < 0 { jit = 0 }
207 let amp: i64 = GR_SEGLEN*jit/1000
208 var seed: i64 = jseed
209 if seed <= 0 { seed = 1 }
210 var j: i64 = 0
211 while j < GR_NSEG {
212 var ox: i64 = 0
213 var oy: i64 = 0
214 var oz: i64 = 0
215 i = 0
216 while i < kn {
217 let gi: i64 = idx[i]
218 ox = ox + w[i]*(gr_x(gi, j) - gr_rootx(gi))/wsum
219 oy = oy + w[i]*(gr_y(gi, j) - gr_rooty(gi))/wsum
220 oz = oz + w[i]*(gr_z(gi, j) - gr_rootz(gi))/wsum
221 i = i + 1
222 }
223 var jx: i64 = 0
224 var jz: i64 = 0
225 if amp > 0 {
226 seed = (seed * GR_LCG_A) % GR_LCG_M
227 jx = (seed % (2*amp + 1) - amp)*(j + 1)/GR_NSEG
228 seed = (seed * GR_LCG_A) % GR_LCG_M
229 jz = (seed % (2*amp + 1) - amp)*(j + 1)/GR_NSEG
230 }
231 out[j*3] = rx + ox + jx
232 out[j*3 + 1] = ry + oy
233 out[j*3 + 2] = rz + oz + jz
234 j = j + 1
235 }
236 return kn
237}
238func gr_strand(seed: i64, cx: i64, cy: i64, cz: i64, scalp_r: i64, nguides: i64, nchild: i64, nseg: i64, seglen: i64, jitter_permil: i64) -> i64 {
239 gr_alloc()
240 if nguides < 1 { gr_err("GROOM-REFUSE at least one guide\n" as *u8); return 0 - GR_E_ARG }
241 if nguides > GR_MAXG { gr_err("GROOM-REFUSE guide capacity GR_MAXG\n" as *u8); return 0 - GR_E_ARG }
242 if nchild < 0 { gr_err("GROOM-REFUSE child count must not be negative\n" as *u8); return 0 - GR_E_ARG }
243 if nguides + nchild > GR_MAXS { gr_err("GROOM-REFUSE strand capacity GR_MAXS\n" as *u8); return 0 - GR_E_ARG }
244 if nseg < 1 { gr_err("GROOM-REFUSE at least one segment\n" as *u8); return 0 - GR_E_ARG }
245 if nseg > GR_MAXSEG { gr_err("GROOM-REFUSE segment capacity GR_MAXSEG\n" as *u8); return 0 - GR_E_ARG }
246 if seglen < 1 { gr_err("GROOM-REFUSE segment length must be positive\n" as *u8); return 0 - GR_E_ARG }
247 if scalp_r < 1 { gr_err("GROOM-REFUSE scalp radius must be positive\n" as *u8); return 0 - GR_E_ARG }
248 GR_SEED = seed
249 GR_N = 0
250 GR_NG = nguides
251 GR_NSEG = nseg
252 GR_SEGLEN = seglen
253 let root: *i64 = GR_ROOT as *i64
254 let lvl: *i64 = GR_LVL as *i64
255 let tmp: *i64 = sys_mmap(32) as *i64
256 var k: i64 = 0
257 while k < nguides {
258 gr_scalp_root(cx, cy, cz, scalp_r, tmp)
259 root[k*3] = tmp[0]; root[k*3+1] = tmp[1]; root[k*3+2] = tmp[2]
260 lvl[k] = 0
261 gr_shape_guide(k, nseg, seglen)
262 k = k + 1
263 }
264 GR_N = nguides
265 let child: *i64 = sys_mmap(GR_MAXSEG*3*8) as *i64
266 var c: i64 = 0
267 while c < nchild {
268 let s: i64 = nguides + c
269 gr_scalp_root(cx, cy, cz, scalp_r, tmp)
270 root[s*3] = tmp[0]; root[s*3+1] = tmp[1]; root[s*3+2] = tmp[2]
271 lvl[s] = 1
272 let jseed: i64 = gr_rand()
273 gr_child_at(tmp[0], tmp[1], tmp[2], jitter_permil, jseed, child)
274 var j: i64 = 0
275 while j < nseg { gr_set(s, j, child[j*3], child[j*3+1], child[j*3+2]); j = j + 1 }
276 c = c + 1
277 }
278 GR_N = nguides + nchild
279 return GR_N
280}
281// seat strand k into a softdyn chain state (nseg segments, velocities zero): the dynamics start from the groom
282func gr_to_chain(st: *i64, k: i64) -> i64 {
283 var j: i64 = 0
284 while j < GR_NSEG {
285 let b: i64 = j*SD_STRIDE
286 st[b] = gr_x(k, j)
287 st[b+1] = gr_y(k, j)
288 st[b+2] = gr_z(k, j)
289 st[b+3] = 0
290 st[b+4] = 0
291 st[b+5] = 0
292 j = j + 1
293 }
294 return GR_NSEG
295}
296// the length of segment j of strand k (segment 0 runs from the root)
297func gr_seglen_at(k: i64, j: i64) -> i64 {
298 var ax: i64 = gr_rootx(k)
299 var ay: i64 = gr_rooty(k)
300 var az: i64 = gr_rootz(k)
301 if j > 0 { ax = gr_x(k, j - 1); ay = gr_y(k, j - 1); az = gr_z(k, j - 1) }
302 let dx: i64 = gr_x(k, j) - ax
303 let dy: i64 = gr_y(k, j) - ay
304 let dz: i64 = gr_z(k, j) - az
305 return sd_isqrt(dx*dx + dy*dy + dz*dz)
306}
307
308// ---- DERIVATION: COMPOSED, NEVER RE-IMPLEMENTED ----
309func hd_k(fn_mhz: i64) -> i64 { return sb_k_sym(fn_mhz) }
310func hd_c(K: i64, zeta_permil: i64) -> i64 { return sb_c_for_zeta(K, zeta_permil) }
311
312// ---- CHAIN STATE. softdyn owns the layout; this lib only indexes it, exactly as nx_softbind's
313// own accessors do. All positions Q8, so a chain never truncates its own coupling. ----
314func hd_alloc(nseg: i64) -> *i64 { return sd_alloc(nseg) }
315func hd_x_q8(st: *i64, i: i64) -> i64 { return st[i*SD_STRIDE] }
316func hd_y_q8(st: *i64, i: i64) -> i64 { return st[i*SD_STRIDE + 1] }
317func hd_tip_x_q8(st: *i64, nseg: i64) -> i64 { return st[(nseg - 1)*SD_STRIDE] }
318
319// seat the strand hanging straight down from the root, at rest (no startup transient)
320func hd_seat(st: *i64, nseg: i64, rx: i64, ry: i64, rz: i64, seglen: i64) -> i64 {
321 var i: i64 = 0
322 while i < nseg {
323 let b: i64 = i*SD_STRIDE
324 st[b] = rx
325 st[b+1] = ry - (i + 1)*seglen
326 st[b+2] = rz
327 st[b+3] = 0
328 st[b+4] = 0
329 st[b+5] = 0
330 i = i + 1
331 }
332 return 0
333}
334
335// ONE TICK OF THE STRAND. Segment 0 hangs off the ROOT; segment i hangs off segment i-1's CURRENT
336// position. That one-hop coupling is what makes this a chain rather than N independent springs
337// wearing a loop, and it is why the tip LAGS the root -- the property nx_hairdyn_gate measures
338// with rd_phase_lag_permil, because a lag that grows with chain length is something N copies of
339// one spring can never produce.
340func hd_tick(st: *i64, nseg: i64, rx: i64, ry: i64, rz: i64, seglen: i64,
341 K: i64, C: i64, maxd: i64) -> i64 {
342 var ax: i64 = rx
343 var ay: i64 = ry
344 var az: i64 = rz
345 var i: i64 = 0
346 while i < nseg {
347 sd_step_axq(st, i, ax, ay - seglen, az, K, K, K, K, C, C, C, C, maxd)
348 let b: i64 = i*SD_STRIDE
349 ax = st[b]
350 ay = st[b+1]
351 az = st[b+2]
352 i = i + 1
353 }
354 return 0
355}
356
357// HAIR AGAINST THE BODY. The operator's REJECT row of 2026-08-30 states the defect in four words --
358// "hair clips the body" -- and hd_tick above STRUCTURALLY CANNOT ANSWER IT: its only spatial bound is
359// maxd, a RADIAL CLAMP about the anchor. A clamp limits how far a segment may travel; it says nothing
360// about what the segment travels THROUGH.
361// * A CLAMP IS NOT A COLLIDER, AND THE TWO ARE EASY TO MISTAKE BECAUSE BOTH BOUND A POSITION.
362//
363// caps is a flat array of ncap capsules, SEVEN i64 each: ax ay az bx by bz r. After each segment's spring
364// step the position is pushed out of every capsule it has entered, through vm_capsule_push -- the ONE
365// collider in nx_vecmath -- rather than a fourth private copy of that geometry (it already existed three
366// times: inline in sj_step, and as ss_capsule in both nx_skullsdf trees).
367//
368// hd_tick ITSELF IS UNCHANGED AND STILL SHIPS. This is a SIBLING, not an edit, and that is deliberate:
369// the bake path in nx_game_page_emit and nx_hairdyn_gate's ringdown and phase-lag measurements assert
370// hd_tick's EXACT output, so folding a collider into it would silently move every baked frame and every
371// number that gate checks -- a behaviour change wearing a bug fix's clothes.
372//
373// * THE RETURN VALUE IS CONTACTS **RESOLVED**, NOT DEFECTS FOUND, AND THE DISTINCTION IS NOT PEDANTIC:
374// __nxa.clip on the rigview page carries exactly this quantity, was read as a defect count on
375// 2026-09-03, and driving it to zero would have DELETED the collision response rather than fixed the
376// clipping. A rising count here means the collider is WORKING harder, not that the hair is worse.
377// Anything that wants "is the hair still inside the body" must measure the state AFTER this returns.
378// scratch is a CALLER-OWNED buffer of at least 3 i64. It is a parameter and not an internal sys_mmap
379// DELIBERATELY, and the reason is the wasm lane rather than style: the emitted $nx_wasm_mmap is a
380// STATELESS GROW-PER-ALLOCATION helper (pages = ceil(n/64K), base = memory.grow(pages)*64K), so an
381// allocation inside this function would grow the module's linear memory ONCE PER CALL -- at 60 Hz across
382// a 12-girl cast that is ~720 memory.grow calls per second, unbounded, on the exact path GE21 exists to
383// move into wasm. The defect would not appear at all in the native build and would be catastrophic the
384// moment the solver crossed into the browser.
385// * NEVER ALLOCATE IN A HOT LOOP -- AND "HOT" IS A PROPERTY OF THE TARGET, NOT OF THE SOURCE.
386func hd_tick_collide(st: *i64, nseg: i64, rx: i64, ry: i64, rz: i64, seglen: i64,
387 K: i64, C: i64, maxd: i64, caps: *i64, ncap: i64, scratch: *i64) -> i64 {
388 let hit: *i64 = scratch
389 var ax: i64 = rx
390 var ay: i64 = ry
391 var az: i64 = rz
392 var i: i64 = 0
393 var resolved: i64 = 0
394 while i < nseg {
395 sd_step_axq(st, i, ax, ay - seglen, az, K, K, K, K, C, C, C, C, maxd)
396 let b: i64 = i*SD_STRIDE
397 var ci: i64 = 0
398 while ci < ncap {
399 let c: i64 = ci*7
400 if vm_capsule_push(st[b], st[b+1], st[b+2],
401 caps[c], caps[c+1], caps[c+2],
402 caps[c+3], caps[c+4], caps[c+5], caps[c+6], hit) == 1 {
403 st[b] = hit[0]
404 st[b+1] = hit[1]
405 st[b+2] = hit[2]
406 resolved = resolved + 1
407 }
408 ci = ci + 1
409 }
410 ax = st[b]
411 ay = st[b+1]
412 az = st[b+2]
413 i = i + 1
414 }
415 return resolved
416}
417
418// ---- THE RIGID CONTROL: hair with the dynamics TAKEN OUT. Every segment is written straight to
419// its rest pose under its parent each tick. This is the trivial wrong implementation -- the one
420// that still "has hair" in the sense that the joints exist and follow the head, and that reads on
421// screen as a helmet. It is deliberately NOT sd_step at a huge K, because that is still the
422// solver and would still ring, just fast: it is the ABSENCE of the solver. nx_hairdyn_gate
423// requires that this CANNOT yield a ringdown, because a plant measurement that passes on a rigid
424// chain is measuring nothing.
425func hd_tick_rigid(st: *i64, nseg: i64, rx: i64, ry: i64, rz: i64, seglen: i64) -> i64 {
426 var ax: i64 = rx
427 var ay: i64 = ry
428 var az: i64 = rz
429 var i: i64 = 0
430 while i < nseg {
431 let b: i64 = i*SD_STRIDE
432 st[b] = ax
433 st[b+1] = ay - seglen
434 st[b+2] = az
435 st[b+3] = 0
436 st[b+4] = 0
437 st[b+5] = 0
438 ax = st[b]
439 ay = st[b+1]
440 az = st[b+2]
441 i = i + 1
442 }
443 return 0
444}
445
446// ---- RINGDOWN ----
447const HD_RD_SAMPLES: i64 = 768
448const HD_RD_SETTLE: i64 = 240
449// DEADBAND: nx_softdyn's OWN documented truncation quantum. Both force terms divide by SD_G and
450// truncate toward zero, so the solver has an exact dead zone in which it applies no force at all
451// -- sd_deadsnap exists for precisely that reason and snaps to rest inside it. One Q8 count is
452// that quantum. It is the solver's number, cited; not a threshold chosen to make a trace look
453// clean, which is the magic number this estate refuses.
454const HD_QUANT_Q8: i64 = 1
455
456// EXCITATION derived from the banked clamp, never picked. nx_softbind's SB_MAXD_HAIR is the hard
457// displacement limit hair may never exceed, so HALF of it is the largest excitation guaranteed to
458// stay inside the linear regime that clamp defines. An amplitude at or above the clamp would be
459// measuring the clamp instead of the spring, and would report the clamp's shape as the plant's.
460func hd_amp_q8() -> i64 { return SB_MAXD_HAIR*SD_Q8/2 }
461
462// Measure an ARBITRARY (K,C) pair. This is the primitive, because the first question asked of
463// this lib is "what IS the plant we already ship" -- two hand-picked integers that were never
464// derived from a frequency at all. Answering that must not require pretending they were.
465// sig_tip / sig_root may be 0; when given they receive the traces so a caller can correlate them
466// (phase lag) or draw them. The trace is the evidence a reader can check instead of trusting.
467func hd_ringdown_kc_t(nseg: i64, seglen: i64, K: i64, C: i64,
468 out: *i64, sig_tip: *i64, sig_root: *i64) -> i64 {
469 let st: *i64 = hd_alloc(nseg)
470 hd_seat(st, nseg, 0, 0, 0, seglen)
471 var s: i64 = 0
472 while s < HD_RD_SETTLE {
473 hd_tick(st, nseg, 0, 0, 0, seglen, K, C, SB_MAXD_HAIR)
474 s = s + 1
475 }
476 // DISPLACE THE WHOLE STRAND LATERALLY, then hold the root still and let it swing back.
477 // Moving every segment by the SAME amount strains only the root joint -- each other segment
478 // travelled with its own parent -- which is the fundamental SWAY shape. Displacing one
479 // segment instead would excite a shear mode real hair does not have.
480 let amp: i64 = hd_amp_q8()
481 var d: i64 = 0
482 while d < nseg {
483 st[d*SD_STRIDE] = st[d*SD_STRIDE] + amp
484 d = d + 1
485 }
486 // THE PLANT IS THE SEGMENT, NOT THE TIP -- MEASURED THE HARD WAY, 2026-08-25.
487 // The first version of this routine analysed the TIP's absolute displacement and the analyser
488 // refused it on every single configuration: rc=-4 UNOBSERVABLE-second-lobe-not-smaller, with
489 // a1=767 a2=1357 -- the second lobe nearly DOUBLE the first. That refusal was correct and it
490 // was the instrument catching a real error of subject. Displacing the strand rigidly strains
491 // only the ROOT joint (every other segment travelled with its own parent), so the tip is the
492 // DRIVEN end: energy has to propagate down the chain to reach it, and its amplitude therefore
493 // GROWS for the first few periods before it decays. That is not a free decay, and no damping
494 // ratio can be identified from it -- the logarithmic decrement is not merely inaccurate there,
495 // it is undefined, which is exactly why rd_analyze returns a third state instead of a number.
496 // SEGMENT 0 is the free decay: its anchor is the root, the root is held still, and it is
497 // released from its maximum strain. That is the one-degree-of-freedom oscillator sb_k_sym
498 // actually describes, so it is the only signal whose measured fn is comparable to the
499 // requested one. The tip keeps its own job below -- it is the CHAIN evidence (phase lag),
500 // which is a property of propagation and not of the plant.
501 let sig: *i64 = sys_mmap(HD_RD_SAMPLES*8) as *i64
502 var i: i64 = 0
503 while i < HD_RD_SAMPLES {
504 hd_tick(st, nseg, 0, 0, 0, seglen, K, C, SB_MAXD_HAIR)
505 sig[i] = hd_x_q8(st, 0)
506 if (sig_root as i64) != 0 { sig_root[i] = hd_x_q8(st, 0) }
507 if (sig_tip as i64) != 0 { sig_tip[i] = hd_tip_x_q8(st, nseg) }
508 i = i + 1
509 }
510 return rd_analyze(sig, HD_RD_SAMPLES, hd_dt_us(), HD_QUANT_Q8, out)
511}
512
513func hd_ringdown_kc(nseg: i64, seglen: i64, K: i64, C: i64, out: *i64) -> i64 {
514 return hd_ringdown_kc_t(nseg, seglen, K, C, out, 0 as *i64, 0 as *i64)
515}
516
517// DERIVED plant: a target frequency and damping ratio in, a measured plant out. The whole point
518// of the lane -- ask for 3500 mhz and the ringdown should read back 3500 mhz.
519func hd_ringdown_t(nseg: i64, seglen: i64, fn_mhz: i64, zeta_permil: i64,
520 out: *i64, sig_tip: *i64, sig_root: *i64) -> i64 {
521 let K: i64 = hd_k(fn_mhz)
522 return hd_ringdown_kc_t(nseg, seglen, K, hd_c(K, zeta_permil), out, sig_tip, sig_root)
523}
524
525func hd_ringdown(nseg: i64, seglen: i64, fn_mhz: i64, zeta_permil: i64, out: *i64) -> i64 {
526 return hd_ringdown_t(nseg, seglen, fn_mhz, zeta_permil, out, 0 as *i64, 0 as *i64)
527}
528
529// The rigid control's ringdown. Same excitation, same window, same analyser, no solver.
530func hd_ringdown_rigid(nseg: i64, seglen: i64, out: *i64) -> i64 {
531 let st: *i64 = hd_alloc(nseg)
532 hd_seat(st, nseg, 0, 0, 0, seglen)
533 var s: i64 = 0
534 while s < HD_RD_SETTLE {
535 hd_tick_rigid(st, nseg, 0, 0, 0, seglen)
536 s = s + 1
537 }
538 let amp: i64 = hd_amp_q8()
539 var d: i64 = 0
540 while d < nseg {
541 st[d*SD_STRIDE] = st[d*SD_STRIDE] + amp
542 d = d + 1
543 }
544 let sig: *i64 = sys_mmap(HD_RD_SAMPLES*8) as *i64
545 var i: i64 = 0
546 while i < HD_RD_SAMPLES {
547 hd_tick_rigid(st, nseg, 0, 0, 0, seglen)
548 // SAME SIGNAL AS THE REAL MEASUREMENT (segment 0), deliberately. A control that samples a
549 // different channel than the positive case is not a control -- it could fail for a reason
550 // that has nothing to do with the property under test, and its green would prove nothing
551 // about the measurement it is supposed to guard.
552 sig[i] = hd_x_q8(st, 0)
553 i = i + 1
554 }
555 return rd_analyze(sig, HD_RD_SAMPLES, hd_dt_us(), HD_QUANT_Q8, out)
556}