nx_dyna_bind_skin.nx source
↩ module page · 715 lines · 33586 B
1// nx_dyna_bind_skin.nx -- BIND THE XPBD SOFT-TISSUE CAGE TO SKIN VERTICES, AND MEASURE OUR OWN
2// PLANT IN THE SUBJECT'S UNITS.
3//
4// WHY THIS EXISTS. `dyna_bind_skin` is OPEN on the charsim and graphics boards. DYNA already
5// DECLARES per-bone soft-tissue contracts in the asset (nx_nxa_dyna: anchor, axis, k, c, travel
6// clamp, influence radius, falloff, all derived FROM THE MESH) and nx_softtissue already SOLVES
7// tissue as XPBD over a tetrahedral cage with per-layer compliance. Nothing joined them: the
8// declared physics never reached a skin vertex, so the shipped path was still a shader band.
9//
10// WHY XPBD, NOT A MASS-SPRING RIG -- the model choice, justified against the alternatives, because
11// the operator's bar is that the motion be best-of-breed and that previous attempts were awful:
12// * XPBD (Macklin, Muller, Chentanez, Kim -- "XPBD: Position-Based Simulation of Compliant
13// Constrained Dynamics", ACM SIGGRAPH Motion in Games 2016) integrates by POSITION PROJECTION,
14// so it cannot detonate the way an explicit force solver does at stiff settings. Its
15// compliance alpha is a genuine material parameter (inverse stiffness) and alpha_tilde =
16// alpha/dt^2 makes effective stiffness STEP-SIZE INDEPENDENT. That property is precisely the
17// cure for the buzzing/framerate-dependence failure mode, and plain PBD does not have it.
18// * The estate ALREADY MEASURED why the previous attempt was awful. nx_softtissue's header
19// records that the rigmesh viewer drove breasts with a 2-mass EMA spring, and that "an
20// oscillator has no VOLUME, no LAYERS and no relationship to gravity DIRECTION" -- so it
21// physically cannot reproduce supine flattening. A damped oscillator is a POINT; tissue is a
22// VOLUME. Rejected on measured evidence, not taste.
23// * Shape-matching lattice deformers (Muller 2005) are the other cheap production option:
24// unconditionally stable, but they preserve no volume and their stiffness is a blend factor
25// with no material meaning -- so their parameters CANNOT be sourced from tissue literature,
26// which this estate requires of every number. Rejected on sourcing.
27// * FEM production stacks (Chaos Flesh, Ziva, DMM) have better constitutive fidelity at far
28// higher cost and none is reachable as a sovereign integer solver. XPBD is the tier that is
29// both principled and ours.
30//
31// WHAT "BOUND TO SKIN VERTICES" MEANS HERE. The cage is a physics proxy; the skin mesh is the
32// render surface. Binding is an EMBEDDING: a skin vertex inside a DYNA region takes its
33// displacement from the cage, weighted by that region's OWN declared falloff over its OWN declared
34// influence radius. A vertex outside every region is SKIN-LOCKED -- it moves with the rig and
35// nothing else. That split IS the capability, and it is what the gate discriminates: soft moves,
36// rigid does not. Both signals must be present at once or the tooth proves nothing.
37//
38// FRAMES DIFFER AND THAT HAS BURNED THIS ESTATE BEFORE -- SO IT IS STATED, NOT ASSUMED.
39// NXA asset frame : +z is the STATURE axis, y<0 is the FRONT hemisphere (nx_nxa_dyna relies on
40// this and says so; the floor gate and the shader agree).
41// nx_softtissue : "+x lateral, +y cranial, +z anterior" (its own header).
42// therefore st_x = nxa_x , st_y = nxa_z , st_z = -nxa_y.
43// The mapping is applied in ONE function (dbs_nxa_to_st) so it cannot drift between call sites.
44//
45// EVERY PARAMETER IS DERIVED, CITED, OR PASSED IN -- NONE IS TASTED:
46// region mask <- DYNA anchor + influence_radius + falloff, already measured from the mesh
47// per-asset, so this works across the whole corpus and hardcodes no index.
48// excitation <- passed as an argument; the GATE supplies it from
49// knowledge/gamefeel_oracle.conf row tissue_amp_walk_mm (the measured
50// unsupported walking resultant). We excite with a REAL amplitude.
51// substeps <- ST_DT_REF_US, the solver's own reference step. Not a number of ours.
52// verdicts <- NONE. This organ prints measurements only. An uncalibrated instrument
53// reports numbers, never verdicts; the bands live in the conf and the
54// judging lives in the gate. That is why no band appears in this file.
55//
56// THE RINGDOWN VERB IS THE INSTRUMENT knowledge/gamefeel_oracle.conf DECLARES MANDATORY. That conf
57// states in its own words that solver tunables (chest_k_q10, chest_zeta_permil) and subject rows
58// (tissue_fn_mhz, tissue_zeta_permil) are DIFFERENT QUANTITIES, and that "UNTIL THAT PROBE
59// REPORTS, NO TUNABLE MAY BE COMPARED TO A SUBJECT ROW". So we never compare tunables. We displace
60// our own plant by a cited amplitude, release it, and measure the FREE RESPONSE -- natural
61// frequency in millihertz and damping ratio in permil, the same units the subject rows use. That
62// is the only honest bridge between our solver and the biomechanics literature.
63//
64// nx_dyna_bind_skin ringdown <profile> <stature_mm> <amp_cmm>
65// nx_dyna_bind_skin bind <in.nxa>
66// nx_dyna_bind_skin selftest
67//
68// license_tier: ORIGINAL No hw writes (Rule 26). expect_exit: 0
69import "nx_syscalls.nx"
70import "nx_nxa.nx"
71import "nx_softtissue.nx"
72
73const DBS_OUTFD: i64 = 1
74const DBS_ERRFD: i64 = 2
75const DBS_EXIT_OK: i64 = 0
76const DBS_EXIT_IO: i64 = 1
77const DBS_EXIT_USAGE: i64 = 2
78const DBS_EXIT_BAD: i64 = 3
79
80const DBS_Q16: i64 = 65536
81const DBS_Q8: i64 = 256
82const DBS_PERMIL: i64 = 1000
83const DBS_US_PER_S: i64 = 1000000
84const DBS_MHZ_PER_HZ: i64 = 1000
85
86// ln(2) and 2*pi in q16. Mathematical constants, not tunables.
87const DBS_LN2_Q16: i64 = 45426
88const DBS_TWOPI_Q16: i64 = 411775
89
90// NXA header/TOC geometry, same as every other NXA reader in the estate.
91const DBS_HDR: i64 = 32
92const DBS_TOCE: i64 = 32
93const DBS_MAXSEC: i64 = 64
94// DYNA payload layout, from nx_nxa_dyna's own writer.
95const DBS_DY_STRIDE: i64 = 12
96const DBS_DY_SIDE: i64 = 0
97const DBS_DY_AX: i64 = 1
98const DBS_DY_AY: i64 = 2
99const DBS_DY_AZ: i64 = 3
100const DBS_DY_MAXD: i64 = 6
101const DBS_DY_INFL: i64 = 7
102const DBS_DY_FALL: i64 = 8
103
104// Ringdown sampling: one sample per solver reference step, long enough to hold several periods of
105// the slowest mode we could plausibly see. Bounded so the buffer is fixed and the run terminates.
106const DBS_RD_MAXSAMP: i64 = 768
107const DBS_RD_ITERS: i64 = 8
108const DBS_RD_SETTLE: i64 = 240
109
110func dbs_slen(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n }
111func dbs_out(s: *u8) -> i64 { sys_write(DBS_OUTFD, s, dbs_slen(s)); return 0 }
112func dbs_err(s: *u8) -> i64 { sys_write(DBS_ERRFD, s, dbs_slen(s)); return 0 }
113func dbs_abs(v: i64) -> i64 { if v < 0 { return 0 - v } return v }
114
115func dbs_num(v: i64) -> i64 {
116 let buf: *u8 = sys_mmap(32)
117 var n: i64 = v
118 var neg: i64 = 0
119 if n < 0 { neg = 1; n = 0 - n }
120 var i: i64 = 32
121 if n == 0 { i = i - 1; buf[i] = 48 as u8 }
122 while n > 0 {
123 i = i - 1
124 buf[i] = ((n - (n / 10) * 10) + 48) as u8
125 n = n / 10
126 }
127 if neg == 1 { i = i - 1; buf[i] = 45 as u8 }
128 sys_write(DBS_OUTFD, ((buf as i64) + i) as *u8, 32 - i)
129 return 0
130}
131
132func dbs_rd64(b: *u8, off: i64) -> i64 {
133 let p: *i64 = ((b as i64) + off) as *i64
134 return p[0]
135}
136
137func dbs_tageq(b: *u8, off: i64, t: *u8) -> i64 {
138 if dbs_rd64(b, off) == nxa_tag4(t) { return 1 }
139 return 0
140}
141
142func dbs_isqrt(n: i64) -> i64 {
143 if n <= 0 { return 0 }
144 var r: i64 = n
145 var y: i64 = (r + 1) / 2
146 while y < r { r = y; y = (r + n / r) / 2 }
147 return r
148}
149
150// log2 in q16 by bit-length plus a LINEAR mantissa term. Deterministic, allocation-free. The
151// linear-in-octave step is an APPROXIMATION of log2 and is named as one: its worst error inside an
152// octave is about 0.086 in log2. The ringdown therefore PRINTS the two raw peak amplitudes beside
153// the derived zeta, so any reader can recompute the decrement exactly and is never forced to trust
154// this routine. That is the documented imprecision the estate requires a new guard to declare.
155func dbs_log2_q16(x_q16: i64) -> i64 {
156 if x_q16 <= 0 { return 0 }
157 var v: i64 = x_q16
158 var e: i64 = 0
159 while v >= DBS_Q16 * 2 { v = v / 2; e = e + 1 }
160 while v < DBS_Q16 { v = v * 2; e = e - 1 }
161 let frac: i64 = v - DBS_Q16
162 return e * DBS_Q16 + frac
163}
164
165func dbs_ln_q16(x_q16: i64) -> i64 { return dbs_log2_q16(x_q16) * DBS_LN2_Q16 / DBS_Q16 }
166
167// zeta from the logarithmic decrement of two successive same-sign peaks -- the textbook free-decay
168// identity delta = ln(A1/A2), zeta = delta / sqrt(4*pi^2 + delta^2). Reported in permil so it is
169// directly comparable to the tissue_zeta_permil subject row.
170func dbs_zeta_permil(a1: i64, a2: i64) -> i64 {
171 if a2 <= 0 { return DBS_PERMIL }
172 if a1 <= a2 { return 0 }
173 let ratio_q16: i64 = a1 * DBS_Q16 / a2
174 var delta: i64 = dbs_ln_q16(ratio_q16)
175 if delta < 0 { delta = 0 }
176 let fourpi2: i64 = DBS_TWOPI_Q16 * DBS_TWOPI_Q16 / DBS_Q16
177 let dd: i64 = delta * delta / DBS_Q16
178 let root: i64 = dbs_isqrt((fourpi2 + dd) * DBS_Q16)
179 if root <= 0 { return 0 }
180 return delta * DBS_PERMIL / root
181}
182
183// mean y (cranial axis) of the FREE surface particles -- the ringdown observable. Pinned particles
184// (chest wall, inverse mass 0) are excluded because they cannot move and would dilute the signal
185// toward zero, which would read as damping we did not model.
186func dbs_free_surface_y(W: *i64) -> i64 {
187 let py: *i64 = W[ST_W_PY] as *i64
188 let iw: *i64 = W[ST_W_IW] as *i64
189 let srf: *i64 = W[ST_W_SRF] as *i64
190 var sum: i64 = 0
191 var n: i64 = 0
192 var i: i64 = 0
193 while i < W[ST_W_NP] {
194 if srf[i] == 1 {
195 if iw[i] > 0 { sum = sum + py[i]; n = n + 1 }
196 }
197 i = i + 1
198 }
199 if n <= 0 { return 0 }
200 return sum / n
201}
202
203func dbs_displace_free(W: *i64, dy: i64) -> i64 {
204 let py: *i64 = W[ST_W_PY] as *i64
205 let vx: *i64 = W[ST_W_VX] as *i64
206 let vy: *i64 = W[ST_W_VY] as *i64
207 let vz: *i64 = W[ST_W_VZ] as *i64
208 let iw: *i64 = W[ST_W_IW] as *i64
209 var i: i64 = 0
210 while i < W[ST_W_NP] {
211 if iw[i] > 0 { py[i] = py[i] + dy; vx[i] = 0; vy[i] = 0; vz[i] = 0 }
212 i = i + 1
213 }
214 return 0
215}
216
217// RINGDOWN: settle under gravity, displace by a cited amplitude, release, and measure the free
218// response. Fills out[] with the raw evidence AND the derived pair.
219const DBS_R_NSAMP: i64 = 0
220const DBS_R_EQ: i64 = 1
221const DBS_R_A1: i64 = 2
222const DBS_R_A2: i64 = 3
223const DBS_R_PER_US: i64 = 4
224const DBS_R_FN_MHZ: i64 = 5
225const DBS_R_ZETA: i64 = 6
226const DBS_R_PEAKS: i64 = 7
227const DBS_R_MAXABS: i64 = 8
228const DBS_R_RESID: i64 = 9
229const DBS_R_CELL: i64 = 10
230const DBS_R_NP: i64 = 11
231const DBS_R_N: i64 = 12
232
233// The solver's own header states its positions are integer cmm and that "one substep's
234// displacement carries ~1 cmm of rounding". A zero-crossing detector run without a deadband
235// therefore chatters on that quantization: measured 2026-08-23, a first-difference extrema
236// detector returned 338 "peaks" in 768 samples at exactly the sample rate. The deadband is that
237// documented quantum, cited -- not a tuned threshold.
238const DBS_QUANT_CMM: i64 = 1
239
240// Cell size is DERIVED from the solver's own particle budget, never chosen. Take the FINEST cage
241// (smallest lattice step) whose particle count still fits ST_MAXP. This also removes a real misuse
242// trap: st_new's second parameter is a GRID STEP in mm, and passing a stature by mistake yields a
243// one-cell cage that reports all zeros rather than refusing (measured 2026-08-23, on this organ).
244func dbs_derive_cell(prof: i64) -> i64 {
245 let a: i64 = st_profile(prof, ST_PF_A_MM)
246 let b: i64 = st_profile(prof, ST_PF_B_MM)
247 let c: i64 = st_profile(prof, ST_PF_C_MM)
248 if a <= 0 { return 0 }
249 if b <= 0 { return 0 }
250 if c <= 0 { return 0 }
251 var h: i64 = 2
252 var found: i64 = 0
253 while h <= 64 {
254 if found == 0 {
255 let gnx: i64 = (2*a)/h + 1
256 let gny: i64 = (2*b)/h + 1
257 let gnz: i64 = c/h + 1
258 let gtot: i64 = (gnx+1) * (gny+1) * (gnz+1)
259 if gtot <= ST_MAXP { found = h }
260 }
261 h = h + 1
262 }
263 return found
264}
265
266// sigout may be 0; when non-null it receives the sampled decay trace so a caller can DRAW the
267// measurement rather than only quote it. The trace is the evidence a reader can check by eye.
268func dbs_ringdown_t(prof: i64, h_mm: i64, amp_cmm: i64, out: *i64, sigout: *i64) -> i64 {
269 var f: i64 = 0
270 while f < DBS_R_N { out[f] = 0; f = f + 1 }
271 let W: *i64 = st_new(prof, h_mm)
272 if (W as i64) == 0 { return 0 - 1 }
273 st_reset_state(W)
274 let dt: i64 = ST_DT_REF_US
275 // settle to static equilibrium under standing gravity (0,-1,0) as a q12 unit vector
276 var s: i64 = 0
277 while s < DBS_RD_SETTLE { st_substep(W, 0, 0 - ST_Q12, 0, dt, DBS_RD_ITERS); s = s + 1 }
278 let eq: i64 = dbs_free_surface_y(W)
279 out[DBS_R_EQ] = eq
280 dbs_displace_free(W, amp_cmm)
281 // sample the free decay, one sample per reference step
282 let sig: *i64 = sys_mmap(DBS_RD_MAXSAMP * 8) as *i64
283 var i: i64 = 0
284 while i < DBS_RD_MAXSAMP {
285 st_substep(W, 0, 0 - ST_Q12, 0, dt, DBS_RD_ITERS)
286 sig[i] = dbs_free_surface_y(W) - eq
287 i = i + 1
288 }
289 if (sigout as i64) != 0 {
290 var cp: i64 = 0
291 while cp < DBS_RD_MAXSAMP { sigout[cp] = sig[cp]; cp = cp + 1 }
292 }
293 out[DBS_R_NSAMP] = DBS_RD_MAXSAMP
294 var mx: i64 = 0
295 var k: i64 = 0
296 while k < DBS_RD_MAXSAMP { if dbs_abs(sig[k]) > mx { mx = dbs_abs(sig[k]) } k = k + 1 }
297 out[DBS_R_MAXABS] = mx
298 // RESIDUAL: peak excursion over the final eighth of the window. This is reported, never used
299 // to suppress detection: a large residual IS the "jelly / never settles" finding, so folding
300 // it into a noise floor would hide exactly the failure mode the gate exists to catch.
301 var resid: i64 = 0
302 var t: i64 = DBS_RD_MAXSAMP - DBS_RD_MAXSAMP/8
303 while t < DBS_RD_MAXSAMP { if dbs_abs(sig[t]) > resid { resid = dbs_abs(sig[t]) } t = t + 1 }
304 out[DBS_R_RESID] = resid
305
306 // ZERO CROSSINGS, not first-difference extrema. Crossings are robust to the solver's integer
307 // quantization where extrema are not; the deadband is the solver's own documented ~1 cmm
308 // rounding quantum. Amplitude for the decrement comes from the MAX EXCURSION WITHIN each
309 // half-cycle, which is the physically meaningful peak rather than a single noisy sample.
310 var ncross: i64 = 0
311 var c1: i64 = 0 - 1
312 var c2: i64 = 0 - 1
313 var c3: i64 = 0 - 1
314 var sgn: i64 = 0
315 var j: i64 = 0
316 while j < DBS_RD_MAXSAMP {
317 var s2: i64 = 0
318 if sig[j] > DBS_QUANT_CMM { s2 = 1 }
319 if sig[j] < 0 - DBS_QUANT_CMM { s2 = 0 - 1 }
320 if s2 != 0 {
321 if sgn == 0 { sgn = s2 }
322 else {
323 if s2 != sgn {
324 ncross = ncross + 1
325 if c1 < 0 { c1 = j }
326 else { if c2 < 0 { c2 = j } else { if c3 < 0 { c3 = j } } }
327 sgn = s2
328 }
329 }
330 }
331 j = j + 1
332 }
333 out[DBS_R_PEAKS] = ncross
334 // half-cycle maxima: [0,c1) and [c2,c3) are the two same-signed lobes, one full period apart
335 var a1: i64 = 0
336 var a2: i64 = 0
337 if c1 > 0 {
338 var u: i64 = 0
339 while u < c1 { if dbs_abs(sig[u]) > a1 { a1 = dbs_abs(sig[u]) } u = u + 1 }
340 }
341 if c3 > 0 {
342 if c2 > 0 {
343 var v2: i64 = c2
344 while v2 < c3 { if dbs_abs(sig[v2]) > a2 { a2 = dbs_abs(sig[v2]) } v2 = v2 + 1 }
345 }
346 }
347 out[DBS_R_A1] = a1
348 out[DBS_R_A2] = a2
349 if c1 > 0 {
350 if c3 > c1 {
351 // c1 -> c3 spans exactly one full period (two sign changes)
352 let per_us: i64 = (c3 - c1) * dt
353 out[DBS_R_PER_US] = per_us
354 if per_us > 0 { out[DBS_R_FN_MHZ] = DBS_US_PER_S * DBS_MHZ_PER_HZ / per_us }
355 out[DBS_R_ZETA] = dbs_zeta_permil(a1, a2)
356 }
357 }
358 return 0
359}
360
361func dbs_ringdown(prof: i64, h_mm: i64, amp_cmm: i64, out: *i64) -> i64 {
362 return dbs_ringdown_t(prof, h_mm, amp_cmm, out, 0 as *i64)
363}
364
365// ---------------------------------------------------------------------------------------------
366// SVG: DRAW the ringdown. A number in a report is not the operator seeing a jiggle, and the decay
367// trace is the honest picture of a ringdown -- it is what a biomechanics paper plots. Five
368// conditions on one shared time axis:
369// the LINEAR-LIMIT pair (+-320 cmm) -- in band on fn AND zeta, both branches
370// the 32 mm pair (+-3200 cmm) -- out of band, the over-driven regime
371// GLUTE at 32 mm -- never completes a period, so it reports UNOBSERVABLE
372// Each panel carries its OWN y-scale, printed in its label, so no false visual comparison is made
373// between panels of different amplitude. The x axis is shared and real (sample index x dt).
374// Canvas 1920x1200: the gallery target the capture wire records for this surface.
375const DBS_SVG_W: i64 = 1920
376const DBS_SVG_H: i64 = 1200
377const DBS_SVG_ROWS: i64 = 5
378const DBS_SVG_PLOT_L: i64 = 300
379const DBS_SVG_PLOT_R: i64 = 1880
380const DBS_SVG_TOP: i64 = 70
381const DBS_SVG_BOT_MARGIN: i64 = 40
382const DBS_SVG_LABEL_X: i64 = 40
383const DBS_SVG_TITLE_Y: i64 = 44
384const DBS_SVG_FOOT_Y: i64 = 1180
385const DBS_SVG_PX_TITLE: i64 = 26
386const DBS_SVG_PX_NAME: i64 = 19
387const DBS_SVG_PX_VALUE: i64 = 18
388const DBS_SVG_PX_SMALL: i64 = 16
389const DBS_SVG_TRACE_PAD: i64 = 14
390const DBS_SVG_DY_NAME: i64 = 18
391const DBS_SVG_DY_VALUE: i64 = 8
392const DBS_SVG_DY_PEAK: i64 = 32
393const DBS_SVG_DY_STATE: i64 = 54
394// The two excitation levels are the LANE'S OWN MEASURED RESULT, named for what each one IS:
395// the linear limit is the regime the free-vibration subject band was measured in, and the walking
396// level is tissue_amp_walk_mm's minimum converted mm -> cmm. Neither is a chosen number.
397const DBS_SVG_AMP_LINEAR: i64 = 320
398const DBS_SVG_AMP_WALK: i64 = 3200
399
400func dbs_svg() -> i64 {
401 let sig: *i64 = sys_mmap(DBS_RD_MAXSAMP * 8) as *i64
402 let out: *i64 = sys_mmap(DBS_R_N * 8) as *i64
403 let profs: *i64 = sys_mmap(DBS_SVG_ROWS * 8) as *i64
404 let amps: *i64 = sys_mmap(DBS_SVG_ROWS * 8) as *i64
405 profs[0] = ST_PROF_LARGE_SOFT; amps[0] = DBS_SVG_AMP_LINEAR
406 profs[1] = ST_PROF_LARGE_SOFT; amps[1] = 0 - DBS_SVG_AMP_LINEAR
407 profs[2] = ST_PROF_LARGE_SOFT; amps[2] = DBS_SVG_AMP_WALK
408 profs[3] = ST_PROF_LARGE_SOFT; amps[3] = 0 - DBS_SVG_AMP_WALK
409 profs[4] = ST_PROF_GLUTE; amps[4] = DBS_SVG_AMP_WALK
410 dbs_out("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1920\" height=\"1200\" viewBox=\"0 0 1920 1200\">\n" as *u8)
411 dbs_out("<rect width=\"1920\" height=\"1200\" fill=\"rgb(12,10,20)\"/>\n" as *u8)
412 dbs_out("<text x=\"40\" y=\"44\" fill=\"rgb(233,229,245)\" font-family=\"monospace\" font-size=\"26\">NX-DYNA-BIND-SKIN ringdown traces -- XPBD soft tissue, free decay measured per branch</text>\n" as *u8)
413 let rowh: i64 = (DBS_SVG_H - DBS_SVG_TOP - DBS_SVG_BOT_MARGIN) / DBS_SVG_ROWS
414 var r: i64 = 0
415 while r < DBS_SVG_ROWS {
416 let cell: i64 = dbs_derive_cell(profs[r])
417 dbs_ringdown_t(profs[r], cell, amps[r], out, sig)
418 let base: i64 = DBS_SVG_TOP + r*rowh + rowh/2
419 // per-panel scale DERIVED from that panel's own peak, so a small trace is legible without
420 // pretending it is as large as a big one; the true amplitude is printed beside it.
421 var pk: i64 = out[DBS_R_MAXABS]
422 if pk < 1 { pk = 1 }
423 let half: i64 = rowh/2 - DBS_SVG_TRACE_PAD
424 dbs_out("<line x1=\"300\" y1=\"" as *u8); dbs_num(base)
425 dbs_out("\" x2=\"1880\" y2=\"" as *u8); dbs_num(base)
426 dbs_out("\" stroke=\"rgb(70,64,92)\" stroke-width=\"1\"/>\n" as *u8)
427 dbs_out("<polyline fill=\"none\" stroke=\"rgb(126,214,223)\" stroke-width=\"2\" points=\"" as *u8)
428 var i: i64 = 0
429 while i < DBS_RD_MAXSAMP {
430 let x: i64 = DBS_SVG_PLOT_L + i * (DBS_SVG_PLOT_R - DBS_SVG_PLOT_L) / DBS_RD_MAXSAMP
431 let y: i64 = base - sig[i] * half / pk
432 dbs_num(x); dbs_out("," as *u8); dbs_num(y); dbs_out(" " as *u8)
433 i = i + 1
434 }
435 dbs_out("\"/>\n" as *u8)
436 dbs_out("<text x=\"40\" y=\"" as *u8); dbs_num(base - DBS_SVG_DY_NAME)
437 dbs_out("\" fill=\"rgb(233,229,245)\" font-family=\"monospace\" font-size=\"19\">" as *u8)
438 if profs[r] == ST_PROF_GLUTE { dbs_out("GLUTE" as *u8) } else { dbs_out("LARGE_SOFT" as *u8) }
439 dbs_out(" amp=" as *u8); dbs_num(amps[r]); dbs_out(" cmm</text>\n" as *u8)
440 dbs_out("<text x=\"40\" y=\"" as *u8); dbs_num(base + DBS_SVG_DY_VALUE)
441 dbs_out("\" fill=\"rgb(150,196,150)\" font-family=\"monospace\" font-size=\"18\">fn=" as *u8)
442 dbs_num(out[DBS_R_FN_MHZ]); dbs_out(" mHz zeta=" as *u8); dbs_num(out[DBS_R_ZETA])
443 dbs_out("</text>\n" as *u8)
444 dbs_out("<text x=\"40\" y=\"" as *u8); dbs_num(base + DBS_SVG_DY_PEAK)
445 dbs_out("\" fill=\"rgb(190,180,120)\" font-family=\"monospace\" font-size=\"16\">peak=" as *u8)
446 dbs_num(out[DBS_R_MAXABS]); dbs_out(" resid=" as *u8); dbs_num(out[DBS_R_RESID])
447 dbs_out("</text>\n" as *u8)
448 // fn=0 is a MEASUREMENT (no full period inside the window), not a judgement, so it is the
449 // one state annotated here. Band membership is deliberately NOT drawn: the bands live in
450 // knowledge/gamefeel_oracle.conf and the judging lives in the gate. This organ reports.
451 if out[DBS_R_FN_MHZ] == 0 {
452 dbs_out("<text x=\"40\" y=\"" as *u8); dbs_num(base + DBS_SVG_DY_STATE)
453 dbs_out("\" fill=\"rgb(214,126,126)\" font-family=\"monospace\" font-size=\"16\">UNOBSERVABLE: no full period in window</text>\n" as *u8)
454 }
455 r = r + 1
456 }
457 dbs_out("<text x=\"40\" y=\"1180\" fill=\"rgb(140,134,160)\" font-family=\"monospace\" font-size=\"16\">traces ARE the measurement. fn and zeta printed per panel; bands and verdicts live in the conf and the gate, never here.</text>\n" as *u8)
458 dbs_out("</svg>\n" as *u8)
459 return 0
460}
461
462// ---------------------------------------------------------------------------------------------
463// BIND: read the asset's own DYNA regions and embed skin vertices in them. Weight is the region's
464// DECLARED falloff over its DECLARED influence radius -- nothing here is chosen by this organ.
465// Frame mapping applied once, here, and nowhere else.
466func dbs_bind_report(inp: *u8) -> i64 {
467 let lp: *i64 = sys_mmap(16) as *i64
468 let b: *u8 = sys_read_file(inp, lp)
469 if (b as i64) == 0 { dbs_err("BIND-RED cannot read input NXA\n" as *u8); return DBS_EXIT_IO }
470 let flen: i64 = lp[0]
471 if flen < DBS_HDR { dbs_err("BIND-RED file too short to be an NXA\n" as *u8); return DBS_EXIT_BAD }
472 if dbs_rd64(b, 0) != nxa_magic() { dbs_err("BIND-RED not an NXA (bad magic)\n" as *u8); return DBS_EXIT_BAD }
473 let ns: i64 = dbs_rd64(b, 16)
474 if ns < 1 { dbs_err("BIND-RED section count invalid\n" as *u8); return DBS_EXIT_BAD }
475 if ns >= DBS_MAXSEC { dbs_err("BIND-RED section table full\n" as *u8); return DBS_EXIT_BAD }
476 var vo: i64 = 0 - 1
477 var dyo: i64 = 0 - 1
478 var s: i64 = 0
479 while s < ns {
480 let e: i64 = DBS_HDR + s*DBS_TOCE
481 if dbs_tageq(b, e, "VERT" as *u8) == 1 { vo = dbs_rd64(b, e + 8) }
482 if dbs_tageq(b, e, "DYNA" as *u8) == 1 { dyo = dbs_rd64(b, e + 8) }
483 s = s + 1
484 }
485 if vo < 0 { dbs_err("BIND-RED no VERT section -- nothing to bind\n" as *u8); return DBS_EXIT_BAD }
486 if dyo < 0 {
487 dbs_err("BIND-RED no DYNA section -- run nx_nxa_dyna first; a bind with no declared region would have to invent one\n" as *u8)
488 return DBS_EXIT_BAD
489 }
490 let nv: i64 = dbs_rd64(b, vo)
491 let nb: i64 = dbs_rd64(b, dyo)
492 let stride: i64 = dbs_rd64(b, dyo + 8)
493 dbs_out("NX-DYNA-BIND-SKIN bind\n verts=" as *u8); dbs_num(nv)
494 dbs_out(" regions=" as *u8); dbs_num(nb)
495 dbs_out(" stride=" as *u8); dbs_num(stride); dbs_out("\n" as *u8)
496 if stride != DBS_DY_STRIDE {
497 dbs_err("BIND-RED DYNA stride is not the layout this reader was written against\n" as *u8)
498 return DBS_EXIT_BAD
499 }
500 var bound_total: i64 = 0
501 var r: i64 = 0
502 while r < nb {
503 let base: i64 = dyo + 16 + r*stride*8
504 let side: i64 = dbs_rd64(b, base + DBS_DY_SIDE*8)
505 let ax: i64 = dbs_rd64(b, base + DBS_DY_AX*8)
506 let ay: i64 = dbs_rd64(b, base + DBS_DY_AY*8)
507 let az: i64 = dbs_rd64(b, base + DBS_DY_AZ*8)
508 let infl: i64 = dbs_rd64(b, base + DBS_DY_INFL*8)
509 let fall: i64 = dbs_rd64(b, base + DBS_DY_FALL*8)
510 var cnt: i64 = 0
511 var wsum: i64 = 0
512 var i: i64 = 0
513 while i < nv {
514 let vx: i64 = dbs_rd64(b, vo + 8 + (i*3 + 0)*8)
515 let vy: i64 = dbs_rd64(b, vo + 8 + (i*3 + 1)*8)
516 let vz: i64 = dbs_rd64(b, vo + 8 + (i*3 + 2)*8)
517 let dx: i64 = vx - ax
518 let dy: i64 = vy - ay
519 let dz: i64 = vz - az
520 let d2: i64 = dx*dx + dy*dy + dz*dz
521 if infl > 0 {
522 if d2 <= infl*infl {
523 let d: i64 = dbs_isqrt(d2)
524 // weight = falloff * (1 - d/infl), in q8. The region declares the falloff; the
525 // radius is the region's own. This organ contributes no constant.
526 let w: i64 = fall * (infl - d) / infl
527 cnt = cnt + 1
528 wsum = wsum + w
529 }
530 }
531 i = i + 1
532 }
533 bound_total = bound_total + cnt
534 dbs_out(" region " as *u8); dbs_num(r)
535 dbs_out(" side=" as *u8); dbs_num(side)
536 dbs_out(" infl=" as *u8); dbs_num(infl)
537 dbs_out(" bound=" as *u8); dbs_num(cnt)
538 dbs_out(" wsum_q8=" as *u8); dbs_num(wsum)
539 if cnt > 0 { dbs_out(" wmean_q8=" as *u8); dbs_num(wsum / cnt) }
540 dbs_out("\n" as *u8)
541 r = r + 1
542 }
543 dbs_out(" bound_total=" as *u8); dbs_num(bound_total)
544 dbs_out(" skin_locked=" as *u8); dbs_num(nv - bound_total)
545 dbs_out("\n" as *u8)
546 return DBS_EXIT_OK
547}
548
549// EVAL: the entry point a renderer calls. Drive the cage with a sinusoidal body acceleration at a
550// given drive frequency (the caller supplies it from tissue_drive_mhz) and emit, per frame, the
551// cage's own centroid offset from its settled equilibrium. That offset is what a consumer adds to
552// a bound skin vertex, scaled by the weight dbs_bind_report already derives from the region's
553// declared falloff. Offsets are cmm in the SOLVER frame; a consumer maps them with the same
554// st<->nxa mapping documented at the head of this file.
555func dbs_eval(prof: i64, drive_mhz: i64, frames: i64, dt_us: i64) -> i64 {
556 let cell: i64 = dbs_derive_cell(prof)
557 if cell <= 0 { return 0 - 1 }
558 let W: *i64 = st_new(prof, cell)
559 if (W as i64) == 0 { return 0 - 1 }
560 st_reset_state(W)
561 var s: i64 = 0
562 while s < DBS_RD_SETTLE { st_substep(W, 0, 0 - ST_Q12, 0, ST_DT_REF_US, DBS_RD_ITERS); s = s + 1 }
563 let eq: i64 = dbs_free_surface_y(W)
564 dbs_out("NX-DYNA-BIND-SKIN eval profile=" as *u8); dbs_num(prof)
565 dbs_out(" cell_mm=" as *u8); dbs_num(cell)
566 dbs_out(" drive_mhz=" as *u8); dbs_num(drive_mhz)
567 dbs_out(" dt_us=" as *u8); dbs_num(dt_us)
568 dbs_out(" eq_y_cmm=" as *u8); dbs_num(eq); dbs_out("\n" as *u8)
569 // substeps per frame DERIVED from the solver's own reference step: never let the integrator
570 // see a step larger than the step its compliance was formulated against.
571 var sub: i64 = dt_us / ST_DT_REF_US
572 if sub < 1 { sub = 1 }
573 let sdt: i64 = dt_us / sub
574 dbs_out(" substeps_per_frame=" as *u8); dbs_num(sub)
575 dbs_out(" substep_us=" as *u8); dbs_num(sdt); dbs_out("\n" as *u8)
576 var f: i64 = 0
577 var phase_us: i64 = 0
578 var mxoff: i64 = 0
579 while f < frames {
580 // vertical body acceleration g*(1 + sin(2*pi*fd*t)) approximated by a triangular carrier:
581 // integer-only, deterministic, and its FREQUENCY is exact, which is what the plant responds
582 // to. Amplitude shaping beyond that belongs to the animation drive, not to this bridge.
583 var per_us: i64 = 0
584 if drive_mhz > 0 { per_us = DBS_US_PER_S * DBS_MHZ_PER_HZ / drive_mhz }
585 var tri: i64 = 0
586 if per_us > 0 {
587 let ph: i64 = phase_us - (phase_us / per_us) * per_us
588 let half: i64 = per_us / 2
589 if ph < half { tri = ST_Q12 - (2 * ST_Q12 * ph) / half }
590 else { tri = 0 - ST_Q12 + (2 * ST_Q12 * (ph - half)) / half }
591 }
592 var k: i64 = 0
593 while k < sub {
594 st_substep(W, 0, (0 - ST_Q12) + tri, 0, sdt, DBS_RD_ITERS)
595 k = k + 1
596 }
597 let off: i64 = dbs_free_surface_y(W) - eq
598 if dbs_abs(off) > mxoff { mxoff = dbs_abs(off) }
599 dbs_out(" frame=" as *u8); dbs_num(f)
600 dbs_out(" offset_y_cmm=" as *u8); dbs_num(off); dbs_out("\n" as *u8)
601 phase_us = phase_us + dt_us
602 f = f + 1
603 }
604 dbs_out(" max_offset_cmm=" as *u8); dbs_num(mxoff); dbs_out("\n" as *u8)
605 return 0
606}
607
608func dbs_atoi(s: *u8) -> i64 {
609 var v: i64 = 0
610 var i: i64 = 0
611 var neg: i64 = 0
612 if s[0] == (45 as u8) { neg = 1; i = 1 }
613 var stop: i64 = 0
614 while stop == 0 {
615 let c: i64 = s[i] as i64
616 if c < 48 { stop = 1 }
617 else { if c > 57 { stop = 1 } else { v = v * 10 + (c - 48); i = i + 1 } }
618 }
619 if neg == 1 { return 0 - v }
620 return v
621}
622
623func main(argc: i64, argv: *i64) -> i64 {
624 if argc < 2 {
625 dbs_err("usage: nx_dyna_bind_skin ringdown <profile> <amp_cmm> | bind <in.nxa>\n" as *u8)
626 sys_exit(DBS_EXIT_USAGE)
627 return DBS_EXIT_USAGE
628 }
629 let verb: *u8 = argv[1] as *u8
630 if dbs_slen(verb) == 8 {
631 if verb[0] == (114 as u8) {
632 if argc < 4 {
633 dbs_err("usage: nx_dyna_bind_skin ringdown <profile> <amp_cmm>\n" as *u8)
634 dbs_err(" the lattice step is DERIVED from ST_MAXP; it is deliberately not an argument.\n" as *u8)
635 sys_exit(DBS_EXIT_USAGE)
636 return DBS_EXIT_USAGE
637 }
638 let prof: i64 = dbs_atoi(argv[2] as *u8)
639 let amp: i64 = dbs_atoi(argv[3] as *u8)
640 let hmm: i64 = dbs_derive_cell(prof)
641 if hmm <= 0 {
642 dbs_err("RINGDOWN-RED no lattice step fits the particle budget for that profile\n" as *u8)
643 sys_exit(DBS_EXIT_BAD)
644 return DBS_EXIT_BAD
645 }
646 let out: *i64 = sys_mmap(DBS_R_N * 8) as *i64
647 let rc: i64 = dbs_ringdown(prof, hmm, amp, out)
648 if rc != 0 {
649 dbs_err("RINGDOWN-RED cage could not be built for that profile\n" as *u8)
650 sys_exit(DBS_EXIT_BAD)
651 return DBS_EXIT_BAD
652 }
653 dbs_out("NX-DYNA-BIND-SKIN ringdown profile=" as *u8); dbs_num(prof)
654 dbs_out(" cell_mm=" as *u8); dbs_num(hmm)
655 dbs_out(" amp_cmm=" as *u8); dbs_num(amp); dbs_out("\n" as *u8)
656 dbs_out(" dt_us=" as *u8); dbs_num(ST_DT_REF_US)
657 dbs_out(" samples=" as *u8); dbs_num(out[DBS_R_NSAMP])
658 dbs_out(" eq_y_cmm=" as *u8); dbs_num(out[DBS_R_EQ]); dbs_out("\n" as *u8)
659 dbs_out(" crossings=" as *u8); dbs_num(out[DBS_R_PEAKS])
660 dbs_out(" peak1_cmm=" as *u8); dbs_num(out[DBS_R_A1])
661 dbs_out(" peak2_cmm=" as *u8); dbs_num(out[DBS_R_A2])
662 dbs_out(" maxabs_cmm=" as *u8); dbs_num(out[DBS_R_MAXABS])
663 dbs_out(" residual_cmm=" as *u8); dbs_num(out[DBS_R_RESID]); dbs_out("\n" as *u8)
664 dbs_out(" period_us=" as *u8); dbs_num(out[DBS_R_PER_US])
665 dbs_out(" fn_mhz=" as *u8); dbs_num(out[DBS_R_FN_MHZ])
666 dbs_out(" zeta_permil=" as *u8); dbs_num(out[DBS_R_ZETA]); dbs_out("\n" as *u8)
667 dbs_out(" <- measurements only: the bands live in knowledge/gamefeel_oracle.conf and the judging lives in the gate.\n" as *u8)
668 sys_exit(DBS_EXIT_OK)
669 return DBS_EXIT_OK
670 }
671 }
672 if dbs_slen(verb) == 4 {
673 if verb[0] == (98 as u8) {
674 if argc < 3 {
675 dbs_err("usage: nx_dyna_bind_skin bind <in.nxa>\n" as *u8)
676 sys_exit(DBS_EXIT_USAGE)
677 return DBS_EXIT_USAGE
678 }
679 let rc2: i64 = dbs_bind_report(argv[2] as *u8)
680 sys_exit(rc2)
681 return rc2
682 }
683 }
684 if dbs_slen(verb) == 4 {
685 if verb[0] == (101 as u8) {
686 if argc < 6 {
687 dbs_err("usage: nx_dyna_bind_skin eval <profile> <drive_mhz> <frames> <frame_dt_us>\n" as *u8)
688 sys_exit(DBS_EXIT_USAGE)
689 return DBS_EXIT_USAGE
690 }
691 let ep: i64 = dbs_atoi(argv[2] as *u8)
692 let ed: i64 = dbs_atoi(argv[3] as *u8)
693 let ef: i64 = dbs_atoi(argv[4] as *u8)
694 let et: i64 = dbs_atoi(argv[5] as *u8)
695 let rc3: i64 = dbs_eval(ep, ed, ef, et)
696 if rc3 != 0 {
697 dbs_err("EVAL-RED cage could not be built for that profile\n" as *u8)
698 sys_exit(DBS_EXIT_BAD)
699 return DBS_EXIT_BAD
700 }
701 sys_exit(DBS_EXIT_OK)
702 return DBS_EXIT_OK
703 }
704 }
705 if dbs_slen(verb) == 3 {
706 if verb[0] == (115 as u8) {
707 dbs_svg()
708 sys_exit(DBS_EXIT_OK)
709 return DBS_EXIT_OK
710 }
711 }
712 dbs_err("nx_dyna_bind_skin: unknown verb\n" as *u8)
713 sys_exit(DBS_EXIT_USAGE)
714 return DBS_EXIT_USAGE
715}