@blockcast/mmt-base 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,862 @@
1
+ /**
2
+ * Receiver-side parsers for MMTP signalling message mode.
3
+ *
4
+ * - The MMTP payload header for signalling message mode
5
+ * (ISO/IEC 23008-1:2023 9.3.4.2, Figure 15).
6
+ * - The general signalling message format (10.2, Table 20).
7
+ * - The AL-FEC message (Annex C Table C.3 as replaced by
8
+ * ISO/IEC 23008-1:2023/Amd 1:2025; semantics C.6.3).
9
+ * - The PA message (10.3.2, Table 21) and MPT message (10.3.4, Table 23)
10
+ * with the MP table (10.3.9, Table 29), Identifier_mapping (10.6.3,
11
+ * Table 98), asset_id (10.6.2, Table 97) and MMT_general_location_info
12
+ * (10.6.1, Table 95).
13
+ *
14
+ * Every parser here is total: malformed, truncated, ambiguous or unsupported
15
+ * input yields null, never an exception. Nothing is inferred: a field the
16
+ * receiver cannot place exactly makes the whole message unparseable, because
17
+ * every later field of a sequential syntax would be misread after it.
18
+ *
19
+ * Reserved bits are ignored on receipt. The senders write them, and a
20
+ * receiver that refuses a reserved value cannot accept a conformant revision
21
+ * of the syntax that assigns it.
22
+ */
23
+ import {
24
+ ALFEC_MESSAGE_ID,
25
+ FEC_CODE_RAPTORQ,
26
+ PACKET_TYPE_SIGNALING,
27
+ parseMMTPHeader,
28
+ type MMTPHeader,
29
+ } from './mmtp.js'
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Wire reader
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /** Internal: raised by WireReader, caught at every exported parser. */
36
+ class MalformedSignalling extends Error {}
37
+
38
+ class WireReader {
39
+ readonly #bytes: Uint8Array
40
+ readonly #end: number
41
+ #offset: number
42
+
43
+ constructor(bytes: Uint8Array, start = 0, end = bytes.length) {
44
+ this.#bytes = bytes
45
+ this.#offset = start
46
+ this.#end = end
47
+ }
48
+
49
+ get offset(): number {
50
+ return this.#offset
51
+ }
52
+
53
+ get remaining(): number {
54
+ return this.#end - this.#offset
55
+ }
56
+
57
+ #take(length: number): number {
58
+ if (length < 0 || length > this.remaining) throw new MalformedSignalling('truncated')
59
+ const at = this.#offset
60
+ this.#offset += length
61
+ return at
62
+ }
63
+
64
+ u8(): number {
65
+ return this.#bytes[this.#take(1)]!
66
+ }
67
+
68
+ u16(): number {
69
+ const at = this.#take(2)
70
+ return (this.#bytes[at]! << 8) | this.#bytes[at + 1]!
71
+ }
72
+
73
+ u24(): number {
74
+ const at = this.#take(3)
75
+ return (this.#bytes[at]! << 16) | (this.#bytes[at + 1]! << 8) | this.#bytes[at + 2]!
76
+ }
77
+
78
+ u32(): number {
79
+ const at = this.#take(4)
80
+ return ((this.#bytes[at]! << 24) | (this.#bytes[at + 1]! << 16) |
81
+ (this.#bytes[at + 2]! << 8) | this.#bytes[at + 3]!) >>> 0
82
+ }
83
+
84
+ /** A copy, so a parsed message never aliases a reused packet buffer. */
85
+ bytes(length: number): Uint8Array {
86
+ const at = this.#take(length)
87
+ return this.#bytes.slice(at, at + length)
88
+ }
89
+
90
+ /** Throw unless every byte up to the end bound was consumed. */
91
+ expectEnd(): void {
92
+ if (this.remaining !== 0) throw new MalformedSignalling('trailing bytes')
93
+ }
94
+ }
95
+
96
+ function fail(reason: string): never {
97
+ throw new MalformedSignalling(reason)
98
+ }
99
+
100
+ /** Run a parser, turning only our own malformed-input signal into null. */
101
+ function total<T>(parse: () => T | null): T | null {
102
+ try {
103
+ return parse()
104
+ } catch (error) {
105
+ if (error instanceof MalformedSignalling) return null
106
+ throw error
107
+ }
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // 9.3.4.2 — MMTP payload header for signalling message mode
112
+ // ---------------------------------------------------------------------------
113
+
114
+ /**
115
+ * How the signalling payload of an MMTP packet was framed.
116
+ *
117
+ * - `iso-9.3.4.2`: the 2-byte `f_i(2) RES(4) H(1) A(1) | frag_counter(8)`
118
+ * header of ISO/IEC 23008-1:2023 9.3.4.2 precedes the message(s).
119
+ * - `legacy-bare-alfec`: the pre-remediation Blockcast form, where the
120
+ * AL-FEC message starts directly after the MMTP header. It is recognised
121
+ * only by its first two bytes being `0x02 0x03` (message_id 0x0203). That
122
+ * pair is never a valid 9.3.4.2 header for a complete message: f_i=00 with
123
+ * frag_counter=3 claims three further fragments of a message the same
124
+ * header declares complete. The peek is therefore exact, not a heuristic.
125
+ * The legacy branch is deleted once no legacy sender remains (BLO-35597
126
+ * plan, Phase L).
127
+ */
128
+ export type SignallingPayloadForm = 'iso-9.3.4.2' | 'legacy-bare-alfec'
129
+
130
+ export interface SignallingPayload {
131
+ form: SignallingPayloadForm
132
+ /** Each complete signalling message, starting at its message_id. */
133
+ messages: Uint8Array[]
134
+ }
135
+
136
+ /** f_i (Table 19): payload contains one or more complete signalling messages. */
137
+ export const SIGNALLING_FRAGMENTATION_COMPLETE = 0b00
138
+
139
+ /**
140
+ * Split the payload of an MMTP signalling packet into its complete
141
+ * signalling messages.
142
+ *
143
+ * Returns null when the packet is not a well-formed, unprotected (FEC_type 0)
144
+ * signalling packet, or when it carries a fragment (f_i != 00): reassembly
145
+ * across packets is not implemented, and a fragment is not a message.
146
+ */
147
+ export function parseSignallingPayload(mmtpPacket: Uint8Array): SignallingPayload | null {
148
+ let header: MMTPHeader
149
+ try {
150
+ header = parseMMTPHeader(mmtpPacket)
151
+ } catch {
152
+ return null
153
+ }
154
+ if (header.packetType !== PACKET_TYPE_SIGNALING || header.fecType !== 0) return null
155
+ const payload = mmtpPacket.subarray(header.headerLength)
156
+ return total(() => splitSignallingPayload(payload))
157
+ }
158
+
159
+ function splitSignallingPayload(payload: Uint8Array): SignallingPayload {
160
+ if (payload.length < 2) fail('short signalling payload')
161
+ if (payload[0] === (ALFEC_MESSAGE_ID >> 8) && payload[1] === (ALFEC_MESSAGE_ID & 0xff)) {
162
+ return { form: 'legacy-bare-alfec', messages: [payload] }
163
+ }
164
+
165
+ const reader = new WireReader(payload)
166
+ const flags = reader.u8()
167
+ const fragmentation = flags >> 6
168
+ const lengthIs32Bits = ((flags >> 1) & 1) === 1 // H
169
+ const aggregated = (flags & 1) === 1 // A
170
+ const fragmentCounter = reader.u8()
171
+ if (fragmentation !== SIGNALLING_FRAGMENTATION_COMPLETE) fail('fragmented signalling message')
172
+ // frag_counter counts the fragments that follow this one; a complete
173
+ // message has none, and an aggregated payload "shall" carry 0.
174
+ if (fragmentCounter !== 0) fail('frag_counter on a complete message')
175
+
176
+ if (!aggregated) {
177
+ // A=0: exactly one message and no MSG_length field (H is meaningless).
178
+ if (reader.remaining === 0) fail('empty signalling payload')
179
+ return { form: 'iso-9.3.4.2', messages: [payload.subarray(reader.offset)] }
180
+ }
181
+
182
+ const messages: Uint8Array[] = []
183
+ while (reader.remaining > 0) {
184
+ const length = lengthIs32Bits ? reader.u32() : reader.u16()
185
+ if (length === 0) fail('zero MSG_length')
186
+ const start = reader.offset
187
+ reader.bytes(length) // bounds check
188
+ messages.push(payload.subarray(start, start + length))
189
+ }
190
+ if (messages.length === 0) fail('aggregated payload with no message')
191
+ return { form: 'iso-9.3.4.2', messages }
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // 10.2 — general signalling message format
196
+ // ---------------------------------------------------------------------------
197
+
198
+ export const PA_MESSAGE_ID = 0x0000
199
+ export const MPI_MESSAGE_ID_FIRST = 0x0001
200
+ export const MPI_MESSAGE_ID_LAST = 0x0010
201
+ export const MPT_MESSAGE_ID_FIRST = 0x0011
202
+ /** MPT message carrying the complete MP table (Table 101). */
203
+ export const MPT_MESSAGE_ID_COMPLETE = 0x0020
204
+
205
+ /**
206
+ * Whether a message's `length` field is 32 bits (Table 20: PA and MPI
207
+ * messages; Table 101: the 0x7000-0x7FFF range "32-bit length message").
208
+ */
209
+ export function signallingMessageHas32BitLength(messageId: number): boolean {
210
+ return messageId === PA_MESSAGE_ID ||
211
+ (messageId >= MPI_MESSAGE_ID_FIRST && messageId <= MPI_MESSAGE_ID_LAST) ||
212
+ (messageId >= 0x7000 && messageId <= 0x7fff)
213
+ }
214
+
215
+ /**
216
+ * Read the general header (Table 20) of a complete message and check that
217
+ * `length` covers exactly the rest of the message.
218
+ */
219
+ function readMessageHeader(message: Uint8Array): { reader: WireReader; messageId: number; version: number } {
220
+ const reader = new WireReader(message)
221
+ const messageId = reader.u16()
222
+ const version = reader.u8()
223
+ const length = signallingMessageHas32BitLength(messageId) ? reader.u32() : reader.u16()
224
+ // "The value 0 is not valid for this field" (C.6.3, 10.3.2.3, 10.3.4.3).
225
+ if (length === 0 || length !== reader.remaining) fail('message length mismatch')
226
+ return { reader, messageId, version }
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Annex C Table C.3 (Amd 1:2025) — AL-FEC message
231
+ // ---------------------------------------------------------------------------
232
+
233
+ /** fec_coding_structure values (Table C.8 as replaced by Amd 1:2025). */
234
+ export const FEC_CODING_STRUCTURE_NONE = 0b0000
235
+ export const FEC_CODING_STRUCTURE_ONE_STAGE = 0b0001
236
+ export const FEC_CODING_STRUCTURE_TWO_STAGE = 0b0010
237
+ export const FEC_CODING_STRUCTURE_LAYER_AWARE = 0b0011
238
+ export const FEC_CODING_STRUCTURE_ADAPTIVE = 0b0100
239
+
240
+ /** ssbg_mode values (Table C.5). b11 is reserved. */
241
+ export const SSBG_MODE_0 = 0b00
242
+ export const SSBG_MODE_1 = 0b01
243
+ export const SSBG_MODE_2 = 0b10
244
+
245
+ export interface AlFecPrivateField {
246
+ privateFlag: 0 | 1
247
+ data: Uint8Array
248
+ }
249
+
250
+ export interface AlFecRepairFlow {
251
+ /**
252
+ * repair_flow_id (16 bits under Amd 1:2025 for one-stage, two-stage and
253
+ * LA-FEC). Blockcast senders set it to the repair flow's MMTP packet_id.
254
+ */
255
+ repairFlowId: number
256
+ /**
257
+ * fec_code_id_for_repair_flow. Under LA-FEC (0011) one value is signalled
258
+ * per FEC flow and is repeated here on every layer.
259
+ */
260
+ fecCodeId: number
261
+ /**
262
+ * The private_flag/private_field_length/private_field triple, present
263
+ * exactly when the message's private_fec_flag is 1. Shared by every layer
264
+ * under LA-FEC, like fecCodeId.
265
+ */
266
+ privateField?: AlFecPrivateField
267
+ maximumK: number
268
+ maximumP: number
269
+ protectionWindowTime: number
270
+ protectionWindowSize: number
271
+ }
272
+
273
+ export interface AlFecFlow {
274
+ fecFlowId: number
275
+ sourceFlowId: number
276
+ /** Every asset packet_id of the FEC source flow, in signalled order. */
277
+ packetIds: number[]
278
+ fecCodingStructure: number
279
+ ssbgMode: number
280
+ ffsrptsFlag: 0 | 1
281
+ /**
282
+ * The low bit of the coding byte. Amd 1:2025 Table C.3 names it
283
+ * fec_payload_id_mode (Table C.7: 0 = mode 0, Source FEC Payload ID added
284
+ * to each packet; 1 = mode 1, C.7, packets unmodified). The unamended 2023
285
+ * Table C.3 lists the same bit as reserved "1". The parse never depends on
286
+ * it — no later field is conditional on it — so both readings are
287
+ * accepted and the value is reported, not interpreted (BLO-35597 N1).
288
+ */
289
+ fecPayloadIdMode: 0 | 1
290
+ /** length_of_repair_symbol, in bytes. */
291
+ repairSymbolSize: number
292
+ /** Present exactly when ssbg_mode is b10. */
293
+ ssbgMode2?: {
294
+ numRepairSymbolPerPacket: number
295
+ numSymbolElementPerSourceSymbol: number
296
+ }
297
+ /** num_of_sub_block_per_source_block, two-stage (0010) only. */
298
+ numSubBlockPerSourceBlock?: number
299
+ /** One entry for one-stage; N3 parity flows; N4 LA-FEC layers; none for 0000. */
300
+ repairFlows: AlFecRepairFlow[]
301
+ }
302
+
303
+ export interface AlFecMessageBody {
304
+ /** The message's version counter (C.6.3). Any value is accepted. */
305
+ version: number
306
+ fecFlag: 0 | 1
307
+ privateFecFlag: 0 | 1
308
+ /** Empty exactly when fec_flag is 0 (no FEC source flow). */
309
+ flows: AlFecFlow[]
310
+ }
311
+
312
+ export interface AlFecMessage extends AlFecMessageBody {
313
+ payloadForm: SignallingPayloadForm
314
+ }
315
+
316
+ function readPrivateField(reader: WireReader): AlFecPrivateField {
317
+ const head = reader.u8()
318
+ return { privateFlag: (head >> 7) as 0 | 1, data: reader.bytes(head & 0x7f) }
319
+ }
320
+
321
+ function readRepairFlowTail(
322
+ reader: WireReader,
323
+ repairFlowId: number,
324
+ fecCodeId: number,
325
+ privateField: AlFecPrivateField | undefined,
326
+ ): AlFecRepairFlow {
327
+ const maximumK = reader.u24()
328
+ const maximumP = reader.u24()
329
+ const protectionWindowTime = reader.u32()
330
+ const protectionWindowSize = reader.u32()
331
+ return {
332
+ repairFlowId,
333
+ fecCodeId,
334
+ ...(privateField ? { privateField } : {}),
335
+ maximumK,
336
+ maximumP,
337
+ protectionWindowTime,
338
+ protectionWindowSize,
339
+ }
340
+ }
341
+
342
+ function readRepairFlow(reader: WireReader, privateFecFlag: boolean): AlFecRepairFlow {
343
+ const repairFlowId = reader.u16()
344
+ const fecCodeId = reader.u8()
345
+ const privateField = privateFecFlag ? readPrivateField(reader) : undefined
346
+ return readRepairFlowTail(reader, repairFlowId, fecCodeId, privateField)
347
+ }
348
+
349
+ function readFecFlow(reader: WireReader, privateFecFlag: boolean): AlFecFlow {
350
+ const fecFlowId = reader.u8()
351
+ const sourceFlowId = reader.u8()
352
+ const numberOfAssets = reader.u8()
353
+ if (numberOfAssets === 0) fail('FEC flow with no asset')
354
+ const packetIds: number[] = []
355
+ for (let i = 0; i < numberOfAssets; i++) packetIds.push(reader.u16())
356
+
357
+ const coding = reader.u8()
358
+ const fecCodingStructure = coding >> 4
359
+ const ssbgMode = (coding >> 2) & 0b11
360
+ const ffsrptsFlag = ((coding >> 1) & 1) as 0 | 1
361
+ const fecPayloadIdMode = (coding & 1) as 0 | 1
362
+ if (ssbgMode === 0b11) fail('reserved ssbg_mode')
363
+ const repairSymbolSize = reader.u16()
364
+
365
+ const flow: AlFecFlow = {
366
+ fecFlowId,
367
+ sourceFlowId,
368
+ packetIds,
369
+ fecCodingStructure,
370
+ ssbgMode,
371
+ ffsrptsFlag,
372
+ fecPayloadIdMode,
373
+ repairSymbolSize,
374
+ repairFlows: [],
375
+ }
376
+ if (ssbgMode === SSBG_MODE_2) {
377
+ flow.ssbgMode2 = {
378
+ numRepairSymbolPerPacket: reader.u16(),
379
+ numSymbolElementPerSourceSymbol: reader.u16(),
380
+ }
381
+ }
382
+
383
+ switch (fecCodingStructure) {
384
+ case FEC_CODING_STRUCTURE_NONE:
385
+ break
386
+ case FEC_CODING_STRUCTURE_ONE_STAGE:
387
+ flow.repairFlows.push(readRepairFlow(reader, privateFecFlag))
388
+ break
389
+ case FEC_CODING_STRUCTURE_TWO_STAGE: {
390
+ flow.numSubBlockPerSourceBlock = reader.u8()
391
+ const numberOfParityFlows = reader.u8()
392
+ for (let k = 0; k < numberOfParityFlows; k++) {
393
+ flow.repairFlows.push(readRepairFlow(reader, privateFecFlag))
394
+ }
395
+ break
396
+ }
397
+ case FEC_CODING_STRUCTURE_LAYER_AWARE: {
398
+ const numberOfLayers = reader.u8()
399
+ const fecCodeId = reader.u8()
400
+ const privateField = privateFecFlag ? readPrivateField(reader) : undefined
401
+ for (let l = 0; l < numberOfLayers; l++) {
402
+ flow.repairFlows.push(readRepairFlowTail(reader, reader.u16(), fecCodeId, privateField))
403
+ }
404
+ break
405
+ }
406
+ default:
407
+ // 0100 (adaptive FEC, Amd 1:2025): the published table leaves the
408
+ // width of number_of__class unstated, so its flows cannot be placed
409
+ // exactly. 0101-1111 are reserved. Either way every later flow would
410
+ // be misread, so the whole message is refused.
411
+ fail(`unsupported fec_coding_structure ${fecCodingStructure}`)
412
+ }
413
+ return flow
414
+ }
415
+
416
+ /**
417
+ * Parse a complete AL-FEC message (Table C.3, Amd 1:2025), starting at its
418
+ * message_id. Every FEC flow, every asset packet_id and every
419
+ * coding-structure-conditional field is returned.
420
+ */
421
+ export function parseAlFecMessageBody(message: Uint8Array): AlFecMessageBody | null {
422
+ return total(() => {
423
+ const { reader, messageId, version } = readMessageHeader(message)
424
+ if (messageId !== ALFEC_MESSAGE_ID) return null
425
+ const flags = reader.u8()
426
+ const fecFlag = (flags >> 7) as 0 | 1
427
+ const privateFecFlag = ((flags >> 6) & 1) as 0 | 1
428
+ if (fecFlag === 0) {
429
+ // "If every source flows are delivered without AL-FEC protection, all
430
+ // the remaining message is not to be delivered" (C.6.3).
431
+ reader.expectEnd()
432
+ return { version, fecFlag, privateFecFlag, flows: [] }
433
+ }
434
+ const descriptorLength = reader.u16()
435
+ if (descriptorLength === 0 || descriptorLength !== reader.remaining) {
436
+ fail('length_of_fec_flow_descriptor mismatch')
437
+ }
438
+ const numberOfFecFlows = reader.u8()
439
+ if (numberOfFecFlows === 0) fail('fec_flag=1 with no FEC flow')
440
+ const flows: AlFecFlow[] = []
441
+ for (let i = 0; i < numberOfFecFlows; i++) flows.push(readFecFlow(reader, privateFecFlag === 1))
442
+ reader.expectEnd()
443
+ return { version, fecFlag, privateFecFlag, flows }
444
+ })
445
+ }
446
+
447
+ /**
448
+ * Parse the AL-FEC message carried by a full MMTP signalling packet, in
449
+ * either payload form (see SignallingPayloadForm). Returns null when the
450
+ * packet carries no AL-FEC message, more than one, or a malformed one.
451
+ */
452
+ export function parseAlFecMessage(mmtpPacket: Uint8Array): AlFecMessage | null {
453
+ const packet = parseSignallingPacket(mmtpPacket)
454
+ if (!packet) return null
455
+ const alFec = packet.messages.filter(
456
+ (message): message is Extract<MmtSignallingMessage, { kind: 'al-fec' }> => message.kind === 'al-fec',
457
+ )
458
+ if (alFec.length !== 1) return null
459
+ return { ...alFec[0]!.message, payloadForm: packet.payloadForm }
460
+ }
461
+
462
+ /**
463
+ * Decoder geometry of one FEC flow under the Blockcast one-stage RaptorQ
464
+ * profile: private_field = RaptorQ FEC OTI (RFC 6330 3.3.2-3.3.3, 12 bytes)
465
+ * followed by the interleave depth (1 byte).
466
+ */
467
+ export interface AlFecRaptorQFlowConfig {
468
+ sourcePacketIds: number[]
469
+ repairFlowId: number
470
+ ssbgMode: number
471
+ fecPayloadIdMode: 0 | 1
472
+ repairSymbolSize: number
473
+ maximumK: number
474
+ maximumP: number
475
+ interleaveDepth: number
476
+ oti: Uint8Array
477
+ protectionWindowTime: number
478
+ protectionWindowSize: number
479
+ }
480
+
481
+ const RAPTORQ_PRIVATE_FIELD_LENGTH = 13
482
+ const RAPTORQ_MAX_SOURCE_SYMBOLS = 56_403
483
+ const REPAIR_SYMBOL_ID_MAX = 0x00ff_ffff
484
+
485
+ function canonicalRaptorQOtiK(oti: Uint8Array, repairSymbolSize: number): number | null {
486
+ if (oti.length !== 12 || oti[5] !== 0) return null
487
+ const transferLength = (oti[0]! * 0x1_0000_0000) +
488
+ (((oti[1]! << 24) | (oti[2]! << 16) | (oti[3]! << 8) | oti[4]!) >>> 0)
489
+ const symbolSize = (oti[6]! << 8) | oti[7]!
490
+ const sourceBlocks = oti[8]!
491
+ const subBlocks = (oti[9]! << 8) | oti[10]!
492
+ const alignment = oti[11]!
493
+ if (!Number.isSafeInteger(transferLength) || transferLength <= 0 ||
494
+ symbolSize !== repairSymbolSize || symbolSize === 0 || symbolSize % 8 !== 0 ||
495
+ sourceBlocks !== 1 || subBlocks !== 1 || alignment !== 8 ||
496
+ transferLength % symbolSize !== 0) {
497
+ return null
498
+ }
499
+ const k = transferLength / symbolSize
500
+ return Number.isInteger(k) && k >= 1 && k <= RAPTORQ_MAX_SOURCE_SYMBOLS ? k : null
501
+ }
502
+
503
+ /**
504
+ * Validate one parsed FEC flow against the Blockcast one-stage RaptorQ
505
+ * profile and return its decoder geometry, or null when the flow is outside
506
+ * the profile. Nothing is inferred: K comes from the OTI and must equal
507
+ * maximum_k_for_repair_flow.
508
+ *
509
+ * ssbg_mode 0 and 1 are both returned as signalled: which source-symbol
510
+ * construction a decoder applies is the stream's FEC block profile, decided
511
+ * by its consumer, not here. ffsrpts_flag=1 is refused because the FFSRP_TS
512
+ * word changes the source/repair packet trailer layout that every Blockcast
513
+ * receiver assumes.
514
+ */
515
+ export function alFecRaptorQFlowConfig(flow: AlFecFlow): AlFecRaptorQFlowConfig | null {
516
+ if (flow.fecCodingStructure !== FEC_CODING_STRUCTURE_ONE_STAGE || flow.repairFlows.length !== 1) return null
517
+ if (flow.ssbgMode !== SSBG_MODE_0 && flow.ssbgMode !== SSBG_MODE_1) return null
518
+ if (flow.ffsrptsFlag !== 0) return null
519
+ const repair = flow.repairFlows[0]!
520
+ if (repair.fecCodeId !== FEC_CODE_RAPTORQ) return null
521
+ const privateField = repair.privateField
522
+ if (!privateField || privateField.privateFlag !== 1 ||
523
+ privateField.data.length !== RAPTORQ_PRIVATE_FIELD_LENGTH) {
524
+ return null
525
+ }
526
+ const oti = privateField.data.slice(0, 12)
527
+ const interleaveDepth = privateField.data[12]!
528
+ const otiK = canonicalRaptorQOtiK(oti, flow.repairSymbolSize)
529
+ if (otiK === null || interleaveDepth === 0) return null
530
+ if (repair.maximumK !== otiK || repair.maximumP === 0 ||
531
+ repair.maximumK + repair.maximumP - 1 > REPAIR_SYMBOL_ID_MAX) {
532
+ return null
533
+ }
534
+ return {
535
+ sourcePacketIds: [...flow.packetIds],
536
+ repairFlowId: repair.repairFlowId,
537
+ ssbgMode: flow.ssbgMode,
538
+ fecPayloadIdMode: flow.fecPayloadIdMode,
539
+ repairSymbolSize: flow.repairSymbolSize,
540
+ maximumK: repair.maximumK,
541
+ maximumP: repair.maximumP,
542
+ interleaveDepth,
543
+ oti,
544
+ protectionWindowTime: repair.protectionWindowTime,
545
+ protectionWindowSize: repair.protectionWindowSize,
546
+ }
547
+ }
548
+
549
+ // ---------------------------------------------------------------------------
550
+ // 10.6 — location, asset_id and identifier mapping
551
+ // ---------------------------------------------------------------------------
552
+
553
+ /** location_type values (Table 96) a receiver may meet in an asset location. */
554
+ export type MmtGeneralLocation =
555
+ | { locationType: 0x00; packetId: number }
556
+ | { locationType: 0x01; srcAddress: Uint8Array; dstAddress: Uint8Array; dstPort: number; packetId: number }
557
+ | { locationType: 0x02; srcAddress: Uint8Array; dstAddress: Uint8Array; dstPort: number; packetId: number }
558
+ | { locationType: 0x03; networkId: number; transportStreamId: number; mpeg2Pid: number }
559
+ | { locationType: 0x04; srcAddress: Uint8Array; dstAddress: Uint8Array; dstPort: number; mpeg2Pid: number }
560
+ | { locationType: 0x05; url: Uint8Array }
561
+ | { locationType: 0x06; data: Uint8Array }
562
+
563
+ /**
564
+ * MMT_general_location_info() for an Asset location. "Only the value of
565
+ * location_type between 0x00 and 0x06 shall be used for an Asset location"
566
+ * (10.3.9.3), so any other value is refused rather than skipped: the
567
+ * remaining types have their own lengths, and a reserved one has none.
568
+ */
569
+ function readAssetLocation(reader: WireReader): MmtGeneralLocation {
570
+ const locationType = reader.u8()
571
+ switch (locationType) {
572
+ case 0x00:
573
+ return { locationType, packetId: reader.u16() }
574
+ case 0x01:
575
+ case 0x02: {
576
+ const addressLength = locationType === 0x01 ? 4 : 16
577
+ const srcAddress = reader.bytes(addressLength)
578
+ const dstAddress = reader.bytes(addressLength)
579
+ const dstPort = reader.u16()
580
+ return { locationType, srcAddress, dstAddress, dstPort, packetId: reader.u16() }
581
+ }
582
+ case 0x03: {
583
+ const networkId = reader.u16()
584
+ const transportStreamId = reader.u16()
585
+ return { locationType, networkId, transportStreamId, mpeg2Pid: reader.u16() & 0x1fff }
586
+ }
587
+ case 0x04: {
588
+ const srcAddress = reader.bytes(16)
589
+ const dstAddress = reader.bytes(16)
590
+ const dstPort = reader.u16()
591
+ return { locationType, srcAddress, dstAddress, dstPort, mpeg2Pid: reader.u16() & 0x1fff }
592
+ }
593
+ case 0x05:
594
+ return { locationType, url: reader.bytes(reader.u8()) }
595
+ case 0x06:
596
+ return { locationType, data: reader.bytes(reader.u16()) }
597
+ default:
598
+ fail(`location_type ${locationType} is not an Asset location`)
599
+ }
600
+ }
601
+
602
+ /** Identifier_mapping() (Table 98). */
603
+ export type MmtIdentifierMapping =
604
+ | { identifierType: 0x00; assetIdScheme: number; assetId: Uint8Array }
605
+ | { identifierType: 0x01; urls: Uint8Array[] }
606
+ | { identifierType: 0x02; regex: Uint8Array }
607
+ | { identifierType: 0x03; representationId: Uint8Array }
608
+ | { identifierType: number; privateIdentifier: Uint8Array }
609
+
610
+ function readIdentifierMapping(reader: WireReader): MmtIdentifierMapping {
611
+ const identifierType = reader.u8()
612
+ switch (identifierType) {
613
+ case 0x00: {
614
+ const assetIdScheme = reader.u32()
615
+ return { identifierType, assetIdScheme, assetId: reader.bytes(reader.u32()) }
616
+ }
617
+ case 0x01: {
618
+ const urls: Uint8Array[] = []
619
+ const urlCount = reader.u16()
620
+ for (let i = 0; i < urlCount; i++) urls.push(reader.bytes(reader.u16()))
621
+ return { identifierType, urls }
622
+ }
623
+ case 0x02:
624
+ return { identifierType, regex: reader.bytes(reader.u16()) }
625
+ case 0x03:
626
+ return { identifierType, representationId: reader.bytes(reader.u16()) }
627
+ default:
628
+ return { identifierType, privateIdentifier: reader.bytes(reader.u16()) }
629
+ }
630
+ }
631
+
632
+ // ---------------------------------------------------------------------------
633
+ // 10.3.9 — MP table; 10.3.2 PA message; 10.3.4 MPT message
634
+ // ---------------------------------------------------------------------------
635
+
636
+ /** table_id range of the MP table (Table 102): subset 0..14, then complete. */
637
+ export const MP_TABLE_ID_SUBSET_0 = 0x11
638
+ export const MP_TABLE_ID_COMPLETE = 0x20
639
+ export const MPI_TABLE_ID_FIRST = 0x01
640
+ export const MPI_TABLE_ID_LAST = 0x10
641
+
642
+ export interface MpAsset {
643
+ identifier: MmtIdentifierMapping
644
+ /** asset_type, a 4CC (for example 'hvc1', 'mp4a'). */
645
+ assetType: string
646
+ assetModificationFlag: boolean
647
+ defaultAssetFlag: boolean
648
+ /** Present exactly when asset_clock_relation_flag is 1. */
649
+ assetClockRelationId?: number
650
+ /** Present exactly when asset_timescale_flag is 1. */
651
+ assetTimescale?: number
652
+ locations: MmtGeneralLocation[]
653
+ descriptors: Uint8Array
654
+ }
655
+
656
+ export interface MpTable {
657
+ tableId: number
658
+ version: number
659
+ /** MP_table_mode (Table 30); b11 is reserved and reported as read. */
660
+ mpTableMode: number
661
+ /** Present exactly for the complete table (0x20) and subset 0 (0x11). */
662
+ packageId?: Uint8Array
663
+ /** Present exactly for the complete table (0x20) and subset 0 (0x11). */
664
+ descriptors?: Uint8Array
665
+ assets: MpAsset[]
666
+ }
667
+
668
+ function readMpTable(reader: WireReader): MpTable {
669
+ const tableId = reader.u8()
670
+ if (tableId < MP_TABLE_ID_SUBSET_0 || tableId > MP_TABLE_ID_COMPLETE) fail('not an MP table')
671
+ const version = reader.u8()
672
+ const length = reader.u16()
673
+ if (length === 0) fail('zero MP table length')
674
+ const table = new WireReader(reader.bytes(length))
675
+
676
+ const mpTableMode = table.u8() & 0b11
677
+ const result: MpTable = { tableId, version, mpTableMode, assets: [] }
678
+ if (tableId === MP_TABLE_ID_COMPLETE || tableId === MP_TABLE_ID_SUBSET_0) {
679
+ result.packageId = table.bytes(table.u8())
680
+ result.descriptors = table.bytes(table.u16())
681
+ }
682
+ const numberOfAssets = table.u8()
683
+ for (let i = 0; i < numberOfAssets; i++) {
684
+ const identifier = readIdentifierMapping(table)
685
+ const assetType = String.fromCharCode(...table.bytes(4))
686
+ const flags = table.u8()
687
+ const asset: MpAsset = {
688
+ identifier,
689
+ assetType,
690
+ assetModificationFlag: ((flags >> 2) & 1) === 1,
691
+ defaultAssetFlag: ((flags >> 1) & 1) === 1,
692
+ locations: [],
693
+ descriptors: new Uint8Array(0),
694
+ }
695
+ if ((flags & 1) === 1) { // asset_clock_relation_flag
696
+ asset.assetClockRelationId = table.u8()
697
+ if ((table.u8() & 1) === 1) asset.assetTimescale = table.u32() // asset_timescale_flag
698
+ }
699
+ const locationCount = table.u8()
700
+ for (let j = 0; j < locationCount; j++) asset.locations.push(readAssetLocation(table))
701
+ asset.descriptors = table.bytes(table.u16())
702
+ result.assets.push(asset)
703
+ }
704
+ table.expectEnd()
705
+ return result
706
+ }
707
+
708
+ /** Parse a complete MP table (Table 29), starting at its table_id. */
709
+ export function parseMpTable(bytes: Uint8Array): MpTable | null {
710
+ return total(() => {
711
+ const reader = new WireReader(bytes)
712
+ const table = readMpTable(reader)
713
+ reader.expectEnd()
714
+ return table
715
+ })
716
+ }
717
+
718
+ /**
719
+ * The packet_ids an Asset is delivered on: every location of type 0x00
720
+ * (same MMTP packet flow), 0x01 (UDP/IPv4) or 0x02 (UDP/IPv6), in
721
+ * signalled order.
722
+ */
723
+ export function mpAssetPacketIds(asset: MpAsset): number[] {
724
+ const packetIds: number[] = []
725
+ for (const location of asset.locations) {
726
+ if (location.locationType === 0x00 || location.locationType === 0x01 || location.locationType === 0x02) {
727
+ packetIds.push(location.packetId)
728
+ }
729
+ }
730
+ return packetIds
731
+ }
732
+
733
+ export interface MmtSignallingTable {
734
+ tableId: number
735
+ version: number
736
+ /** The complete table, starting at its table_id. */
737
+ bytes: Uint8Array
738
+ }
739
+
740
+ export interface PaMessage {
741
+ version: number
742
+ /** Every table of the message payload, in order. */
743
+ tables: MmtSignallingTable[]
744
+ /** The MP tables among `tables`, decoded. */
745
+ mpTables: MpTable[]
746
+ }
747
+
748
+ export interface MptMessage {
749
+ /** 0x0011-0x001F carry subset 0-14, 0x0020 the complete MP table. */
750
+ messageId: number
751
+ version: number
752
+ table: MpTable
753
+ }
754
+
755
+ /**
756
+ * Parse a complete PA message (Table 21), starting at its message_id.
757
+ *
758
+ * Every table in the payload is delimited by its own 16-bit `length` and
759
+ * must match the extension's table_id and table_version. For non-MPI tables
760
+ * table_length is "a copy of the length field" (10.3.2.3) and must match it
761
+ * too; for an MPI table it is derived differently, so it is not compared.
762
+ */
763
+ export function parsePaMessageBody(message: Uint8Array): PaMessage | null {
764
+ return total(() => {
765
+ const { reader, messageId, version } = readMessageHeader(message)
766
+ if (messageId !== PA_MESSAGE_ID) return null
767
+ const numberOfTables = reader.u8()
768
+ const entries: { tableId: number; tableVersion: number; tableLength: number }[] = []
769
+ for (let i = 0; i < numberOfTables; i++) {
770
+ entries.push({ tableId: reader.u8(), tableVersion: reader.u8(), tableLength: reader.u16() })
771
+ }
772
+ const tables: MmtSignallingTable[] = []
773
+ const mpTables: MpTable[] = []
774
+ for (const entry of entries) {
775
+ const start = reader.offset
776
+ const tableId = reader.u8()
777
+ const tableVersion = reader.u8()
778
+ const length = reader.u16()
779
+ if (length === 0) fail('zero table length')
780
+ reader.bytes(length) // bounds check
781
+ const isMpi = tableId >= MPI_TABLE_ID_FIRST && tableId <= MPI_TABLE_ID_LAST
782
+ if (tableId !== entry.tableId || tableVersion !== entry.tableVersion ||
783
+ (!isMpi && length !== entry.tableLength)) {
784
+ fail('PA extension does not describe its table')
785
+ }
786
+ const bytes = message.subarray(start, reader.offset)
787
+ tables.push({ tableId, version: tableVersion, bytes })
788
+ if (tableId >= MP_TABLE_ID_SUBSET_0 && tableId <= MP_TABLE_ID_COMPLETE) {
789
+ const table = parseMpTable(bytes)
790
+ if (!table) fail('malformed MP table in PA message')
791
+ mpTables.push(table)
792
+ }
793
+ }
794
+ reader.expectEnd()
795
+ return { version, tables, mpTables }
796
+ })
797
+ }
798
+
799
+ /** Parse a complete MPT message (Table 23), starting at its message_id. */
800
+ export function parseMptMessageBody(message: Uint8Array): MptMessage | null {
801
+ return total(() => {
802
+ const { reader, messageId, version } = readMessageHeader(message)
803
+ if (messageId < MPT_MESSAGE_ID_FIRST || messageId > MPT_MESSAGE_ID_COMPLETE) return null
804
+ const table = readMpTable(reader)
805
+ reader.expectEnd()
806
+ return { messageId, version, table }
807
+ })
808
+ }
809
+
810
+ // ---------------------------------------------------------------------------
811
+ // Packet-level dispatch (the packet_id 0 signalling handler)
812
+ // ---------------------------------------------------------------------------
813
+
814
+ export type MmtSignallingMessage =
815
+ | { kind: 'al-fec'; message: AlFecMessageBody }
816
+ | { kind: 'pa'; message: PaMessage }
817
+ | { kind: 'mpt'; message: MptMessage }
818
+ /** A message this module does not decode, delimited and length-checked. */
819
+ | { kind: 'other'; messageId: number; version: number; bytes: Uint8Array }
820
+
821
+ export interface MmtSignallingPacket {
822
+ payloadForm: SignallingPayloadForm
823
+ messages: MmtSignallingMessage[]
824
+ }
825
+
826
+ function decodeSignallingMessage(bytes: Uint8Array): MmtSignallingMessage {
827
+ const { messageId, version } = readMessageHeader(bytes)
828
+ if (messageId === ALFEC_MESSAGE_ID) {
829
+ const message = parseAlFecMessageBody(bytes)
830
+ if (!message) fail('malformed AL-FEC message')
831
+ return { kind: 'al-fec', message }
832
+ }
833
+ if (messageId === PA_MESSAGE_ID) {
834
+ const message = parsePaMessageBody(bytes)
835
+ if (!message) fail('malformed PA message')
836
+ return { kind: 'pa', message }
837
+ }
838
+ if (messageId >= MPT_MESSAGE_ID_FIRST && messageId <= MPT_MESSAGE_ID_COMPLETE) {
839
+ const message = parseMptMessageBody(bytes)
840
+ if (!message) fail('malformed MPT message')
841
+ return { kind: 'mpt', message }
842
+ }
843
+ return { kind: 'other', messageId, version, bytes }
844
+ }
845
+
846
+ /**
847
+ * Parse every signalling message of a full MMTP signalling packet (the
848
+ * packet_id 0 flow). Returns null when the packet or any message in it is
849
+ * malformed: one unparseable message leaves the receiver unable to say what
850
+ * the packet signalled.
851
+ *
852
+ * The legacy bare form carries exactly one AL-FEC message; any other message
853
+ * is only accepted inside a 9.3.4.2 payload.
854
+ */
855
+ export function parseSignallingPacket(mmtpPacket: Uint8Array): MmtSignallingPacket | null {
856
+ const payload = parseSignallingPayload(mmtpPacket)
857
+ if (!payload) return null
858
+ return total(() => ({
859
+ payloadForm: payload.form,
860
+ messages: payload.messages.map(decodeSignallingMessage),
861
+ }))
862
+ }