code wiki / (root) / nx_hairdyn_lib.nx

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}