code wiki / (root) / nx_nxa_play.nx

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}