nx_vcf.nx source
↩ module page · 436 lines · 20836 B
1// nx_vcf.nx -- VCF (Variant Call Format) single-line writer.
2//
3// license_tier: INDEPENDENT_REDERIVE
4// genealogy_id: international-research-sources/danecek-2011-vcf-spec-v4.2
5//
6// G2.4 of NISHI_GENOMICS_SUBSTRATE_ROADMAP.md. Closes the variant
7// output side: called variants from snv_call become VCF lines that
8// every downstream tool (bcftools / vcftools / IGV / ClinVar /
9// dbSNP / gnomAD / nf-core pipelines) consumes.
10//
11// VCF v4.2 data-line format (Danecek 2011, NCBI hosts canonical):
12// CHROM POS ID REF ALT QUAL FILTER INFO [FORMAT SAMPLE...]
13// tab-separated, terminated by LF (or CRLF on Windows)
14//
15// G2.4 emits the 8 required fields:
16// CHROM : caller-supplied reference sequence name (no validation)
17// POS : 1-indexed (VCF spec); caller converts from substrate's
18// 0-indexed internal coords
19// ID : "." (no rsID lookup yet; G2.4b composes dbSNP table)
20// REF : single ACGT letter from ref_code (NX_DNA_A/C/G/T)
21// ALT : single ACGT letter from alt_code
22// QUAL : integer Phred-scaled quality (typically 0..60 mapq-equivalent)
23// FILTER : "PASS" if pass_flag=1, "." if 0
24// INFO : "DP=<depth>" (caller can extend; G2.4 just emits DP)
25//
26// Why a single-line writer (not a streaming serialiser):
27// VCF lines are line-by-line composable. Caller drives the per-
28// variant loop, calls this per variant, accumulates bytes into a
29// buffer or writes them to a file. Keeps this primitive small +
30// composable + testable without I/O surface.
31//
32// What G2.4 does NOT do (deferred):
33// - VCF header (##fileformat=VCFv4.2 + ##INFO + ##FILTER + #CHROM) -- G2.4a
34// - Multi-allelic ALT (A>G,T) -- G2.4b
35// - FORMAT + per-sample fields (GT:AD:DP) -- G2.4c
36// - INFO extension (AF, AC, AN, MQ, MQRankSum, etc.) -- G2.4d
37// - VCF parsing (read existing VCF files) -- G2.4e
38// - bgzip + tabix indexing -- G2.4f
39// - VCF -> SAM rsID annotation lookup -- G2.4g
40//
41// API:
42// vcf_write_snv_line(
43// chrom, chrom_len,
44// fields, // 6 i64s: [pos, ref, alt, qual, pass_flag, depth]
45// out_ascii, max_out
46// ) -> i64 bytes written or -1
47//
48// Field bundling works around the nxc2 codegen issue with 9+ scalar
49// arguments; same trick as nx_align_score's region/sw_end bundles.
50//
51// nx_safety_envelope: (schema: nishi-library/seeds/safety-critical-standards.toml)
52// intended_use: "VCF v4.2 single-line variant output;
53// the format every downstream variant
54// consumer (bcftools, IGV, ClinVar, etc.)
55// ingests"
56// sil_target: SIL2
57// asil_target: QM
58// dal_target: DAL C
59// iec_62304_class: B
60// evidence: [no_floating_point, deterministic,
61// bit_equal_reproducible,
62// danecek_2011_vcf_v4_2_spec,
63// composes_nx_cigar_i64_to_ascii_digits,
64// composes_nx_const_NX_DNA_to_ascii,
65// canonical_chr1_12345_A_G_KAT,
66// license_tier_INDEPENDENT_REDERIVE]
67// hazard_register: [bug-tape-vcf-pos-0-indexed-leaked-to-output,
68// bug-tape-vcf-tab-vs-space-separator-confused,
69// bug-tape-vcf-ref-alt-same-code-emitted-as-variant,
70// bug-tape-vcf-line-not-lf-terminated,
71// bug-tape-vcf-bytes-written-mismatched-actual]
72// residual_risk: "G2.4 does not validate ref_code != alt_code
73// because a variant where they match is the
74// snv_call NONE case which the caller should
75// not be writing as a VCF line. Documented
76// in header; caller-side guard."
77// verdict: NOT_YET_EVALUATED
78
79import "nx_syscalls.nx"
80import "nx_const.nx"
81import "nx_sequence.nx" // for dna_decode_base
82import "nx_cigar.nx" // for nx_i64_to_ascii_digits
83
84// Internal: copy a byte string into out_ascii. Returns bytes copied.
85// Returns -1 if max_out exceeded (parameter is the REMAINING capacity).
86func nx_copy_bytes(src: *u8, src_len: i64,
87 dst: *u8, dst_off: i64, max_dst: i64) -> i64 {
88 if dst_off + src_len > max_dst { return -1 }
89 var i: i64 = 0
90 while i < src_len {
91 dst[dst_off + i] = src[i] & 0xff
92 i = i + 1
93 }
94 return src_len
95}
96
97// Internal: copy a one-byte literal.
98func nx_copy_byte(b: i64, dst: *u8, dst_off: i64, max_dst: i64) -> i64 {
99 if dst_off >= max_dst { return -1 }
100 dst[dst_off] = b & 0xff
101 return 1
102}
103
104// Internal: emit decimal integer + return bytes written.
105func nx_emit_int(n: i64, dst: *u8, dst_off: i64, max_dst: i64) -> i64 {
106 let scratch: *u8 = sys_mmap(32)
107 let d_count: i64 = nx_i64_to_ascii_digits(n, scratch)
108 if dst_off + d_count > max_dst { return -1 }
109 var i: i64 = 0
110 while i < d_count {
111 dst[dst_off + i] = scratch[i] & 0xff
112 i = i + 1
113 }
114 return d_count
115}
116
117// Field indices into the bundled fields[6] array for SNV lines.
118const NX_VCF_FIELD_POS: i64 = 0
119const NX_VCF_FIELD_REF: i64 = 1
120const NX_VCF_FIELD_ALT: i64 = 2
121const NX_VCF_FIELD_QUAL: i64 = 3
122const NX_VCF_FIELD_PASS: i64 = 4
123const NX_VCF_FIELD_DEPTH: i64 = 5
124
125// Field indices for multi-byte-allele variant lines (indels / MNVs).
126// REF and ALT bases are passed via ref_alt byte array (concat'd);
127// lengths live in the fields[] array.
128const NX_VCF_VAR_FIELD_POS: i64 = 0
129const NX_VCF_VAR_FIELD_QUAL: i64 = 1
130const NX_VCF_VAR_FIELD_PASS: i64 = 2
131const NX_VCF_VAR_FIELD_DEPTH: i64 = 3
132const NX_VCF_VAR_FIELD_REF_LEN: i64 = 4
133const NX_VCF_VAR_FIELD_ALT_LEN: i64 = 5
134
135// Emit the minimal VCFv4.2 header into out_ascii. Four lines:
136// ##fileformat=VCFv4.2
137// ##INFO=<ID=DP,Number=1,Type=Integer,Description="Total Depth">
138// ##FILTER=<ID=PASS,Description="All filters passed">
139// #CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO
140//
141// Returns total bytes written or -1 if max_out insufficient.
142// The header is fixed (no parameters): callers that need to extend
143// INFO / FILTER definitions append additional ## lines themselves
144// before emitting the first data line via vcf_write_snv_line.
145//
146// Byte total = 21 + 63 + 52 + 39 = 175 (each line LF-terminated).
147func vcf_write_header(out_ascii: *u8, max_out: i64) -> i64 {
148 let total: i64 = 175
149 if max_out < total { return -1 }
150
151 var off: i64 = 0
152
153 // ---- Line 1: "##fileformat=VCFv4.2\n" (21 bytes) ----
154 out_ascii[off+0]=0x23; out_ascii[off+1]=0x23 // ##
155 out_ascii[off+2]=0x66; out_ascii[off+3]=0x69; out_ascii[off+4]=0x6C; out_ascii[off+5]=0x65 // file
156 out_ascii[off+6]=0x66; out_ascii[off+7]=0x6F; out_ascii[off+8]=0x72; out_ascii[off+9]=0x6D; out_ascii[off+10]=0x61; out_ascii[off+11]=0x74 // format
157 out_ascii[off+12]=0x3D // =
158 out_ascii[off+13]=0x56; out_ascii[off+14]=0x43; out_ascii[off+15]=0x46 // VCF
159 out_ascii[off+16]=0x76; out_ascii[off+17]=0x34; out_ascii[off+18]=0x2E; out_ascii[off+19]=0x32 // v4.2
160 out_ascii[off+20]=0x0A // \n
161 off = off + 21
162
163 // ---- Line 2: "##INFO=<ID=DP,Number=1,Type=Integer,Description=\"Total Depth\">\n" (62 bytes) ----
164 out_ascii[off+0]=0x23; out_ascii[off+1]=0x23 // ##
165 out_ascii[off+2]=0x49; out_ascii[off+3]=0x4E; out_ascii[off+4]=0x46; out_ascii[off+5]=0x4F // INFO
166 out_ascii[off+6]=0x3D; out_ascii[off+7]=0x3C // =<
167 out_ascii[off+8]=0x49; out_ascii[off+9]=0x44; out_ascii[off+10]=0x3D; out_ascii[off+11]=0x44; out_ascii[off+12]=0x50 // ID=DP
168 out_ascii[off+13]=0x2C // ,
169 out_ascii[off+14]=0x4E; out_ascii[off+15]=0x75; out_ascii[off+16]=0x6D; out_ascii[off+17]=0x62; out_ascii[off+18]=0x65; out_ascii[off+19]=0x72 // Number
170 out_ascii[off+20]=0x3D; out_ascii[off+21]=0x31 // =1
171 out_ascii[off+22]=0x2C // ,
172 out_ascii[off+23]=0x54; out_ascii[off+24]=0x79; out_ascii[off+25]=0x70; out_ascii[off+26]=0x65 // Type
173 out_ascii[off+27]=0x3D // =
174 out_ascii[off+28]=0x49; out_ascii[off+29]=0x6E; out_ascii[off+30]=0x74; out_ascii[off+31]=0x65; out_ascii[off+32]=0x67; out_ascii[off+33]=0x65; out_ascii[off+34]=0x72 // Integer
175 out_ascii[off+35]=0x2C // ,
176 out_ascii[off+36]=0x44; out_ascii[off+37]=0x65; out_ascii[off+38]=0x73; out_ascii[off+39]=0x63; out_ascii[off+40]=0x72; out_ascii[off+41]=0x69; out_ascii[off+42]=0x70; out_ascii[off+43]=0x74; out_ascii[off+44]=0x69; out_ascii[off+45]=0x6F; out_ascii[off+46]=0x6E // Description
177 out_ascii[off+47]=0x3D; out_ascii[off+48]=0x22 // ="
178 out_ascii[off+49]=0x54; out_ascii[off+50]=0x6F; out_ascii[off+51]=0x74; out_ascii[off+52]=0x61; out_ascii[off+53]=0x6C // Total
179 out_ascii[off+54]=0x20 // space
180 out_ascii[off+55]=0x44; out_ascii[off+56]=0x65; out_ascii[off+57]=0x70; out_ascii[off+58]=0x74; out_ascii[off+59]=0x68 // Depth
181 out_ascii[off+60]=0x22; out_ascii[off+61]=0x3E // ">
182 // Wait, that's 62 bytes (0..61) but I need 62 total including LF.
183 // Let me recount: I've assigned 0..61 = 62 positions = 62 bytes including '>'.
184 // Need to add \n. So actually 63 bytes total for line 2 incl LF. Fixing:
185 out_ascii[off+62]=0x0A // \n
186 off = off + 63 // line 2 is actually 63 bytes incl LF
187
188 // ---- Line 3: "##FILTER=<ID=PASS,Description=\"All filters passed\">\n" (52 bytes) ----
189 out_ascii[off+0]=0x23; out_ascii[off+1]=0x23 // ##
190 out_ascii[off+2]=0x46; out_ascii[off+3]=0x49; out_ascii[off+4]=0x4C; out_ascii[off+5]=0x54; out_ascii[off+6]=0x45; out_ascii[off+7]=0x52 // FILTER
191 out_ascii[off+8]=0x3D; out_ascii[off+9]=0x3C // =<
192 out_ascii[off+10]=0x49; out_ascii[off+11]=0x44; out_ascii[off+12]=0x3D; out_ascii[off+13]=0x50; out_ascii[off+14]=0x41; out_ascii[off+15]=0x53; out_ascii[off+16]=0x53 // ID=PASS
193 out_ascii[off+17]=0x2C // ,
194 out_ascii[off+18]=0x44; out_ascii[off+19]=0x65; out_ascii[off+20]=0x73; out_ascii[off+21]=0x63; out_ascii[off+22]=0x72; out_ascii[off+23]=0x69; out_ascii[off+24]=0x70; out_ascii[off+25]=0x74; out_ascii[off+26]=0x69; out_ascii[off+27]=0x6F; out_ascii[off+28]=0x6E // Description
195 out_ascii[off+29]=0x3D; out_ascii[off+30]=0x22 // ="
196 out_ascii[off+31]=0x41; out_ascii[off+32]=0x6C; out_ascii[off+33]=0x6C // All
197 out_ascii[off+34]=0x20 // space
198 out_ascii[off+35]=0x66; out_ascii[off+36]=0x69; out_ascii[off+37]=0x6C; out_ascii[off+38]=0x74; out_ascii[off+39]=0x65; out_ascii[off+40]=0x72; out_ascii[off+41]=0x73 // filters
199 out_ascii[off+42]=0x20 // space
200 out_ascii[off+43]=0x70; out_ascii[off+44]=0x61; out_ascii[off+45]=0x73; out_ascii[off+46]=0x73; out_ascii[off+47]=0x65; out_ascii[off+48]=0x64 // passed
201 out_ascii[off+49]=0x22; out_ascii[off+50]=0x3E // ">
202 out_ascii[off+51]=0x0A // \n
203 off = off + 52
204
205 // ---- Line 4: "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\n" (38 bytes) ----
206 out_ascii[off+0]=0x23 // #
207 out_ascii[off+1]=0x43; out_ascii[off+2]=0x48; out_ascii[off+3]=0x52; out_ascii[off+4]=0x4F; out_ascii[off+5]=0x4D // CHROM
208 out_ascii[off+6]=0x09 // \t
209 out_ascii[off+7]=0x50; out_ascii[off+8]=0x4F; out_ascii[off+9]=0x53 // POS
210 out_ascii[off+10]=0x09 // \t
211 out_ascii[off+11]=0x49; out_ascii[off+12]=0x44 // ID
212 out_ascii[off+13]=0x09 // \t
213 out_ascii[off+14]=0x52; out_ascii[off+15]=0x45; out_ascii[off+16]=0x46 // REF
214 out_ascii[off+17]=0x09 // \t
215 out_ascii[off+18]=0x41; out_ascii[off+19]=0x4C; out_ascii[off+20]=0x54 // ALT
216 out_ascii[off+21]=0x09 // \t
217 out_ascii[off+22]=0x51; out_ascii[off+23]=0x55; out_ascii[off+24]=0x41; out_ascii[off+25]=0x4C // QUAL
218 out_ascii[off+26]=0x09 // \t
219 out_ascii[off+27]=0x46; out_ascii[off+28]=0x49; out_ascii[off+29]=0x4C; out_ascii[off+30]=0x54; out_ascii[off+31]=0x45; out_ascii[off+32]=0x52 // FILTER
220 out_ascii[off+33]=0x09 // \t
221 out_ascii[off+34]=0x49; out_ascii[off+35]=0x4E; out_ascii[off+36]=0x46; out_ascii[off+37]=0x4F // INFO
222 out_ascii[off+38]=0x0A // \n
223 off = off + 39 // 39 bytes incl LF, not 38
224
225 return off
226}
227
228// Emit one VCF data line with MULTI-BYTE REF and ALT alleles
229// (indels / MNVs / arbitrary substitutions). Per VCF v4.2 spec:
230// Insertion : REF = base before insertion site (1 byte)
231// ALT = REF + inserted bases
232// Deletion : REF = base before deletion + deleted bases
233// ALT = base before deletion (1 byte)
234// General : any non-empty REF / ALT strings (both required >=1 byte)
235//
236// API:
237// vcf_write_var_line(
238// chrom, chrom_len,
239// fields, // [pos, qual, pass_flag, depth, ref_len, alt_len]
240// ref_alt, // REF bases concat'd with ALT bases
241// // (length = ref_len + alt_len; REF is first)
242// out_ascii, max_out
243// ) -> i64 bytes written or -1
244//
245// Args bundled per recurring nxc2 codegen quirk (~10 scalar args
246// silently returns -1; bundle inputs into i64 array + ref_alt byte
247// stream).
248func vcf_write_var_line(chrom: *u8, chrom_len: i64,
249 fields: *i64,
250 ref_alt: *u8,
251 out_ascii: *u8, max_out: i64) -> i64 {
252 if chrom_len < 0 { return -1 }
253 let pos_1indexed: i64 = fields[NX_VCF_VAR_FIELD_POS]
254 let qual: i64 = fields[NX_VCF_VAR_FIELD_QUAL]
255 let pass_flag: i64 = fields[NX_VCF_VAR_FIELD_PASS]
256 let depth: i64 = fields[NX_VCF_VAR_FIELD_DEPTH]
257 let ref_len: i64 = fields[NX_VCF_VAR_FIELD_REF_LEN]
258 let alt_len: i64 = fields[NX_VCF_VAR_FIELD_ALT_LEN]
259 if pos_1indexed < 1 { return -1 }
260 if qual < 0 { return -1 }
261 if depth < 0 { return -1 }
262 if ref_len < 1 { return -1 }
263 if alt_len < 1 { return -1 }
264 if max_out <= 0 { return -1 }
265
266 var off: i64 = 0
267
268 // ---- CHROM ----
269 let n_chrom: i64 = nx_copy_bytes(chrom, chrom_len, out_ascii, off, max_out)
270 if n_chrom < 0 { return -1 }
271 off = off + n_chrom
272
273 // ---- tab + POS ----
274 if nx_copy_byte(NX_ASCII_TAB, out_ascii, off, max_out) < 0 { return -1 }
275 off = off + 1
276 let n_pos: i64 = nx_emit_int(pos_1indexed, out_ascii, off, max_out)
277 if n_pos < 0 { return -1 }
278 off = off + n_pos
279
280 // ---- tab + ID ('.') ----
281 if nx_copy_byte(NX_ASCII_TAB, out_ascii, off, max_out) < 0 { return -1 }
282 off = off + 1
283 if nx_copy_byte(NX_ASCII_DOT, out_ascii, off, max_out) < 0 { return -1 }
284 off = off + 1
285
286 // ---- tab + REF (multi-byte) ----
287 if nx_copy_byte(NX_ASCII_TAB, out_ascii, off, max_out) < 0 { return -1 }
288 off = off + 1
289 let n_ref: i64 = nx_copy_bytes(ref_alt, ref_len, out_ascii, off, max_out)
290 if n_ref < 0 { return -1 }
291 off = off + n_ref
292
293 // ---- tab + ALT (multi-byte, starts at ref_alt + ref_len) ----
294 if nx_copy_byte(NX_ASCII_TAB, out_ascii, off, max_out) < 0 { return -1 }
295 off = off + 1
296 let alt_start: *u8 = ((ref_alt as i64) + ref_len) as *u8
297 let n_alt: i64 = nx_copy_bytes(alt_start, alt_len, out_ascii, off, max_out)
298 if n_alt < 0 { return -1 }
299 off = off + n_alt
300
301 // ---- tab + QUAL ----
302 if nx_copy_byte(NX_ASCII_TAB, out_ascii, off, max_out) < 0 { return -1 }
303 off = off + 1
304 let n_qual: i64 = nx_emit_int(qual, out_ascii, off, max_out)
305 if n_qual < 0 { return -1 }
306 off = off + n_qual
307
308 // ---- tab + FILTER ----
309 if nx_copy_byte(NX_ASCII_TAB, out_ascii, off, max_out) < 0 { return -1 }
310 off = off + 1
311 if pass_flag == 1 {
312 if off + 4 > max_out { return -1 }
313 out_ascii[off] = 0x50
314 out_ascii[off+1] = 0x41
315 out_ascii[off+2] = 0x53
316 out_ascii[off+3] = 0x53
317 off = off + 4
318 } else {
319 if nx_copy_byte(NX_ASCII_DOT, out_ascii, off, max_out) < 0 { return -1 }
320 off = off + 1
321 }
322
323 // ---- tab + INFO "DP=<depth>" ----
324 if nx_copy_byte(NX_ASCII_TAB, out_ascii, off, max_out) < 0 { return -1 }
325 off = off + 1
326 if off + 3 > max_out { return -1 }
327 out_ascii[off] = 0x44
328 out_ascii[off+1] = 0x50
329 out_ascii[off+2] = 0x3D
330 off = off + 3
331 let n_dp: i64 = nx_emit_int(depth, out_ascii, off, max_out)
332 if n_dp < 0 { return -1 }
333 off = off + n_dp
334
335 // ---- terminating LF ----
336 if nx_copy_byte(NX_ASCII_LF, out_ascii, off, max_out) < 0 { return -1 }
337 off = off + 1
338
339 return off
340}
341
342// Emit one VCF SNV data line per spec. See header for API.
343func vcf_write_snv_line(chrom: *u8, chrom_len: i64,
344 fields: *i64,
345 out_ascii: *u8, max_out: i64) -> i64 {
346 if chrom_len < 0 { return -1 }
347 let pos_1indexed: i64 = fields[NX_VCF_FIELD_POS]
348 let ref_code: i64 = fields[NX_VCF_FIELD_REF]
349 let alt_code: i64 = fields[NX_VCF_FIELD_ALT]
350 let qual: i64 = fields[NX_VCF_FIELD_QUAL]
351 let pass_flag: i64 = fields[NX_VCF_FIELD_PASS]
352 let depth: i64 = fields[NX_VCF_FIELD_DEPTH]
353 if pos_1indexed < 1 { return -1 }
354 if ref_code < 0 { return -1 }
355 if ref_code > 3 { return -1 }
356 if alt_code < 0 { return -1 }
357 if alt_code > 3 { return -1 }
358 if qual < 0 { return -1 }
359 if depth < 0 { return -1 }
360 if max_out <= 0 { return -1 }
361
362 var off: i64 = 0
363
364 // ---- CHROM ----
365 let n_chrom: i64 = nx_copy_bytes(chrom, chrom_len, out_ascii, off, max_out)
366 if n_chrom < 0 { return -1 }
367 off = off + n_chrom
368
369 // ---- tab + POS ----
370 if nx_copy_byte(NX_ASCII_TAB, out_ascii, off, max_out) < 0 { return -1 }
371 off = off + 1
372 let n_pos: i64 = nx_emit_int(pos_1indexed, out_ascii, off, max_out)
373 if n_pos < 0 { return -1 }
374 off = off + n_pos
375
376 // ---- tab + ID ('.') ----
377 if nx_copy_byte(NX_ASCII_TAB, out_ascii, off, max_out) < 0 { return -1 }
378 off = off + 1
379 if nx_copy_byte(NX_ASCII_DOT, out_ascii, off, max_out) < 0 { return -1 }
380 off = off + 1
381
382 // ---- tab + REF letter ----
383 if nx_copy_byte(NX_ASCII_TAB, out_ascii, off, max_out) < 0 { return -1 }
384 off = off + 1
385 let ref_letter: i64 = dna_decode_base(ref_code)
386 if nx_copy_byte(ref_letter, out_ascii, off, max_out) < 0 { return -1 }
387 off = off + 1
388
389 // ---- tab + ALT letter ----
390 if nx_copy_byte(NX_ASCII_TAB, out_ascii, off, max_out) < 0 { return -1 }
391 off = off + 1
392 let alt_letter: i64 = dna_decode_base(alt_code)
393 if nx_copy_byte(alt_letter, out_ascii, off, max_out) < 0 { return -1 }
394 off = off + 1
395
396 // ---- tab + QUAL ----
397 if nx_copy_byte(NX_ASCII_TAB, out_ascii, off, max_out) < 0 { return -1 }
398 off = off + 1
399 let n_qual: i64 = nx_emit_int(qual, out_ascii, off, max_out)
400 if n_qual < 0 { return -1 }
401 off = off + n_qual
402
403 // ---- tab + FILTER ----
404 if nx_copy_byte(NX_ASCII_TAB, out_ascii, off, max_out) < 0 { return -1 }
405 off = off + 1
406 if pass_flag == 1 {
407 // "PASS" = 4 bytes 0x50 0x41 0x53 0x53
408 if off + 4 > max_out { return -1 }
409 out_ascii[off] = 0x50 // P
410 out_ascii[off+1] = 0x41 // A
411 out_ascii[off+2] = 0x53 // S
412 out_ascii[off+3] = 0x53 // S
413 off = off + 4
414 } else {
415 if nx_copy_byte(NX_ASCII_DOT, out_ascii, off, max_out) < 0 { return -1 }
416 off = off + 1
417 }
418
419 // ---- tab + INFO "DP=<depth>" ----
420 if nx_copy_byte(NX_ASCII_TAB, out_ascii, off, max_out) < 0 { return -1 }
421 off = off + 1
422 if off + 3 > max_out { return -1 }
423 out_ascii[off] = 0x44 // D
424 out_ascii[off+1] = 0x50 // P
425 out_ascii[off+2] = 0x3D // =
426 off = off + 3
427 let n_dp: i64 = nx_emit_int(depth, out_ascii, off, max_out)
428 if n_dp < 0 { return -1 }
429 off = off + n_dp
430
431 // ---- terminating LF ----
432 if nx_copy_byte(NX_ASCII_LF, out_ascii, off, max_out) < 0 { return -1 }
433 off = off + 1
434
435 return off
436}