nx_nxa_play.nx source
↩ module page · 403 lines · 19843 B
1// nx_nxa_play.nx -- THE NXA POSE PLAYER: apply a POSE-library entry to a rigged NXA and evaluate
2// linear-blend skinning over the WHOLE mesh, at the corpus's real dimensions.
3//
4// WHY THIS EXISTS (measured 2026-08-23). Every rigged asset we own carries SKEL+SKIN with exact
5// weights, and nx_nxa_pose freezes ANIM channel-2 keyframes into a POSE library -- but NOTHING
6// applied them: the only in-engine evaluator (nx_skeleton) was capped at 32 bones / 2048 verts
7// against a corpus of 104-370 joints and 14,164-423,919 verts, so a real character could not be
8// posed at all. That cap is now gone (capacity is declared from the asset); this organ is the
9// consumer that makes the capability reachable. A blocker removed but uncalled is BUILT+UNWIRED.
10//
11// THE MATH IS THE SPEC'S, NOT A NEW ONE. knowledge/nxa_format_spec.md declares the normative
12// runtime form: M x = D (x - bind_t) + bind_t + dt, where D is the world-delta rotation ABOUT THE
13// BIND POSE and dt the world translation delta -- exact LBS with NO hierarchy walk at runtime.
14// Expanded, that is an AFFINE per-joint transform: M x = D x + (bind_t - D bind_t + dt).
15// So the shared-local-position form nx_skeleton already implements (sum_j w_j (R_j v + p_j)) is
16// SUFFICIENT: set R_j = D_j and p_j = bind_t - D bind_t + dt. No second skinning path is created,
17// and nx_skeleton's v1 note ("one local pos, valid when bind poses align") stops being a limit --
18// the per-joint bind offset is folded into p_j, which is exactly where an affine form puts it.
19//
20// UNITS ARE THE MEASURED TRAP, NAMED IN THE SPEC: VERT/SKEL binds are in 0.01 mm model units while
21// the POSE/ANIM dt lanes are packed in MILLIMETERS to fit i16. A consumer MUST scale dt by 100 or
22// every baked translation renders at 1% (the recorded symptom: forearms pinned at T-pose pivots).
23//
24// WEIGHTS: SKIN carries q12 weights summing 4096 (spec); nx_skeleton blends in fx256 summing 256.
25// The conversion happens HERE, at the boundary, and RENORMALIZES: the truncation residual is added
26// back to the largest influence so every vertex's weights sum to EXACTLY 256. Dropping the residual
27// shrinks a vertex toward the origin -- a silent, uniform volume loss that reads as "the mesh got
28// slightly smaller", which is precisely the kind of defect that never gets attributed.
29//
30// Usage: nx_nxa_play <in.nxa> [pose_id] (no pose_id, or a pose_id absent from the library =>
31// the IDENTITY pose, which by construction reproduces bind pose exactly -- the neg-control)
32// Exits: 0 ok | 2 usage | 3 refused-by-name | 5 no rigged sections
33import "nx_syscalls.nx"
34import "nx_nxa.nx"
35import "nx_nxa_fk.nx"
36import "nx_skeleton.nx"
37
38const NP_INF: i64 = 4 // SKIN is 4 influences per vertex (NXA v1 spec, fixed by format)
39const NP_WQ: i64 = 4096 // SKIN weight scale, q12, sum = 4096 (spec)
40const NP_FX: i64 = 256 // nx_skeleton weight scale, fx256, sum = 256
41// dt SCALE, CHOSEN BY MEASUREMENT, NOT BY THE SPEC PARAGRAPH -- and the discrepancy is filed below.
42// nxa_format_spec.md states POSE entry dt lanes carry MILLIMETRES (packed /100 to fit i16), so a
43// consumer in 0.01 mm model units must multiply by 100. Applying that literally to the SHIPPED
44// ref9d POSE library yields max displacement 5,400,911 model units = 54.0 m on a figure whose own
45// VERT extent is 171,530 units = 1.715 m -- THIRTY-ONE TIMES ITS OWN HEIGHT, physically impossible.
46// Dividing by 100 yields 54,009 units = 540 mm: a natural stride/arm world-delta, ~0.31x height.
47// The emitted POSE dt lanes are therefore ALREADY in model units (0.01 mm), not millimetres.
48// The two candidates are separated by physical plausibility against the asset's own measured
49// height, not by preference -- and the arithmetic is exactly 100x, which is the unit pair in
50// question. SETTLED BY THE RECORD, NOT LEFT OPEN: debt row 1785380029 (eaten, ws=game-pose-section,
51// measured 2026-07-29) is this exact class and states the x100 correction was applied INSIDE
52// nx_nxa_pose AT FREEZE TIME -- "Craft page + nx_nxa_pose FIXED (x100 at parse/freeze)". The emitted
53// POSE library therefore ALREADY carries dt in 0.01 mm model units, so a consumer must NOT scale
54// again: doing so would re-apply the 1785380029 defect in the opposite direction (100x too large,
55// which is exactly the 54 m measured above).
56// => THE STALE SIDE IS THE SPEC PARAGRAPH, which still claims POSE dt lanes are millimetres.
57// That row explicitly asked that "nx_mesh_view / any native NXA player likewise" be audited; this
58// organ IS that native player and this constant is that audit's answer.
59// I first published this as "unadjudicated": the record already held the answer and I had not mined
60// it -- the estate's own law is never to call a thing a judgement call while the record is unmined.
61const NP_DT_MODEL: i64 = 1
62const NP_Q12: i64 = 4096 // quaternion fixed-point unit (spec)
63// pose-hold weight is expressed in PER MILLE because that is this estate's standing ratio unit
64// (permil appears throughout its rulers and confs); 1000 = pose fully applied, 0 = bind pose.
65const NP_WFULL: i64 = 1000
66const NP_MODE: i64 = 0x1a4 // 0644 on the published asset, matching every sibling NXA writer
67const NP_HDR: i64 = 32 // header bytes before the TOC (NXA v1 layout)
68const NP_TOC_W: i64 = 4 // words per TOC entry: tag, byte_off, word_len, check
69const NP_EXIT_USAGE: i64 = 2
70const NP_EXIT_REFUSE: i64 = 3
71const NP_EXIT_NOSEC: i64 = 5
72
73func npw(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } sys_write(1, s, n); return 0 }
74func nperr(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } sys_write(2, s, n); return 0 }
75func npn(v: i64) -> i64 {
76 let b: *u8 = sys_mmap(32)
77 var x: i64 = v
78 if x < 0 { b[0] = 45 as u8; sys_write(1, b, 1); x = 0 - x }
79 if x == 0 { b[0] = 48 as u8; sys_write(1, b, 1); return 0 }
80 var d: i64 = 0
81 var y: i64 = x
82 while y > 0 { d = d + 1; y = y / 10 }
83 var i: i64 = d
84 while i > 0 { i = i - 1; b[i] = ((x % 10) + 48) as u8; x = x / 10 }
85 sys_write(1, b, d)
86 return 0
87}
88func np_atoi(s: *u8) -> i64 {
89 var v: i64 = 0
90 var i: i64 = 0
91 while s[i] != (0 as u8) {
92 let c: i64 = s[i] as i64
93 if c >= 48 { if c <= 57 { v = v * 10 + (c - 48) } }
94 i = i + 1
95 }
96 return v
97}
98func np_abs(v: i64) -> i64 { if v < 0 { return 0 - v } return v }
99
100// D (unit q12 quat) -> 3x3 fx256 row-major, by rotating the fx256 basis vectors: column k = D e_k.
101// Composes nx_nxa_fk's proven quaternion algebra; no quat math is re-derived here (rule 15).
102func np_quat_to_m(q: *i64, m: *i64, o3: *i64, scr: *i64) -> i64 {
103 nf_qrotv(q, NP_FX, 0, 0, o3, scr)
104 m[0] = o3[0]; m[3] = o3[1]; m[6] = o3[2]
105 nf_qrotv(q, 0, NP_FX, 0, o3, scr)
106 m[1] = o3[0]; m[4] = o3[1]; m[7] = o3[2]
107 nf_qrotv(q, 0, 0, NP_FX, o3, scr)
108 m[2] = o3[0]; m[5] = o3[1]; m[8] = o3[2]
109 return 0
110}
111
112func main(argc: i64, argv: *i64) -> i64 {
113 if argc < 2 {
114 nperr("usage: nx_nxa_play <in.nxa> [pose_id]\n" as *u8)
115 return NP_EXIT_USAGE
116 }
117 let path: *u8 = argv[1] as *u8
118 var pose_id: i64 = 0 - 1
119 if argc >= 3 { pose_id = np_atoi(argv[2] as *u8) }
120 // optional: write the POSED asset out, and hold the pose at a fraction of full application.
121 // The weight exists so a SEQUENCE can be emitted -- bind (0) through fully posed (1000) -- which
122 // is what turns a still into visible motion. Per the format spec, players blend poses by
123 // lerp/nlerp with shortest-path handling; that is what is done below, not a new scheme.
124 var outp: *u8 = 0 as *u8
125 if argc >= 4 { outp = argv[3] as *u8 }
126 var wpm: i64 = NP_WFULL
127 if argc >= 5 { wpm = np_atoi(argv[4] as *u8) }
128 if wpm < 0 { wpm = 0 }
129 if wpm > NP_WFULL { wpm = NP_WFULL }
130
131 let lp: *i64 = sys_mmap(64)
132 // sys_read_file sizes from the file itself and cannot short-read (the banked law: never a
133 // hand-picked cap on a file read -- a capped reader silently truncates its own subject).
134 let b: *u8 = sys_read_file(path, lp)
135 if b as i64 == 0 { nperr("NXA-PLAY-REFUSE cannot read input\n" as *u8); return NP_EXIT_REFUSE }
136 let flen: i64 = lp[0]
137 let w: *i64 = b as *i64
138
139 let vwo: i64 = nxa_find(b, flen, nxa_tag4("VERT" as *u8))
140 let swo: i64 = nxa_find(b, flen, nxa_tag4("SKEL" as *u8))
141 let kwo: i64 = nxa_find(b, flen, nxa_tag4("SKIN" as *u8))
142 if vwo < 0 { nperr("NXA-PLAY-REFUSE no valid VERT section\n" as *u8); return NP_EXIT_NOSEC }
143 if swo < 0 { nperr("NXA-PLAY-REFUSE no valid SKEL section (asset is not rigged)\n" as *u8); return NP_EXIT_NOSEC }
144 if kwo < 0 { nperr("NXA-PLAY-REFUSE no valid SKIN section (rig carries no weights)\n" as *u8); return NP_EXIT_NOSEC }
145
146 let nv: i64 = w[vwo]
147 let nj: i64 = w[swo]
148 let nsk: i64 = w[kwo]
149 if nv < 1 { nperr("NXA-PLAY-REFUSE empty VERT\n" as *u8); return NP_EXIT_REFUSE }
150 if nj < 1 { nperr("NXA-PLAY-REFUSE empty SKEL\n" as *u8); return NP_EXIT_REFUSE }
151 // a joint/influence-count mismatch is REFUSED BY NAME rather than silently posing a prefix:
152 // a skin table shorter than its mesh would leave the tail at bind pose and read as a partial
153 // deformation, which is indistinguishable from a bad clip.
154 if nsk != nv {
155 nperr("NXA-PLAY-REFUSE SKIN vertex count does not match VERT count\n" as *u8)
156 return NP_EXIT_REFUSE
157 }
158
159 // ---- per-joint delta rotation (q12) + delta translation (mm), default = IDENTITY ----------
160 let dq: *i64 = sys_mmap(nj * 4 * 8)
161 let dt: *i64 = sys_mmap(nj * 3 * 8)
162 var j: i64 = 0
163 while j < nj {
164 dq[j*4] = 0; dq[j*4+1] = 0; dq[j*4+2] = 0; dq[j*4+3] = NP_Q12
165 dt[j*3] = 0; dt[j*3+1] = 0; dt[j*3+2] = 0
166 j = j + 1
167 }
168
169 var applied: i64 = 0
170 var poses_avail: i64 = 0
171 let pwo: i64 = nxa_find(b, flen, nxa_tag4("POSE" as *u8))
172 if pwo >= 0 {
173 poses_avail = w[pwo]
174 if pose_id >= 0 {
175 var cur: i64 = pwo + 1
176 var p: i64 = 0
177 while p < poses_avail {
178 let pid: i64 = w[cur]
179 let ne: i64 = w[cur + 1]
180 if pid == pose_id {
181 var e: i64 = 0
182 while e < ne {
183 let eo: i64 = cur + 2 + e * 8
184 let jj: i64 = w[eo]
185 if jj >= 0 { if jj < nj {
186 dq[jj*4] = w[eo+1]; dq[jj*4+1] = w[eo+2]
187 dq[jj*4+2] = w[eo+3]; dq[jj*4+3] = w[eo+4]
188 dt[jj*3] = w[eo+5]; dt[jj*3+1] = w[eo+6]; dt[jj*3+2] = w[eo+7]
189 applied = applied + 1
190 } }
191 e = e + 1
192 }
193 }
194 cur = cur + 2 + ne * 8
195 p = p + 1
196 }
197 }
198 }
199
200 // ---- hold the pose at wpm/1000 so a SEQUENCE can be emitted -------------------------------
201 // nlerp the delta rotation from identity and scale the delta translation linearly. The format
202 // spec's own player note is "shortest-path lerp/slerp between keys (negate the second quat when
203 // the dot is negative)" -- the sign flip below IS that rule, not an invention: a delta whose w
204 // is negative sits on the far side of the quaternion double cover, and blending toward it
205 // without the flip takes the LONG way round, which renders as a limb rotating the wrong way.
206 if wpm < NP_WFULL {
207 let sq: *i64 = sys_mmap(64)
208 var jb: i64 = 0
209 while jb < nj {
210 var qx: i64 = dq[jb*4]
211 var qy: i64 = dq[jb*4+1]
212 var qz: i64 = dq[jb*4+2]
213 var qw2: i64 = dq[jb*4+3]
214 if qw2 < 0 { qx = 0 - qx; qy = 0 - qy; qz = 0 - qz; qw2 = 0 - qw2 }
215 sq[0] = qx * wpm / NP_WFULL
216 sq[1] = qy * wpm / NP_WFULL
217 sq[2] = qz * wpm / NP_WFULL
218 sq[3] = (qw2 * wpm + NP_Q12 * (NP_WFULL - wpm)) / NP_WFULL
219 nf_qnorm(sq)
220 dq[jb*4] = sq[0]; dq[jb*4+1] = sq[1]; dq[jb*4+2] = sq[2]; dq[jb*4+3] = sq[3]
221 dt[jb*3] = dt[jb*3] * wpm / NP_WFULL
222 dt[jb*3+1] = dt[jb*3+1] * wpm / NP_WFULL
223 dt[jb*3+2] = dt[jb*3+2] * wpm / NP_WFULL
224 jb = jb + 1
225 }
226 }
227
228 // ---- arena sized from the ASSET, not from a constant --------------------------------------
229 let abytes: i64 = sk_bytes_for(nj, nv, NP_INF)
230 let base: i64 = sys_mmap(abytes) as i64
231 if base == 0 { nperr("NXA-PLAY-REFUSE arena allocation failed\n" as *u8); return NP_EXIT_REFUSE }
232 if sk_init_cap(base, nj, nv, NP_INF) < 0 {
233 nperr("NXA-PLAY-REFUSE evaluator rejected the declared capacity\n" as *u8)
234 return NP_EXIT_REFUSE
235 }
236
237 let o3: *i64 = sys_mmap(64)
238 let scr: *i64 = sys_mmap(256)
239 let mm: *i64 = sys_mmap(128)
240 let tv: *i64 = sys_mmap(64)
241
242 // bones: R = D, p = bind_t - D*bind_t + dt*100 (the spec's affine expansion)
243 j = 0
244 while j < nj {
245 let so: i64 = swo + 1 + j * 8
246 let par: i64 = w[so]
247 let bx: i64 = w[so+1]
248 let by: i64 = w[so+2]
249 let bz: i64 = w[so+3]
250 let bi: i64 = sk_add_bone(base, par, bx, by, bz)
251 if bi < 0 { nperr("NXA-PLAY-REFUSE bone capacity exceeded\n" as *u8); return NP_EXIT_REFUSE }
252 np_quat_to_m(((dq as i64) + j*32) as *i64, mm, o3, scr)
253 let bp: *i64 = sk_bone(base, bi)
254 var k: i64 = 0
255 while k < 9 { bp[6 + k] = mm[k]; k = k + 1 }
256 sk_matvec(mm, bx, by, bz, tv)
257 bp[15] = bx - tv[0] + dt[j*3] * NP_DT_MODEL
258 bp[16] = by - tv[1] + dt[j*3+1] * NP_DT_MODEL
259 bp[17] = bz - tv[2] + dt[j*3+2] * NP_DT_MODEL
260 bp[18] = 1
261 j = j + 1
262 }
263
264 // vertices: weights q12 -> fx256, residual folded into the largest influence so the sum is
265 // EXACTLY 256 (an under-sum shrinks the vertex toward the origin: silent volume loss).
266 let bs: *i64 = sys_mmap(64)
267 let ws: *i64 = sys_mmap(64)
268 var i: i64 = 0
269 var wsum_bad: i64 = 0
270 while i < nv {
271 let ko: i64 = kwo + 1 + i * 8
272 var tot: i64 = 0
273 var bigk: i64 = 0
274 var bigw: i64 = 0 - 1
275 var k2: i64 = 0
276 while k2 < NP_INF {
277 let jw: i64 = w[ko + 4 + k2]
278 let cw: i64 = jw * NP_FX / NP_WQ
279 bs[k2] = w[ko + k2]
280 ws[k2] = cw
281 tot = tot + cw
282 if jw > bigw { bigw = jw; bigk = k2 }
283 k2 = k2 + 1
284 }
285 if tot > 0 { ws[bigk] = ws[bigk] + (NP_FX - tot) }
286 if tot <= 0 { wsum_bad = wsum_bad + 1 }
287 let vo: i64 = vwo + 1 + i * 3
288 if sk_add_vert_n(base, bs, ws, NP_INF, w[vo], w[vo+1], w[vo+2]) < 0 {
289 nperr("NXA-PLAY-REFUSE vertex capacity exceeded\n" as *u8)
290 return NP_EXIT_REFUSE
291 }
292 i = i + 1
293 }
294
295 sk_skin(base)
296
297 // ---- PUBLISH IS DELIBERATELY LAST, AFTER THE MEASUREMENT ----------------------------------
298 // v1 of this organ published here, BEFORE the report loop -- and the publish overwrites the
299 // VERT payload IN PLACE, which is the very buffer the report compares the skinned output
300 // against. Every displacement therefore measured posed-against-posed and printed ZERO: the
301 // organ silently reported "nothing moved" for a pose that had moved all 14,164 vertices.
302 // MEASURE FIRST, THEN MUTATE. A reporter that shares a buffer with a writer is not a reporter.
303 if outp as i64 != 0 {
304 // PUBLISH ON A COPY -- the fix, and the reason it is needed. The report loop below compares
305 // the skinned output against the ORIGINAL VERT payload, so writing posed vertices back into
306 // the INPUT buffer makes every displacement measure posed-against-posed and print ZERO.
307 // v1 did exactly that: it published six correct assets while reporting nothing had moved,
308 // a silent self-inflicted false negative that a reader would have taken as "skinning is
309 // broken". A WRITER MUST NOT MUTATE A READER'S INPUT: the publisher takes its own copy and
310 // the measurement subject stays pristine. Sized from flen, so no cap is introduced.
311 let nb: *u8 = sys_mmap(flen)
312 if nb as i64 == 0 {
313 nperr("NXA-PLAY-REFUSE cannot allocate the publish copy\n" as *u8)
314 return NP_EXIT_REFUSE
315 }
316 let nw: *i64 = nb as *i64
317 let nwords: i64 = flen / 8
318 var cw: i64 = 0
319 while cw < nwords { nw[cw] = w[cw]; cw = cw + 1 }
320 let nsect: i64 = nw[2]
321 var vt: i64 = 0 - 1
322 var ti: i64 = 0
323 while ti < nsect {
324 if nw[NP_TOC_W + ti*NP_TOC_W] == nxa_tag4("VERT" as *u8) { vt = ti }
325 ti = ti + 1
326 }
327 if vt < 0 {
328 nperr("NXA-PLAY-REFUSE cannot locate the VERT TOC entry to publish\n" as *u8)
329 return NP_EXIT_REFUSE
330 }
331 var pi: i64 = 0
332 while pi < nv {
333 let o: *i64 = sk_out(base, pi)
334 let vo2: i64 = vwo + 1 + pi * 3
335 nw[vo2] = o[0]; nw[vo2+1] = o[1]; nw[vo2+2] = o[2]
336 pi = pi + 1
337 }
338 let te: i64 = NP_TOC_W + vt * NP_TOC_W
339 let vpay: *i64 = ((nb as i64) + nw[te + 1]) as *i64
340 nw[te + 3] = nxa_check2(1, vpay, nw[te + 2])
341 let tbp: *i64 = ((nb as i64) + NP_HDR) as *i64
342 nw[3] = nxa_check2(1, tbp, nsect * NP_TOC_W)
343 let fd: i64 = sys_openat_wr(outp, NP_MODE)
344 if fd < 0 {
345 nperr("NXA-PLAY-REFUSE cannot open the output asset for write\n" as *u8)
346 return NP_EXIT_REFUSE
347 }
348 var wo2: i64 = 0
349 while wo2 < flen {
350 let kk: i64 = sys_write(fd, ((nb as i64) + wo2) as *u8, flen - wo2)
351 if kk <= 0 {
352 sys_close(fd)
353 nperr("NXA-PLAY-REFUSE short write publishing the posed asset\n" as *u8)
354 return NP_EXIT_REFUSE
355 }
356 wo2 = wo2 + kk
357 }
358 sys_close(fd)
359 }
360
361 // ---- report: measured displacement, not a claim -------------------------------------------
362 var moved: i64 = 0
363 var maxd: i64 = 0
364 var mind: i64 = 0x7fffffffffffffff
365 i = 0
366 while i < nv {
367 let vo: i64 = vwo + 1 + i * 3
368 let o: *i64 = sk_out(base, i)
369 let ddx: i64 = np_abs(o[0] - w[vo])
370 let ddy: i64 = np_abs(o[1] - w[vo+1])
371 let ddz: i64 = np_abs(o[2] - w[vo+2])
372 var d: i64 = ddx
373 if ddy > d { d = ddy }
374 if ddz > d { d = ddz }
375 if d > 0 { moved = moved + 1 }
376 if d > maxd { maxd = d }
377 if d < mind { mind = d }
378 i = i + 1
379 }
380 // SPREAD is the anti-vacuity signal, and it is the whole reason min is reported at all:
381 // a JOINT-DRIVEN deformation moves different vertices by different amounts (a shoulder swings
382 // the hand far and the hip barely at all), whereas a GLOBAL TRANSLATE -- or any whole-mesh
383 // jitter applied uniformly -- moves every vertex by the SAME amount and therefore collapses
384 // spread to zero. A gate that only asserts "the mesh moved" is passed by both; only spread
385 // separates real skinning from a mesh that was simply shoved.
386 var spread: i64 = 0
387 if moved > 0 { spread = maxd - mind }
388
389 npw("NXA-PLAY joints=" as *u8); npn(nj)
390 npw(" verts=" as *u8); npn(nv)
391 npw(" influences=" as *u8); npn(NP_INF)
392 npw(" arena_bytes=" as *u8); npn(abytes)
393 npw(" poses_in_library=" as *u8); npn(poses_avail)
394 npw(" pose_id=" as *u8); npn(pose_id)
395 npw(" joints_posed=" as *u8); npn(applied)
396 npw(" verts_moved=" as *u8); npn(moved)
397 npw(" max_disp_umm=" as *u8); npn(maxd)
398 npw(" min_disp_umm=" as *u8); npn(mind)
399 npw(" disp_spread_umm=" as *u8); npn(spread)
400 npw(" zero_weight_verts=" as *u8); npn(wsum_bad)
401 npw("\n" as *u8)
402 return 0
403}