/** * Receiver-side parsers for MMTP signalling message mode. * * - The MMTP payload header for signalling message mode * (ISO/IEC 23008-1:2023 9.3.4.2, Figure 15). * - The general signalling message format (10.2, Table 20). * - The AL-FEC message (Annex C Table C.3 as replaced by * ISO/IEC 23008-1:2023/Amd 1:2025; semantics C.6.3). * - The PA message (10.3.2, Table 21) and MPT message (10.3.4, Table 23) * with the MP table (10.3.9, Table 29), Identifier_mapping (10.6.3, * Table 98), asset_id (10.6.2, Table 97) and MMT_general_location_info * (10.6.1, Table 95). * * Every parser here is total: malformed, truncated, ambiguous or unsupported * input yields null, never an exception. Nothing is inferred: a field the * receiver cannot place exactly makes the whole message unparseable, because * every later field of a sequential syntax would be misread after it. * * Reserved bits are ignored on receipt. The senders write them, and a * receiver that refuses a reserved value cannot accept a conformant revision * of the syntax that assigns it. */ import { ALFEC_MESSAGE_ID, FEC_CODE_RAPTORQ, PACKET_TYPE_SIGNALING, parseMMTPHeader, type MMTPHeader, } from './mmtp.js' // --------------------------------------------------------------------------- // Wire reader // --------------------------------------------------------------------------- /** Internal: raised by WireReader, caught at every exported parser. */ class MalformedSignalling extends Error {} class WireReader { readonly #bytes: Uint8Array readonly #end: number #offset: number constructor(bytes: Uint8Array, start = 0, end = bytes.length) { this.#bytes = bytes this.#offset = start this.#end = end } get offset(): number { return this.#offset } get remaining(): number { return this.#end - this.#offset } #take(length: number): number { if (length < 0 || length > this.remaining) throw new MalformedSignalling('truncated') const at = this.#offset this.#offset += length return at } u8(): number { return this.#bytes[this.#take(1)]! } u16(): number { const at = this.#take(2) return (this.#bytes[at]! << 8) | this.#bytes[at + 1]! } u24(): number { const at = this.#take(3) return (this.#bytes[at]! << 16) | (this.#bytes[at + 1]! << 8) | this.#bytes[at + 2]! } u32(): number { const at = this.#take(4) return ((this.#bytes[at]! << 24) | (this.#bytes[at + 1]! << 16) | (this.#bytes[at + 2]! << 8) | this.#bytes[at + 3]!) >>> 0 } /** A copy, so a parsed message never aliases a reused packet buffer. */ bytes(length: number): Uint8Array { const at = this.#take(length) return this.#bytes.slice(at, at + length) } /** Throw unless every byte up to the end bound was consumed. */ expectEnd(): void { if (this.remaining !== 0) throw new MalformedSignalling('trailing bytes') } } function fail(reason: string): never { throw new MalformedSignalling(reason) } /** Run a parser, turning only our own malformed-input signal into null. */ function total(parse: () => T | null): T | null { try { return parse() } catch (error) { if (error instanceof MalformedSignalling) return null throw error } } // --------------------------------------------------------------------------- // 9.3.4.2 — MMTP payload header for signalling message mode // --------------------------------------------------------------------------- /** * How the signalling payload of an MMTP packet was framed: always * `iso-9.3.4.2`, the 2-byte `f_i(2) RES(4) H(1) A(1) | frag_counter(8)` * header of ISO/IEC 23008-1:2023 9.3.4.2 ahead of the message(s). * * The pre-remediation Blockcast form, an AL-FEC message directly after the * MMTP header, is not accepted (BLO-35597, Phase L). Its first two bytes, * `0x02 0x03`, read as a 9.3.4.2 header claiming three further fragments * of a complete message, which the header parser refuses: such a packet * parses to null, never to a guess. */ export type SignallingPayloadForm = 'iso-9.3.4.2' export interface SignallingPayload { form: SignallingPayloadForm /** Each complete signalling message, starting at its message_id. */ messages: Uint8Array[] } /** f_i (Table 19): payload contains one or more complete signalling messages. */ export const SIGNALLING_FRAGMENTATION_COMPLETE = 0b00 /** * Split the payload of an MMTP signalling packet into its complete * signalling messages. * * Returns null when the packet is not a well-formed, unprotected (FEC_type 0) * signalling packet, or when it carries a fragment (f_i != 00): reassembly * across packets is not implemented, and a fragment is not a message. */ export function parseSignallingPayload(mmtpPacket: Uint8Array): SignallingPayload | null { let header: MMTPHeader try { header = parseMMTPHeader(mmtpPacket) } catch { return null } if (header.packetType !== PACKET_TYPE_SIGNALING || header.fecType !== 0) return null const payload = mmtpPacket.subarray(header.headerLength) return total(() => splitSignallingPayload(payload)) } function splitSignallingPayload(payload: Uint8Array): SignallingPayload { if (payload.length < 2) fail('short signalling payload') const reader = new WireReader(payload) const flags = reader.u8() const fragmentation = flags >> 6 const lengthIs32Bits = ((flags >> 1) & 1) === 1 // H const aggregated = (flags & 1) === 1 // A const fragmentCounter = reader.u8() if (fragmentation !== SIGNALLING_FRAGMENTATION_COMPLETE) fail('fragmented signalling message') // frag_counter counts the fragments that follow this one; a complete // message has none, and an aggregated payload "shall" carry 0. if (fragmentCounter !== 0) fail('frag_counter on a complete message') if (!aggregated) { // A=0: exactly one message and no MSG_length field (H is meaningless). if (reader.remaining === 0) fail('empty signalling payload') return { form: 'iso-9.3.4.2', messages: [payload.subarray(reader.offset)] } } const messages: Uint8Array[] = [] while (reader.remaining > 0) { const length = lengthIs32Bits ? reader.u32() : reader.u16() if (length === 0) fail('zero MSG_length') const start = reader.offset reader.bytes(length) // bounds check messages.push(payload.subarray(start, start + length)) } if (messages.length === 0) fail('aggregated payload with no message') return { form: 'iso-9.3.4.2', messages } } // --------------------------------------------------------------------------- // 10.2 — general signalling message format // --------------------------------------------------------------------------- export const PA_MESSAGE_ID = 0x0000 export const MPI_MESSAGE_ID_FIRST = 0x0001 export const MPI_MESSAGE_ID_LAST = 0x0010 export const MPT_MESSAGE_ID_FIRST = 0x0011 /** MPT message carrying the complete MP table (Table 101). */ export const MPT_MESSAGE_ID_COMPLETE = 0x0020 /** * Whether a message's `length` field is 32 bits (Table 20: PA and MPI * messages; Table 101: the 0x7000-0x7FFF range "32-bit length message"). */ export function signallingMessageHas32BitLength(messageId: number): boolean { return messageId === PA_MESSAGE_ID || (messageId >= MPI_MESSAGE_ID_FIRST && messageId <= MPI_MESSAGE_ID_LAST) || (messageId >= 0x7000 && messageId <= 0x7fff) } /** * Read the general header (Table 20) of a complete message and check that * `length` covers exactly the rest of the message. */ function readMessageHeader(message: Uint8Array): { reader: WireReader; messageId: number; version: number } { const reader = new WireReader(message) const messageId = reader.u16() const version = reader.u8() const length = signallingMessageHas32BitLength(messageId) ? reader.u32() : reader.u16() // "The value 0 is not valid for this field" (C.6.3, 10.3.2.3, 10.3.4.3). if (length === 0 || length !== reader.remaining) fail('message length mismatch') return { reader, messageId, version } } // --------------------------------------------------------------------------- // Annex C Table C.3 (Amd 1:2025) — AL-FEC message // --------------------------------------------------------------------------- /** fec_coding_structure values (Table C.8 as replaced by Amd 1:2025). */ export const FEC_CODING_STRUCTURE_NONE = 0b0000 export const FEC_CODING_STRUCTURE_ONE_STAGE = 0b0001 export const FEC_CODING_STRUCTURE_TWO_STAGE = 0b0010 export const FEC_CODING_STRUCTURE_LAYER_AWARE = 0b0011 export const FEC_CODING_STRUCTURE_ADAPTIVE = 0b0100 /** ssbg_mode values (Table C.5). b11 is reserved. */ export const SSBG_MODE_0 = 0b00 export const SSBG_MODE_1 = 0b01 export const SSBG_MODE_2 = 0b10 export interface AlFecPrivateField { privateFlag: 0 | 1 data: Uint8Array } export interface AlFecRepairFlow { /** * repair_flow_id (16 bits under Amd 1:2025 for one-stage, two-stage and * LA-FEC). Blockcast senders set it to the repair flow's MMTP packet_id. */ repairFlowId: number /** * fec_code_id_for_repair_flow. Under LA-FEC (0011) one value is signalled * per FEC flow and is repeated here on every layer. */ fecCodeId: number /** * The private_flag/private_field_length/private_field triple, present * exactly when the message's private_fec_flag is 1. Shared by every layer * under LA-FEC, like fecCodeId. */ privateField?: AlFecPrivateField maximumK: number maximumP: number protectionWindowTime: number protectionWindowSize: number } export interface AlFecFlow { fecFlowId: number sourceFlowId: number /** Every asset packet_id of the FEC source flow, in signalled order. */ packetIds: number[] fecCodingStructure: number ssbgMode: number ffsrptsFlag: 0 | 1 /** * The low bit of the coding byte. Amd 1:2025 Table C.3 names it * fec_payload_id_mode (Table C.7: 0 = mode 0, Source FEC Payload ID added * to each packet; 1 = mode 1, C.7, packets unmodified). The unamended 2023 * Table C.3 lists the same bit as reserved "1". The parse never depends on * it — no later field is conditional on it — so both readings are * accepted and the value is reported, not interpreted (BLO-35597 N1). */ fecPayloadIdMode: 0 | 1 /** length_of_repair_symbol, in bytes. */ repairSymbolSize: number /** Present exactly when ssbg_mode is b10. */ ssbgMode2?: { numRepairSymbolPerPacket: number numSymbolElementPerSourceSymbol: number } /** num_of_sub_block_per_source_block, two-stage (0010) only. */ numSubBlockPerSourceBlock?: number /** One entry for one-stage; N3 parity flows; N4 LA-FEC layers; none for 0000. */ repairFlows: AlFecRepairFlow[] } export interface AlFecMessageBody { /** The message's version counter (C.6.3). Any value is accepted. */ version: number fecFlag: 0 | 1 privateFecFlag: 0 | 1 /** Empty exactly when fec_flag is 0 (no FEC source flow). */ flows: AlFecFlow[] } export interface AlFecMessage extends AlFecMessageBody { payloadForm: SignallingPayloadForm } function readPrivateField(reader: WireReader): AlFecPrivateField { const head = reader.u8() return { privateFlag: (head >> 7) as 0 | 1, data: reader.bytes(head & 0x7f) } } function readRepairFlowTail( reader: WireReader, repairFlowId: number, fecCodeId: number, privateField: AlFecPrivateField | undefined, ): AlFecRepairFlow { const maximumK = reader.u24() const maximumP = reader.u24() const protectionWindowTime = reader.u32() const protectionWindowSize = reader.u32() return { repairFlowId, fecCodeId, ...(privateField ? { privateField } : {}), maximumK, maximumP, protectionWindowTime, protectionWindowSize, } } function readRepairFlow(reader: WireReader, privateFecFlag: boolean): AlFecRepairFlow { const repairFlowId = reader.u16() const fecCodeId = reader.u8() const privateField = privateFecFlag ? readPrivateField(reader) : undefined return readRepairFlowTail(reader, repairFlowId, fecCodeId, privateField) } function readFecFlow(reader: WireReader, privateFecFlag: boolean): AlFecFlow { const fecFlowId = reader.u8() const sourceFlowId = reader.u8() const numberOfAssets = reader.u8() if (numberOfAssets === 0) fail('FEC flow with no asset') const packetIds: number[] = [] for (let i = 0; i < numberOfAssets; i++) packetIds.push(reader.u16()) const coding = reader.u8() const fecCodingStructure = coding >> 4 const ssbgMode = (coding >> 2) & 0b11 const ffsrptsFlag = ((coding >> 1) & 1) as 0 | 1 const fecPayloadIdMode = (coding & 1) as 0 | 1 if (ssbgMode === 0b11) fail('reserved ssbg_mode') const repairSymbolSize = reader.u16() const flow: AlFecFlow = { fecFlowId, sourceFlowId, packetIds, fecCodingStructure, ssbgMode, ffsrptsFlag, fecPayloadIdMode, repairSymbolSize, repairFlows: [], } if (ssbgMode === SSBG_MODE_2) { flow.ssbgMode2 = { numRepairSymbolPerPacket: reader.u16(), numSymbolElementPerSourceSymbol: reader.u16(), } } switch (fecCodingStructure) { case FEC_CODING_STRUCTURE_NONE: break case FEC_CODING_STRUCTURE_ONE_STAGE: flow.repairFlows.push(readRepairFlow(reader, privateFecFlag)) break case FEC_CODING_STRUCTURE_TWO_STAGE: { flow.numSubBlockPerSourceBlock = reader.u8() const numberOfParityFlows = reader.u8() for (let k = 0; k < numberOfParityFlows; k++) { flow.repairFlows.push(readRepairFlow(reader, privateFecFlag)) } break } case FEC_CODING_STRUCTURE_LAYER_AWARE: { const numberOfLayers = reader.u8() const fecCodeId = reader.u8() const privateField = privateFecFlag ? readPrivateField(reader) : undefined for (let l = 0; l < numberOfLayers; l++) { flow.repairFlows.push(readRepairFlowTail(reader, reader.u16(), fecCodeId, privateField)) } break } default: // 0100 (adaptive FEC, Amd 1:2025): the published table leaves the // width of number_of__class unstated, so its flows cannot be placed // exactly. 0101-1111 are reserved. Either way every later flow would // be misread, so the whole message is refused. fail(`unsupported fec_coding_structure ${fecCodingStructure}`) } return flow } /** * Parse a complete AL-FEC message (Table C.3, Amd 1:2025), starting at its * message_id. Every FEC flow, every asset packet_id and every * coding-structure-conditional field is returned. */ export function parseAlFecMessageBody(message: Uint8Array): AlFecMessageBody | null { return total(() => { const { reader, messageId, version } = readMessageHeader(message) if (messageId !== ALFEC_MESSAGE_ID) return null const flags = reader.u8() const fecFlag = (flags >> 7) as 0 | 1 const privateFecFlag = ((flags >> 6) & 1) as 0 | 1 if (fecFlag === 0) { // "If every source flows are delivered without AL-FEC protection, all // the remaining message is not to be delivered" (C.6.3). reader.expectEnd() return { version, fecFlag, privateFecFlag, flows: [] } } const descriptorLength = reader.u16() if (descriptorLength === 0 || descriptorLength !== reader.remaining) { fail('length_of_fec_flow_descriptor mismatch') } const numberOfFecFlows = reader.u8() if (numberOfFecFlows === 0) fail('fec_flag=1 with no FEC flow') const flows: AlFecFlow[] = [] for (let i = 0; i < numberOfFecFlows; i++) flows.push(readFecFlow(reader, privateFecFlag === 1)) reader.expectEnd() return { version, fecFlag, privateFecFlag, flows } }) } /** * Parse the AL-FEC message carried by a full MMTP signalling packet (a * 9.3.4.2 payload, see SignallingPayloadForm). Returns null when the packet * carries no AL-FEC message, more than one, or a malformed one. */ export function parseAlFecMessage(mmtpPacket: Uint8Array): AlFecMessage | null { const packet = parseSignallingPacket(mmtpPacket) if (!packet) return null const alFec = packet.messages.filter( (message): message is Extract => message.kind === 'al-fec', ) if (alFec.length !== 1) return null return { ...alFec[0]!.message, payloadForm: packet.payloadForm } } /** * Decoder geometry of one FEC flow under the Blockcast one-stage RaptorQ * profile: private_field = RaptorQ FEC OTI (RFC 6330 3.3.2-3.3.3, 12 bytes) * followed by the interleave depth (1 byte). */ export interface AlFecRaptorQFlowConfig { sourcePacketIds: number[] repairFlowId: number /** Always `SSBG_MODE_1` (`iso-ssbg1-v1`). */ ssbgMode: number /** The coding byte's low bit as signalled (see AlFecFlow.fecPayloadIdMode). */ fecPayloadIdMode: 0 | 1 repairSymbolSize: number maximumK: number maximumP: number interleaveDepth: number oti: Uint8Array protectionWindowTime: number protectionWindowSize: number } const RAPTORQ_PRIVATE_FIELD_LENGTH = 13 const RAPTORQ_MAX_SOURCE_SYMBOLS = 56_403 const REPAIR_SYMBOL_ID_MAX = 0x00ff_ffff function canonicalRaptorQOtiK(oti: Uint8Array, repairSymbolSize: number): number | null { if (oti.length !== 12 || oti[5] !== 0) return null const transferLength = (oti[0]! * 0x1_0000_0000) + (((oti[1]! << 24) | (oti[2]! << 16) | (oti[3]! << 8) | oti[4]!) >>> 0) const symbolSize = (oti[6]! << 8) | oti[7]! const sourceBlocks = oti[8]! const subBlocks = (oti[9]! << 8) | oti[10]! const alignment = oti[11]! if (!Number.isSafeInteger(transferLength) || transferLength <= 0 || symbolSize !== repairSymbolSize || symbolSize === 0 || symbolSize % 8 !== 0 || sourceBlocks !== 1 || subBlocks !== 1 || alignment !== 8 || transferLength % symbolSize !== 0) { return null } const k = transferLength / symbolSize return Number.isInteger(k) && k >= 1 && k <= RAPTORQ_MAX_SOURCE_SYMBOLS ? k : null } /** * Validate one parsed FEC flow against the Blockcast one-stage RaptorQ * profile and return its decoder geometry, or null when the flow is outside * the profile. Nothing is inferred: K comes from the OTI and must equal * maximum_k_for_repair_flow. * * The profile is `iso-ssbg1-v1`, whose AL-FEC coding byte is 0x14 * (draft-ramadan-moq-fec §7.3; ISO/IEC 23008-1 Table C.3 as replaced by * Amd 1:2025): one-stage, ssbg_mode1, ffsrpts_flag 0, fec_payload_id_mode 0. * 0x15 is accepted too: its low bit is fec_payload_id_mode 1 under Amd * 1:2025 but reserved "1" in the unamended 2023 Table C.3, so a 2023 sender * of the same profile emits it. No later field depends on the bit, and each * source packet's FEC_type says whether it carries a Source FEC Payload ID, * so the bit is reported, not interpreted (BLO-35597 N1). Any other coding * byte is refused here, though `parseAlFecMessageBody` still reports it: * ssbg_mode0 (0x10/0x11, the retired `k-padded-v1` wire) carries no length * field; ffsrpts_flag=1 changes the source/repair packet trailer layout. */ export function alFecRaptorQFlowConfig(flow: AlFecFlow): AlFecRaptorQFlowConfig | null { if (flow.fecCodingStructure !== FEC_CODING_STRUCTURE_ONE_STAGE || flow.repairFlows.length !== 1) return null if (flow.ssbgMode !== SSBG_MODE_1) return null if (flow.ffsrptsFlag !== 0) return null const repair = flow.repairFlows[0]! if (repair.fecCodeId !== FEC_CODE_RAPTORQ) return null const privateField = repair.privateField if (!privateField || privateField.privateFlag !== 1 || privateField.data.length !== RAPTORQ_PRIVATE_FIELD_LENGTH) { return null } const oti = privateField.data.slice(0, 12) const interleaveDepth = privateField.data[12]! const otiK = canonicalRaptorQOtiK(oti, flow.repairSymbolSize) if (otiK === null || interleaveDepth === 0) return null if (repair.maximumK !== otiK || repair.maximumP === 0 || repair.maximumK + repair.maximumP - 1 > REPAIR_SYMBOL_ID_MAX) { return null } return { sourcePacketIds: [...flow.packetIds], repairFlowId: repair.repairFlowId, ssbgMode: flow.ssbgMode, fecPayloadIdMode: flow.fecPayloadIdMode, repairSymbolSize: flow.repairSymbolSize, maximumK: repair.maximumK, maximumP: repair.maximumP, interleaveDepth, oti, protectionWindowTime: repair.protectionWindowTime, protectionWindowSize: repair.protectionWindowSize, } } // --------------------------------------------------------------------------- // 10.6 — location, asset_id and identifier mapping // --------------------------------------------------------------------------- /** location_type values (Table 96) a receiver may meet in an asset location. */ export type MmtGeneralLocation = | { locationType: 0x00; packetId: number } | { locationType: 0x01; srcAddress: Uint8Array; dstAddress: Uint8Array; dstPort: number; packetId: number } | { locationType: 0x02; srcAddress: Uint8Array; dstAddress: Uint8Array; dstPort: number; packetId: number } | { locationType: 0x03; networkId: number; transportStreamId: number; mpeg2Pid: number } | { locationType: 0x04; srcAddress: Uint8Array; dstAddress: Uint8Array; dstPort: number; mpeg2Pid: number } | { locationType: 0x05; url: Uint8Array } | { locationType: 0x06; data: Uint8Array } /** * MMT_general_location_info() for an Asset location. "Only the value of * location_type between 0x00 and 0x06 shall be used for an Asset location" * (10.3.9.3), so any other value is refused rather than skipped: the * remaining types have their own lengths, and a reserved one has none. */ function readAssetLocation(reader: WireReader): MmtGeneralLocation { const locationType = reader.u8() switch (locationType) { case 0x00: return { locationType, packetId: reader.u16() } case 0x01: case 0x02: { const addressLength = locationType === 0x01 ? 4 : 16 const srcAddress = reader.bytes(addressLength) const dstAddress = reader.bytes(addressLength) const dstPort = reader.u16() return { locationType, srcAddress, dstAddress, dstPort, packetId: reader.u16() } } case 0x03: { const networkId = reader.u16() const transportStreamId = reader.u16() return { locationType, networkId, transportStreamId, mpeg2Pid: reader.u16() & 0x1fff } } case 0x04: { const srcAddress = reader.bytes(16) const dstAddress = reader.bytes(16) const dstPort = reader.u16() return { locationType, srcAddress, dstAddress, dstPort, mpeg2Pid: reader.u16() & 0x1fff } } case 0x05: return { locationType, url: reader.bytes(reader.u8()) } case 0x06: return { locationType, data: reader.bytes(reader.u16()) } default: fail(`location_type ${locationType} is not an Asset location`) } } /** Identifier_mapping() (Table 98). */ export type MmtIdentifierMapping = | { identifierType: 0x00; assetIdScheme: number; assetId: Uint8Array } | { identifierType: 0x01; urls: Uint8Array[] } | { identifierType: 0x02; regex: Uint8Array } | { identifierType: 0x03; representationId: Uint8Array } | { identifierType: number; privateIdentifier: Uint8Array } function readIdentifierMapping(reader: WireReader): MmtIdentifierMapping { const identifierType = reader.u8() switch (identifierType) { case 0x00: { const assetIdScheme = reader.u32() return { identifierType, assetIdScheme, assetId: reader.bytes(reader.u32()) } } case 0x01: { const urls: Uint8Array[] = [] const urlCount = reader.u16() for (let i = 0; i < urlCount; i++) urls.push(reader.bytes(reader.u16())) return { identifierType, urls } } case 0x02: return { identifierType, regex: reader.bytes(reader.u16()) } case 0x03: return { identifierType, representationId: reader.bytes(reader.u16()) } default: return { identifierType, privateIdentifier: reader.bytes(reader.u16()) } } } // --------------------------------------------------------------------------- // 10.3.9 — MP table; 10.3.2 PA message; 10.3.4 MPT message // --------------------------------------------------------------------------- /** table_id range of the MP table (Table 102): subset 0..14, then complete. */ export const MP_TABLE_ID_SUBSET_0 = 0x11 export const MP_TABLE_ID_COMPLETE = 0x20 export const MPI_TABLE_ID_FIRST = 0x01 export const MPI_TABLE_ID_LAST = 0x10 export interface MpAsset { identifier: MmtIdentifierMapping /** asset_type, a 4CC (for example 'hvc1', 'mp4a'). */ assetType: string assetModificationFlag: boolean defaultAssetFlag: boolean /** Present exactly when asset_clock_relation_flag is 1. */ assetClockRelationId?: number /** Present exactly when asset_timescale_flag is 1. */ assetTimescale?: number locations: MmtGeneralLocation[] descriptors: Uint8Array } export interface MpTable { tableId: number version: number /** MP_table_mode (Table 30); b11 is reserved and reported as read. */ mpTableMode: number /** Present exactly for the complete table (0x20) and subset 0 (0x11). */ packageId?: Uint8Array /** Present exactly for the complete table (0x20) and subset 0 (0x11). */ descriptors?: Uint8Array assets: MpAsset[] } function readMpTable(reader: WireReader): MpTable { const tableId = reader.u8() if (tableId < MP_TABLE_ID_SUBSET_0 || tableId > MP_TABLE_ID_COMPLETE) fail('not an MP table') const version = reader.u8() const length = reader.u16() if (length === 0) fail('zero MP table length') const table = new WireReader(reader.bytes(length)) const mpTableMode = table.u8() & 0b11 const result: MpTable = { tableId, version, mpTableMode, assets: [] } if (tableId === MP_TABLE_ID_COMPLETE || tableId === MP_TABLE_ID_SUBSET_0) { result.packageId = table.bytes(table.u8()) result.descriptors = table.bytes(table.u16()) } const numberOfAssets = table.u8() for (let i = 0; i < numberOfAssets; i++) { const identifier = readIdentifierMapping(table) const assetType = String.fromCharCode(...table.bytes(4)) const flags = table.u8() const asset: MpAsset = { identifier, assetType, assetModificationFlag: ((flags >> 2) & 1) === 1, defaultAssetFlag: ((flags >> 1) & 1) === 1, locations: [], descriptors: new Uint8Array(0), } if ((flags & 1) === 1) { // asset_clock_relation_flag asset.assetClockRelationId = table.u8() if ((table.u8() & 1) === 1) asset.assetTimescale = table.u32() // asset_timescale_flag } const locationCount = table.u8() for (let j = 0; j < locationCount; j++) asset.locations.push(readAssetLocation(table)) asset.descriptors = table.bytes(table.u16()) result.assets.push(asset) } table.expectEnd() return result } /** Parse a complete MP table (Table 29), starting at its table_id. */ export function parseMpTable(bytes: Uint8Array): MpTable | null { return total(() => { const reader = new WireReader(bytes) const table = readMpTable(reader) reader.expectEnd() return table }) } /** * The packet_ids an Asset is delivered on: every location of type 0x00 * (same MMTP packet flow), 0x01 (UDP/IPv4) or 0x02 (UDP/IPv6), in * signalled order. */ export function mpAssetPacketIds(asset: MpAsset): number[] { const packetIds: number[] = [] for (const location of asset.locations) { if (location.locationType === 0x00 || location.locationType === 0x01 || location.locationType === 0x02) { packetIds.push(location.packetId) } } return packetIds } export interface MmtSignallingTable { tableId: number version: number /** The complete table, starting at its table_id. */ bytes: Uint8Array } export interface PaMessage { version: number /** Every table of the message payload, in order. */ tables: MmtSignallingTable[] /** The MP tables among `tables`, decoded. */ mpTables: MpTable[] } export interface MptMessage { /** 0x0011-0x001F carry subset 0-14, 0x0020 the complete MP table. */ messageId: number version: number table: MpTable } /** * Parse a complete PA message (Table 21), starting at its message_id. * * Every table in the payload is delimited by its own 16-bit `length` and * must match the extension's table_id and table_version. For non-MPI tables * table_length is "a copy of the length field" (10.3.2.3) and must match it * too; for an MPI table it is derived differently, so it is not compared. */ export function parsePaMessageBody(message: Uint8Array): PaMessage | null { return total(() => { const { reader, messageId, version } = readMessageHeader(message) if (messageId !== PA_MESSAGE_ID) return null const numberOfTables = reader.u8() const entries: { tableId: number; tableVersion: number; tableLength: number }[] = [] for (let i = 0; i < numberOfTables; i++) { entries.push({ tableId: reader.u8(), tableVersion: reader.u8(), tableLength: reader.u16() }) } const tables: MmtSignallingTable[] = [] const mpTables: MpTable[] = [] for (const entry of entries) { const start = reader.offset const tableId = reader.u8() const tableVersion = reader.u8() const length = reader.u16() if (length === 0) fail('zero table length') reader.bytes(length) // bounds check const isMpi = tableId >= MPI_TABLE_ID_FIRST && tableId <= MPI_TABLE_ID_LAST if (tableId !== entry.tableId || tableVersion !== entry.tableVersion || (!isMpi && length !== entry.tableLength)) { fail('PA extension does not describe its table') } const bytes = message.subarray(start, reader.offset) tables.push({ tableId, version: tableVersion, bytes }) if (tableId >= MP_TABLE_ID_SUBSET_0 && tableId <= MP_TABLE_ID_COMPLETE) { const table = parseMpTable(bytes) if (!table) fail('malformed MP table in PA message') mpTables.push(table) } } reader.expectEnd() return { version, tables, mpTables } }) } /** Parse a complete MPT message (Table 23), starting at its message_id. */ export function parseMptMessageBody(message: Uint8Array): MptMessage | null { return total(() => { const { reader, messageId, version } = readMessageHeader(message) if (messageId < MPT_MESSAGE_ID_FIRST || messageId > MPT_MESSAGE_ID_COMPLETE) return null const table = readMpTable(reader) reader.expectEnd() return { messageId, version, table } }) } // --------------------------------------------------------------------------- // Packet-level dispatch (the packet_id 0 signalling handler) // --------------------------------------------------------------------------- export type MmtSignallingMessage = | { kind: 'al-fec'; message: AlFecMessageBody } | { kind: 'pa'; message: PaMessage } | { kind: 'mpt'; message: MptMessage } /** A message this module does not decode, delimited and length-checked. */ | { kind: 'other'; messageId: number; version: number; bytes: Uint8Array } export interface MmtSignallingPacket { payloadForm: SignallingPayloadForm messages: MmtSignallingMessage[] } function decodeSignallingMessage(bytes: Uint8Array): MmtSignallingMessage { const { messageId, version } = readMessageHeader(bytes) if (messageId === ALFEC_MESSAGE_ID) { const message = parseAlFecMessageBody(bytes) if (!message) fail('malformed AL-FEC message') return { kind: 'al-fec', message } } if (messageId === PA_MESSAGE_ID) { const message = parsePaMessageBody(bytes) if (!message) fail('malformed PA message') return { kind: 'pa', message } } if (messageId >= MPT_MESSAGE_ID_FIRST && messageId <= MPT_MESSAGE_ID_COMPLETE) { const message = parseMptMessageBody(bytes) if (!message) fail('malformed MPT message') return { kind: 'mpt', message } } return { kind: 'other', messageId, version, bytes } } /** * Parse every signalling message of a full MMTP signalling packet (the * packet_id 0 flow). Returns null when the packet or any message in it is * malformed: one unparseable message leaves the receiver unable to say what * the packet signalled. Only the 9.3.4.2 payload form is accepted. */ export function parseSignallingPacket(mmtpPacket: Uint8Array): MmtSignallingPacket | null { const payload = parseSignallingPayload(mmtpPacket) if (!payload) return null return total(() => ({ payloadForm: payload.form, messages: payload.messages.map(decodeSignallingMessage), })) }