const net = require('net'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { WTVShared } = require('./WTVShared.js'); class WTVPNM { minisrv_config = null; service_name = null; service_config = null; server = null; wtvshared = null; sessions = new Map(); constructor(minisrv_config, service_name = 'pnm') { this.minisrv_config = minisrv_config; this.service_name = service_name; this.service_config = minisrv_config.services[service_name] || {}; this.wtvshared = new WTVShared(minisrv_config, true); this.server = net.createServer((socket) => this.handleConnection(socket)); // Stable 32-bit "server id" byte embedded in the first 0x4F chunk of every // descriptor. Observed RealServer values all share the same high bytes // (0x00071a??), so keep the upper 24 bits fixed and randomize the low byte // per process lifetime. The low byte (= descriptor[5]) is NOT a checksum; // cross-referenced against multi_auth.pcap it is constant within a single // server process. this.serverId = 0x00071a00 | (crypto.randomInt(0, 0x100) & 0xff); // Per-session counter embedded in bytes 9..12 of the first 0x4F chunk. // multi_auth.pcap increments this 1,2,3,... across successive sessions. this.sessionCounter = 0; } listen(port, host = '0.0.0.0') { this.server.listen(port, host); return this.server; } close() { if (this.server) this.server.close(); } getDebugEnabled() { return this.minisrv_config.config?.debug_flags?.debug || this.service_config.debug; } debugLog(...args) { if (this.getDebugEnabled()) { console.log('[WTVPNM]', ...args); } } handleConnection(socket) { socket.setNoDelay(true); const session = { id: `${socket.remoteAddress}:${socket.remotePort}`, remoteIp: (socket.remoteAddress || '').replace('::ffff:', ''), helloSent: false, descriptorSent: false, descriptorTimer: null, capabilitiesLogged: false, capabilities: [], clientChallenge: null, requestedMedia: null, mediaPath: null, notFoundSent: false, pnaFields: null, tcpTimer: null, tcpSeq: 0, ipId: 0, tcpStartTimer: null, mediaData: null, mediaOffset: 0, bytesRx: 0, bytesTx: 0, // Control-stream command accumulator. TCP is byte-oriented so // multi-byte commands (0x53 seek, 0x67 stats) can be split across // receive events or coalesced with other bytes; we accumulate and // decode them here in handleControlCommands(). ctrlBuf: Buffer.alloc(0), // RDT b5 nibble state. High nibble = 'seek generation' (starts // at 1, bumped on every 0x53 seek). Low nibble = (seq - // seekBaseSeq) & 0xf where seekBaseSeq is the wall-seq of the // most recent keyframe (RA flags & 0x02) or seek. See the // b5 breakdown comment in buildMediaPayload. seekGen: 1, seekBaseSeq: 0, paused: false, // 'EOS' marker (single 0x45 byte) has been sent on TCP; prevent // duplicate sends if stream-complete fires more than once. eosSent: false }; this.sessions.set(socket, session); this.debugLog('client connected', session.id); socket.on('data', (data) => { try { this.handleData(socket, data); } catch (e) { console.error(' * WTVPNM Error: handleData', e); } }); socket.on('close', (hadError) => { this.clearDescriptorTimer(session); this.stopUdpStream(session); this.debugLog('client disconnected', session.id, hadError ? 'hadError' : 'clean', `tx=${session.bytesTx}`, `rx=${session.bytesRx}`); this.sessions.delete(socket); }); socket.on('error', (err) => { this.clearDescriptorTimer(session); this.stopUdpStream(session); this.debugLog('socket error', session.id, err.message); this.sessions.delete(socket); }); } handleData(socket, data) { const session = this.sessions.get(socket); if (!session) return; session.bytesRx += data.length; const ascii = data.toString('latin1').replace(/[^\x20-\x7E]/g, '.'); this.debugLog('rx', session.id, 'len', data.length, ascii.slice(0, 120)); this.debugLog('rx hex', session.id, data.toString('hex')); if (data.includes(Buffer.from('PNA\x00\x0a', 'latin1'))) { session.pnaFields = this.parsePnaMessage(data); // Dump all parsed PNA fields for debugging if (session.pnaFields && session.pnaFields.length > 0) { session.pnaFields.forEach((f) => { const txt = f.value.toString('latin1').replace(/[^\x20-\x7E]/g, '.').slice(0, 80); this.debugLog('pna field', session.id, `id=${f.id}`, `len=${f.len}`, `hex=${f.value.toString('hex').slice(0, 60)}`, txt); }); } const parsedChallenge = this.getClientChallenge(session.pnaFields); if (parsedChallenge) session.clientChallenge = parsedChallenge; // Extract UDP port from PNA field ID 1 (2 bytes, big-endian) const udpPortField = session.pnaFields.find(f => f.id === 1 && f.len === 2); if (udpPortField) { session.clientUdpPort = udpPortField.value.readUInt16BE(0); this.debugLog('client udp port', session.id, session.clientUdpPort); } const parsedMedia = this.getRequestedMediaName(session.pnaFields, data); if (parsedMedia) { session.requestedMedia = parsedMedia; session.mediaPath = this.resolveMediaPath(session.requestedMedia); } if (!session.capabilitiesLogged) { session.capabilitiesLogged = true; const cap = this.extractCapabilities(data); session.capabilities = cap; // Detect WebTV via User-Agent string in raw request data. // The WebTV PNM client advertises cook/sipr caps like modern // RealPlayer, so codec-sniffing is unreliable; the UA is the // only dependable discriminator. Captured UA format: // 'Mozilla/3.0 WebTV/2.5 (Compatible; MSIE 2.0)' const raw = data.toString('latin1'); session.isWebTV = /WebTV\//i.test(raw); if (cap.length > 0) { this.debugLog('client capabilities', session.id, cap.join(', ')); } this.debugLog('client type', session.id, session.isWebTV ? 'WebTV' : 'non-WebTV'); if (session.clientChallenge) { this.debugLog('client challenge', session.id, session.clientChallenge); } if (session.requestedMedia) { this.debugLog('requested media', session.id, session.requestedMedia); } } if (session.requestedMedia && !session.mediaPath) { this.sendNotFound(socket, session.requestedMedia); session.notFoundSent = true; return; } } if (session.notFoundSent) return; if (!session.helloSent && (ascii.includes('GET /a') || data.includes(Buffer.from('PNA\x00\x0a', 'latin1')))) { this.sendHelloSequence(socket, session); return; } if (session.helloSent && !session.descriptorSent && (ascii.includes('GET /r') || ascii.includes('BET /r') || ascii.toLowerCase().includes('sta'))) { this.sendDescriptorAndStartStream(socket, session, 'client-trigger'); return; } if (session.helloSent && session.descriptorSent) { // Client sends hash response: 0x23 0x00 0x20 + 32 hex chars (35 bytes total) if (data.length === 35 && data[0] === 0x23) { const hashHex = data.toString('ascii', 3, 35); this.debugLog('client hash response', session.id, hashHex); // Verify client response: RNWK_MD5(serverHello_BE, 0x00000000, clientChallenge) const expectedResp = this.computeClientResponse(session); if (expectedResp && hashHex === expectedResp) { this.debugLog('client hash VERIFIED', session.id); session.hashVerified = true; } else { this.debugLog('client hash MISMATCH', session.id, 'expected', expectedResp); } if (session.clientUdpPort && this.service_config.auto_stream !== false) { this.startUdpStream(socket, session); } } else { // Post-descriptor control byte stream. See handleControlCommands // for opcode list. Accumulate and decode — RP8 uses seek/ // pause/resume commands here that can arrive coalesced or // fragmented across TCP segments. this.handleControlCommands(socket, session, data); } return; } } // Parse the post-descriptor TCP control stream sent by RealPlayer during // and after playback. Observed opcodes (multi_seek.pcap, wtv2.pcap): // 0x21 ('!') — 1 byte — periodic keepalive during playback // 0x42 ('B') — 1 byte — play/resume (first seen right before UDP starts) // 0x50 ('P') — 1 byte — pause // 0x53 ('S') — 5 bytes — seek: 0x53 + uint32-BE milliseconds // 0x67 ('g') — 3+N bytes — client stats report: 0x67 + uint16-BE len + payload // The native RealServer does NOT application-reply to any of these on // TCP (only TCP-ACKs). The one exception is the 0x45 end-of-stream // byte the server emits ~0.5s after the last UDP packet. handleControlCommands(socket, session, data) { session.ctrlBuf = session.ctrlBuf && session.ctrlBuf.length ? Buffer.concat([session.ctrlBuf, data]) : Buffer.from(data); let off = 0; const buf = session.ctrlBuf; while (off < buf.length) { const op = buf[off]; if (op === 0x21 || op === 0x42 || op === 0x50) { if (op === 0x21) { this.debugLog('ctrl keepalive', session.id); } else if (op === 0x42) { this.debugLog('ctrl play/resume', session.id); this.resumeUdpStream(socket, session); } else { this.debugLog('ctrl pause', session.id); this.pauseUdpStream(session); } off += 1; } else if (op === 0x53) { if (buf.length - off < 5) break; // need more data const targetMs = buf.readUInt32BE(off + 1); this.debugLog('ctrl seek', session.id, `target=${targetMs}ms`); this.seekUdpStream(session, targetMs); off += 5; } else if (op === 0x67) { if (buf.length - off < 3) break; const slen = buf.readUInt16BE(off + 1); if (buf.length - off < 3 + slen) break; const statsBody = buf.slice(off + 3, off + 3 + slen); const txt = statsBody.toString('latin1').replace(/[^\x20-\x7E]/g, '.'); this.debugLog('ctrl stats', session.id, `len=${slen}`, txt.slice(0, 120)); off += 3 + slen; } else { // Unknown byte — log once and skip to resync. this.debugLog('ctrl unknown', session.id, `op=0x${op.toString(16)}`, 'hex', buf.slice(off, off + 16).toString('hex')); off += 1; } } // Preserve any trailing incomplete command for next receive. session.ctrlBuf = off < buf.length ? buf.slice(off) : Buffer.alloc(0); } pauseUdpStream(session) { if (!session || session.paused) return; session.paused = true; if (session.udpTimer) { clearInterval(session.udpTimer); session.udpTimer = null; } this.debugLog('udp stream paused', session.id); } resumeUdpStream(socket, session) { if (!session || !session.paused) return; session.paused = false; // Re-arm the interval where it left off. _startDataInterval is set // up by startUdpStream() and stays on the session so pause/seek/ // resume can reuse the same timer machinery. if (typeof session._startDataInterval === 'function' && !session.udpTimer && !socket.destroyed) { session._startDataInterval(); this.debugLog('udp stream resumed', session.id); } } seekUdpStream(session, targetMs) { if (!session || !session.mediaFrames || !session.mediaFrames.length) return; const frames = session.mediaFrames; // Find the largest index whose ts <= targetMs AND is a keyframe // (RA flags bit 1 set). Seeking to a non-keyframe would land mid- // block and the cook decoder would emit garbage until the next key. // Fallback: if no keyframe at/below target (rare), use the first. let idx = 0; for (let i = 0; i < frames.length; i++) { if (frames[i].ts > targetMs) break; if (frames[i].flags & 0x02) idx = i; } session.mediaFrameIdx = idx; // Bump seek generation (RDT b5 high nibble). multi_seek.pcap shows // it incrementing 1→2→3→4→5 across four seeks; we wrap within the // 4-bit field, skipping 0 so it always differs from 'no stream'. session.seekGen = ((session.seekGen || 0) + 1) & 0x0f; if (session.seekGen === 0) session.seekGen = 1; // Low nibble restarts at 0 on seek — the next packet carries the new // keyframe so seekBaseSeq will be updated in the interval callback // to match the wall-seq used for that packet. session.eosSent = false; this.debugLog('udp stream seek', session.id, `target=${targetMs}ms`, `→frame[${idx}] ts=${frames[idx].ts}ms flags=0x${frames[idx].flags.toString(16)}`, `gen=${session.seekGen}`); } send(socket, buffer) { const session = this.sessions.get(socket); if (!session) return; session.bytesTx += buffer.length; this.debugLog('tx', session.id, 'len', buffer.length, 'hex', buffer.toString('hex').slice(0, 100)); socket.write(buffer); } sendNotFound(socket, requestedMedia = null) { const target = requestedMedia || 'unknown'; this.debugLog('media missing, sending 404', target); const body = `404 Not Found\r\nMissing media: ${target}\r\n`; const headers = [ 'HTTP/1.0 404 Not Found', 'Content-Type: text/plain', `Content-Length: ${Buffer.byteLength(body, 'utf8')}`, 'Connection: close', '', '' ].join('\r\n'); socket.write(headers + body, () => socket.end()); } getRequestedMediaName(fields, rawData) { if (!Array.isArray(fields) || fields.length === 0) return this.scanRawForMediaName(rawData); // Field 0x52 (82) carries the requested file name in observed captures. const fileField = fields.find((f) => f && f.id === 82 && f.len > 0); if (fileField) { const raw = fileField.value.toString('latin1').replace(/\x00+$/g, '').trim(); if (raw) return path.basename(raw); } // Some clients may carry filename in another TLV field; scan all text values. for (const field of fields) { if (!field || !field.len || field.len < 4) continue; const raw = field.value.toString('latin1').replace(/\x00+/g, ' ').trim(); const match = raw.match(/([A-Za-z0-9_\-\.\/]+\.(?:ra|ray|rm|ram))/i); if (match) { return path.basename(match[1]); } } // Fallback: scan raw data buffer for media filename pattern. return this.scanRawForMediaName(rawData); } scanRawForMediaName(rawData) { if (!Buffer.isBuffer(rawData)) return null; const str = rawData.toString('latin1'); const match = str.match(/([A-Za-z0-9_\-\.]+\.(?:ra|ray|rm|ram))(?:[^A-Za-z0-9]|$)/i); return match ? path.basename(match[1]) : null; } getClientChallenge(fields) { if (!Array.isArray(fields) || fields.length === 0) return null; // Field 4 carries a 32-char challenge token in observed client hello payloads. const challengeField = fields.find((f) => f && f.id === 4 && f.len >= 16); if (!challengeField) return null; const raw = challengeField.value.toString('latin1').replace(/\x00+$/g, '').trim(); const match = raw.match(/[a-f0-9]{32}/i); if (!match) return null; return match[0].toLowerCase(); } getClientTimestamp(fields) { if (!Array.isArray(fields) || fields.length === 0) return null; // PNA_TIMESTAMP is field ID 0x17 (23). Example string: [17/04/2026:02:50:34 00:00] const tsField = fields.find((f) => f && f.id === 23); if (!tsField) return null; const raw = tsField.value.toString('latin1').replace(/\x00+$/g, '').trim(); const match = raw.match(/\[?(\d{2})\/(\d{2})\/(\d{4}):(\d{2}):(\d{2}):(\d{2})/); if (match) { const ms = Date.UTC(parseInt(match[3], 10), parseInt(match[2], 10) - 1, parseInt(match[1], 10), parseInt(match[4], 10), parseInt(match[5], 10), parseInt(match[6], 10)); return Math.floor(ms / 1000); } return null; } resolveMediaPath(requestedMedia) { if (!requestedMedia) return null; const serviceVaultDir = this.service_config.servicevault_dir || this.service_name; const vaults = this.minisrv_config.config?.ServiceVaults || []; const extensionVariants = this.getMediaNameVariants(requestedMedia); for (const vault of vaults) { const base = this.wtvshared.getAbsolutePath(serviceVaultDir, vault); for (const variant of extensionVariants) { const candidate = this.wtvshared.makeSafePath(base, variant); if (candidate && fs.existsSync(candidate) && fs.lstatSync(candidate).isFile()) { if (this.service_config.debug) { this.debugLog('media file found', variant, '->', candidate); } return candidate; } } } if (this.service_config.debug) { this.debugLog('media file not found in vaults', requestedMedia); } return null; } getMediaNameVariants(requestedMedia) { const base = path.basename(requestedMedia || '').trim(); if (!base) return []; const ext = path.extname(base).toLowerCase(); const stem = ext.length > 0 ? base.slice(0, -ext.length) : base; const variants = [base]; if (ext === '.ray') variants.push(`${stem}.ra`); if (ext === '.ram') variants.push(`${stem}.ra`); if (ext === '.rm') variants.push(`${stem}.ra`); if (!ext) { variants.push(`${stem}.ra`); } return Array.from(new Set(variants)); } sendHelloSequence(socket, session) { if (!socket || !session || session.helloSent) return; // RealServer sends the 9-byte PNA hello first, then WAITS for the // client to ACK it before sending the 361-byte descriptor (observed // ~85ms gap in wtv_multi.pcap). When we send both back-to-back, // WebTV silently disconnects after the hello and RP8 gets stuck // buffering at 0kbps — both clients parse hello and descriptor in // separate states and dislike receiving the descriptor bytes before // the hello-handling state completes. // // Node's 'net' socket doesn't expose per-write ACK callbacks, but a // short timer approximates the real flow well enough: send hello, // then send descriptor ~100ms later (> typical delayed-ACK of 40ms // but < client timeout). The gap also gives the client time to // parse the challenge before the next packet arrives. const hello = this.buildPnaHello(session); session.helloSent = true; this.send(socket, hello); this.debugLog('hello sent', session.id, `len=${hello.length}`); const descriptorDelay = (typeof this.service_config.descriptor_after_hello_ms === 'number') ? this.service_config.descriptor_after_hello_ms : 100; session.descriptorTimer = setTimeout(() => { session.descriptorTimer = null; if (socket.destroyed || session.descriptorSent) return; const descriptor = this.buildDescriptorPacket(session); session.descriptorSent = true; this.send(socket, descriptor); this.debugLog('descriptor sent', session.id, `len=${descriptor.length}`, `delay=${descriptorDelay}ms`); this.prepareMediaData(session); }, descriptorDelay); } clearDescriptorTimer(session) { if (!session) return; if (session.descriptorTimer) { clearTimeout(session.descriptorTimer); session.descriptorTimer = null; } } scheduleDescriptorSequence(socket, session) { this.clearDescriptorTimer(session); let delayMs = null; let reason = 'webtv-sequence'; if (this.service_config.descriptor_on_first_pna === true) { delayMs = (typeof this.service_config.descriptor_after_hello_ms === 'number') ? this.service_config.descriptor_after_hello_ms : (this.service_config.descriptor_fallback_ms || 20); } if (delayMs === null) return; this.debugLog('descriptor scheduled', session.id, reason, `${delayMs}ms`); session.descriptorTimer = setTimeout(() => { this.sendDescriptorAndStartStream(socket, session, reason); }, delayMs); } sendDescriptorAndStartStream(socket, session, reason) { if (!socket || !session || session.descriptorSent) return; this.clearDescriptorTimer(session); if (socket.destroyed) return; this.send(socket, this.buildDescriptorPacket(session)); session.descriptorSent = true; this.debugLog('descriptor sent', session.id, reason); this.prepareMediaData(session); // Wait for UDP port response from client before starting stream this.debugLog('descriptor sent, waiting for client UDP port response on TCP connection', session.id); } stopUdpStream(session) { if (!session) return; if (session.udpStartTimer) { clearTimeout(session.udpStartTimer); session.udpStartTimer = null; } if (session.udpTimer) { clearInterval(session.udpTimer); session.udpTimer = null; } if (session.udpSocket) { try { session.udpSocket.close(); } catch(e) {} session.udpSocket = null; } } prepareMediaData(session) { if (!session || session.mediaFrames) return; if (!session.mediaPath || !fs.existsSync(session.mediaPath)) { return; } try { const media = fs.readFileSync(session.mediaPath); // Read avgBitRate from the PROP chunk so we can pace UDP packets // correctly. Underpacing (> real bitrate ms) causes client-side // buffer underruns / dropouts. PROP layout after 8-byte header: // uint16 version | uint32 maxBitRate | uint32 avgBitRate | ... // so avgBitRate lives at PROP.offset + 8 + 2 + 4 = +14. const propChunk = this.getRealMediaChunk(media, 'PROP'); if (propChunk && propChunk.size >= 22) { const avgBitRate = media.readUInt32BE(propChunk.offset + 14); if (avgBitRate > 0) { session.avgBitRate = avgBitRate; this.debugLog('media avgBitRate', session.id, `${avgBitRate} bps`); } } // Parse DATA chunk records. RA v4 DATA chunk: // [DATA:4][size:4][ver:2][numPkts:4][nextDataOfs:4] = 18 bytes header // Each record: // [ver:2=0x0000][len:2][stream:2][ts:4][flags:2][audio:len-12] // The native RealServer maps each record 1:1 to an RDT data packet // of the same length, copying the flags field into the RDT header. const dataChunk = this.getRealMediaChunk(media, 'DATA'); if (!dataChunk || dataChunk.size < 18) { this.debugLog('media DATA chunk missing or too small', session.id); return; } const numPkts = media.readUInt32BE(dataChunk.offset + 10); const frames = []; let o = dataChunk.offset + 18; const end = dataChunk.offset + dataChunk.size; while (o + 12 <= end && frames.length < numPkts) { const len = media.readUInt16BE(o + 2); if (len < 12 || o + len > end) break; const ts = media.readUInt32BE(o + 6); const flags = media.readUInt16BE(o + 10); const audio = media.slice(o + 12, o + len); frames.push({ ts, flags, audio }); o += len; } session.mediaFrames = frames; session.mediaFrameIdx = 0; this.debugLog('media frames parsed', session.id, `count=${frames.length}`, `expected=${numPkts}`, `firstLen=${frames[0]?.audio.length}`); } catch (e) { this.debugLog('media payload load failed', session.id, e.message); } } startUdpStream(socket, session) { if (!socket || !session || socket.destroyed) return; if (session.udpTimer || session.udpStartTimer) return; // Packet cadence is driven by the stream's avgBitRate (read from the // PROP chunk in prepareMediaData). Each RDT data packet carries one // RA frame's audio payload (typically 600 bytes for cook). The sync // frame adds 10 bytes every 5th packet, so amortize that into the // per-packet average to keep the long-run byte rate matching the // avgBitRate (otherwise ~0.4% underrun over time). // Fallback to 220 ms only if avgBitRate can't be determined. const syncEvery = 5; const firstFrame = session.mediaFrames?.[0]; const bodyLen = firstFrame ? firstFrame.audio.length : 600; const avgBytesPerPacket = bodyLen + (10 / syncEvery); const intervalMs = session.avgBitRate > 0 ? (avgBytesPerPacket * 8000) / (session.avgBitRate + 1024) : 220; const startDelayMs = 72; const redundantSeqs = [0, 1]; this.debugLog('udp stream start', session.id, `frames=${session.mediaFrames?.length || 0}`, `avgBitRate=${session.avgBitRate || 'unknown'}bps`, `bodyLen=${bodyLen}`, `interval=${intervalMs.toFixed(2)}ms`, `target=${socket.remoteAddress}:${session.clientUdpPort}`); const dgram = require('dgram'); session.udpSocket = dgram.createSocket('udp4'); session.udpSocket.on('error', (err) => { this.debugLog('udp socket error', session.id, err.message); this.stopUdpStream(session); }); // Some RDT clients also send ACK/resend requests back on the same UDP // flow. Bind so we can receive (even if we ignore the content). session.udpSocket.on('message', (msg, rinfo) => { this.debugLog('udp rx', session.id, `from=${rinfo.address}:${rinfo.port}`, `len=${msg.length}`, 'hex', msg.slice(0, 32).toString('hex')); }); // sendPacket wraps buildMediaPayload with the every-5th-sync-frame // prefix and writes to the UDP socket. Wall-seq and frame are passed // explicitly so the initial-burst retransmit of seq 0,1 (and seeks) // can pair any wall-seq with any frame index. const sendPacket = (seq, frame) => { if (socket.destroyed || !session.udpSocket) return; const withSync = (seq > 0) && (seq % syncEvery === (syncEvery - 1)); const dataFrame = this.buildMediaPayload(session, seq, frame); const out = withSync ? Buffer.concat([this.buildSyncFrame(session, seq), dataFrame]) : dataFrame; session.udpSocket.send(out, 0, out.length, session.clientUdpPort, socket.remoteAddress, (err) => { if (err) this.debugLog('udp send err', session.id, err.message); }); }; // Wall-seq is the RDT byte-2/3 counter. It ticks monotonically for // the lifetime of the session (including across seeks) while the // frame cursor (session.mediaFrameIdx) jumps around on seek. We // close over `seq` in _startDataInterval so pause/resume can pick // up right where we left off. let seq = 0; session.mediaFrameIdx = 0; session._startDataInterval = () => { if (session.udpTimer) return; session.udpTimer = setInterval(() => { if (socket.destroyed || !session.udpSocket) { this.stopUdpStream(session); return; } if (session.paused) return; const frames = session.mediaFrames; if (!frames || session.mediaFrameIdx >= frames.length) { // End of media: stop sending once all RA frames are out. this.debugLog('udp stream complete', session.id, `sent=${seq}`); if (session.udpTimer) { clearInterval(session.udpTimer); session.udpTimer = null; } // Signal end-of-stream to the client on TCP. wtv2.pcap // shows the native RealServer sending a single 0x45 byte // ~0.5s after the last UDP packet; the client then FINs. // Without this the client sits in 'buffering' forever // waiting for more audio it will never get. if (!session.eosSent) { session.eosSent = true; setTimeout(() => { if (!socket.destroyed) { this.send(socket, Buffer.from([0x45])); this.debugLog('sent EOS marker', session.id); } this.stopUdpStream(session); }, 500); } return; } const frame = frames[session.mediaFrameIdx]; // Keyframe / post-seek resets the b5 low-nibble counter: // the next packet's lo = (seq - seekBaseSeq) & 0xf = 0. if (frame.flags & 0x02) session.seekBaseSeq = seq; sendPacket(seq, frame); seq++; session.mediaFrameIdx++; }, intervalMs); }; session.udpStartTimer = setTimeout(() => { session.udpStartTimer = null; if (socket.destroyed) return; // Initial redundant burst: send seq 0,1 once, then the interval // takes over and re-sends frame 0,1,2,3,... with wall-seq 0,1,2,... // multi_auth.pcap shows the real RealServer doing this — RP8 // uses the duplicates for loss recovery. const frames = session.mediaFrames || []; for (const s of redundantSeqs) { const f = frames[s]; if (f) { if (f.flags & 0x02) session.seekBaseSeq = s; sendPacket(s, f); } } session._startDataInterval(); }, startDelayMs); } // RDT Latency/Sync report block. Observed in multi_auth.pcap prepended // to every 5th data packet (making it a 622-byte datagram = 10 + 612). // Layout: [len16=0x000a][type16][flags8][seq8][timestamp24][pad8] // Captured example at seq 4: `00 0a 04 77 62 00 00 0a dc 00`. buildSyncFrame(session, seq) { const out = Buffer.alloc(10); out.writeUInt16BE(0x000a, 0); // length out.writeUInt16BE(0x0477, 2); // type (latency report) out.writeUInt8(0x62, 4); // flags/stream out.writeUInt8(0x00, 5); // pad // Embed a pseudo-timestamp derived from seq (ms since stream start, // using the same 232 ms cadence as data frames). const syncTs = (seq * 232 * 3) & 0xffffff; out.writeUInt8((syncTs >> 16) & 0xff, 6); out.writeUInt8((syncTs >> 8) & 0xff, 7); out.writeUInt8(syncTs & 0xff, 8); out.writeUInt8(0x00, 9); return out; } buildTunnelFrame(session) { // Send raw PNA media data directly on TCP (no PPP/IP/UDP wrapping). return this.buildMediaPayload(session); } buildMediaPayload(session, pSeq, pFrame) { const seq = pSeq !== undefined ? pSeq : (session ? session.udpSeq || 0 : 0); if (session && pSeq === undefined) session.udpSeq = seq + 1; // Pick the frame: caller can pass one explicitly (interval / burst / // seek path) or, for the legacy tunnel path, we fall back to indexing // by seq against mediaFrames as before. let frame = pFrame; if (frame === undefined) { frame = session?.mediaFrames?.[seq]; if (session) { session.mediaFrameIdx = Math.max(session.mediaFrameIdx || 0, seq + 1); } } // RDT b5 nibble: // high nibble = seekGen (1 on first play, ++ per 0x53 seek, // wraps within 4 bits skipping 0) // low nibble = (seq - seekBaseSeq) & 0xf // seekBaseSeq gets bumped to the current wall-seq whenever a // keyframe (RA flags bit 1) is emitted OR a seek occurs, which // causes lo to reset to 0 at those boundaries. Matches the pattern // observed in multi_seek.pcap (gen1 → gen2 → gen5 on seeks, and // natural reset at mid-stream keyframes within a generation). const seekGen = (session?.seekGen || 1) & 0x0f; const seekBaseSeq = session?.seekBaseSeq || 0; const b5 = (seekGen << 4) | ((seq - seekBaseSeq) & 0x0f); if (!frame) { // No media (or stream exhausted): emit a 12-byte-header filler. const out = Buffer.alloc(12); out[0] = 0x02; out[1] = 0x64; out.writeUInt16BE(seq & 0xffff, 2); out[4] = 0x5a; out[5] = b5; return out; } const audioLen = frame.audio.length; const out = Buffer.alloc(12 + audioLen); // RDT data-packet header (12 bytes). Layout confirmed against // multi_auth.pcap seq 0..3 and multi_seek.pcap gen2+: // [0..1] 02 64 — packet type/flags (constant) // [2..3] uint16 BE seq // [4] 5a — stream flags (constant) // [5] (seekGen<<4) | ((seq-seekBaseSeq)&0xf) — see b5 above // [6..7] uint16 BE ts high (0 for short clips) // [8..9] uint16 BE ts low — from RA record // [10..11] uint16 BE flags — from RA record (0x0002 keyframe) out[0] = 0x02; out[1] = 0x64; out.writeUInt16BE(seq & 0xffff, 2); out[4] = 0x5a; out[5] = b5; out.writeUInt16BE((frame.ts >>> 16) & 0xffff, 6); out.writeUInt16BE(frame.ts & 0xffff, 8); out.writeUInt16BE(frame.flags & 0xffff, 10); frame.audio.copy(out, 12); return out; } buildIPv4UdpPacket(session, udpPayload) { const srcIp = this.parseIPv4(this.service_config.stream_src_ip || '10.0.0.2'); const dstIp = this.parseIPv4(this.service_config.stream_dst_ip || '10.0.0.3'); const srcPort = this.service_config.stream_udp_src_port || 0xb385; const dstPort = this.service_config.stream_udp_dst_port || 6970; const ipHeader = Buffer.alloc(20); const udpHeader = Buffer.alloc(8); const totalLen = 20 + 8 + udpPayload.length; const ipId = (session.ipId++ & 0xffff); ipHeader[0] = 0x45; ipHeader[1] = 0x00; ipHeader.writeUInt16BE(totalLen, 2); ipHeader.writeUInt16BE(ipId, 4); ipHeader.writeUInt16BE(0x4000, 6); ipHeader[8] = 0x40; ipHeader[9] = 0x11; srcIp.copy(ipHeader, 12); dstIp.copy(ipHeader, 16); ipHeader.writeUInt16BE(this.ipv4HeaderChecksum(ipHeader), 10); udpHeader.writeUInt16BE(srcPort & 0xffff, 0); udpHeader.writeUInt16BE(dstPort & 0xffff, 2); udpHeader.writeUInt16BE(8 + udpPayload.length, 4); udpHeader.writeUInt16BE(0, 6); return Buffer.concat([ipHeader, udpHeader, udpPayload]); } buildPppIpFrame(ipPacket) { const protocol = Buffer.from([0x21]); const raw = Buffer.concat([protocol, ipPacket]); return this.pppEscape(raw); } pppEscape(buffer) { const escaped = []; for (let i = 0; i < buffer.length; i++) { const b = buffer[i]; if (b === 0x7d || b === 0x7e || b < 0x20) { escaped.push(0x7d, b ^ 0x20); } else { escaped.push(b); } } return Buffer.from(escaped); } parseIPv4(ipStr) { const parts = String(ipStr).split('.').map((v) => parseInt(v, 10)); if (parts.length !== 4 || parts.some((v) => Number.isNaN(v) || v < 0 || v > 255)) { return Buffer.from([10, 0, 0, 2]); } return Buffer.from(parts); } ipv4HeaderChecksum(header) { let sum = 0; for (let i = 0; i < 20; i += 2) { if (i === 10) continue; sum += header.readUInt16BE(i); while (sum > 0xffff) sum = (sum & 0xffff) + (sum >>> 16); } return (~sum) & 0xffff; } // The server-challenge wire format has two observed variants: // - 16-bit: small values like 0x03f1, 0x047d, 0x011e..0x0138. Seen in // every WebTV capture (wtv.pcap, wtv2.pcap, wtv_multi.pcap). WebTV's // PNM client REFUSES to send its hash response when the upper 16 bits // are non-zero. // - 32-bit Unix timestamp: seen in multi_auth.pcap (a newer RealServer // build talking to modern RealPlayer). // Detection is by User-Agent (set in handleData from the raw request data) // since WebTV clients advertise the same cook/sipr caps as modern RP. buildPnaHello(session = null) { // The client advertises its local `time()` value in tag 0 of the // PNA request, XORed with 0x67E32B93. The hello-parser in // pn_net::hello_state compares the server's 4 challenge bytes // against the un-masked value and silently closes the connection // on mismatch (error 34, 'bad magic'). Echoing the recovered // time back is the ONLY way modern WebTV PNM accepts the hello. // // Fallbacks when tag 0 is absent (older RealPlayer that doesn't // send it): // - non-WebTV UA: use our wall-clock time (32-bit) // - WebTV UA: use a small 16-bit increment (upper 16 bits MUST // be zero for WebTV PNM to accept our hello in the first // place — pre-tag-0 builds only range-check the low half). const CLIENT_TIME_MASK = 0x67E32B93; const isWebTV = session?.isWebTV === true; const forceNarrow = this.service_config.force_narrow_challenge === true; let challengeValue = null; let challengeSource = null; const tag0 = Array.isArray(session?.pnaFields) ? session.pnaFields.find((f) => f && f.id === 0 && f.len === 4) : null; if (tag0) { challengeValue = (tag0.value.readUInt32BE(0) ^ CLIENT_TIME_MASK) >>> 0; challengeSource = 'client-tag0'; } else if (isWebTV || forceNarrow) { const base = this.service_config.server_challenge_base ?? (crypto.randomInt(0x0100, 0x0200) & 0xFFFF); const nextSession = this.sessionCounter + 1; challengeValue = (base + nextSession) & 0xFFFF; challengeSource = 'narrow-fallback'; } else { challengeValue = Math.floor(Date.now() / 1000) >>> 0; challengeSource = 'wide-fallback'; } if (session) { session.serverChallenge = challengeValue; if (typeof session.sessionNumber !== 'number') { session.sessionNumber = ++this.sessionCounter; } this.debugLog('pna hello', session.id, challengeSource, isWebTV ? '[WebTV]' : '', `challenge=0x${challengeValue.toString(16)}`); } const out = Buffer.alloc(9); out.write('PNA', 0, 'ascii'); out[3] = 0x00; out[4] = 0x0a; out.writeUInt32BE(challengeValue, 5); return out; } buildDescriptorPacket(session = null) { const fs = require('fs'); const outChunks = []; // 4F headers: Rule Tags / Properties (based on capture to appease client parser) const initTags = Buffer.from('4f0800071a72000000014f060008000000034f02000c4f02000e4f02000f4f0200154f020010', 'hex'); outChunks.push(initTags); let raBuffer = null; if (session && session.mediaPath) { try { raBuffer = fs.readFileSync(session.mediaPath); } catch(e) { this.debugLog('buildDescriptor error reading media', session.mediaPath, e.message); } } if (raBuffer && raBuffer.length > 8 && raBuffer.toString('latin1', 0, 4) === '.RMF') { let offset = 0; // Skip .RMF chunk (usually size 18) const rmfSize = raBuffer.readUInt32BE(4); offset += rmfSize; let chunksFound = []; while (offset < raBuffer.length) { const tag = raBuffer.toString('latin1', offset, offset + 4); const size = raBuffer.readUInt32BE(offset + 4); // Descriptor typically includes PROP, CONT, and the first MDPR chunk if (['PROP', 'CONT'].includes(tag) || (tag === 'MDPR' && !chunksFound.includes('MDPR'))) { chunksFound.push(tag); let chunkData = raBuffer.subarray(offset, offset + size); let finalSize = size; // Normalize PROP tail fields to the stable RealServer values // seen in both captures. if (tag === 'PROP' && chunkData.length >= 50) { const newChunk = Buffer.from(chunkData); newChunk.writeUInt32BE(0x00000000, 28); newChunk.writeUInt32BE(0xCC130000, 32); newChunk.writeUInt32BE(0x10520000, 36); newChunk.writeUInt32BE(0x00000000, 40); newChunk.writeUInt32BE(0x00000001, 44); newChunk.writeUInt16BE(0x0009, 48); chunkData = newChunk; } // Clean CONT chunk by stripping trailing null bytes from string fields if (tag === 'CONT' && chunkData.length >= 24) { try { const version = chunkData.readUInt16BE(8); let off = 10; const readField = () => { const len = chunkData.readUInt16BE(off); off += 2; const buf = chunkData.subarray(off, off + len); off += len; let cLen = len; while (cLen > 0 && buf[cLen - 1] === 0) cLen--; return { cleanedLen: cLen, buf: buf.subarray(0, cLen) }; }; const title = readField(); const author = readField(); const copyright = readField(); const comment = readField(); finalSize = 10 + (2 + title.cleanedLen) + (2 + author.cleanedLen) + (2 + copyright.cleanedLen) + (2 + comment.cleanedLen); const newChunk = Buffer.alloc(finalSize); chunkData.subarray(0, 8).copy(newChunk, 0); // copy ID and Size newChunk.writeUInt32BE(finalSize, 4); // update internal size newChunk.writeUInt16BE(version, 8); // copy version let wOff = 10; const writeField = (field) => { newChunk.writeUInt16BE(field.cleanedLen, wOff); wOff += 2; field.buf.copy(newChunk, wOff); wOff += field.cleanedLen; }; writeField(title); writeField(author); writeField(copyright); writeField(comment); chunkData = newChunk; } catch (e) { this.debugLog('buildDescriptor CONT rewrite error', e.message); } } // Clean MDPR chunk by ensuring string fields are null-terminated and codec is injected if (tag === 'MDPR' && chunkData.length >= 42) { try { // The native RealServer replaces the 'startTime' field (offset 28) // with the 4cc codec ID (e.g. 'slae' or 'cook') for Audio streams // and seemingly adds a static padding byte after the MIME string. // The native RealServer overwrites the 'preroll' offset (32) and sometimes the // 'startTime' offset (28) based on the link bandwidth of the client. if (chunkData.indexOf(Buffer.from('audio/x-pn-', 'ascii')) > -1) { // Enforce the '00 00 10 52' Preroll seen across all WebTV PCAPs chunkData.writeUInt32BE(0x00001052, 32); // Legacy codec compatibility. In wtv_multi.pcap the // real RealServer leaks *uninitialized stack memory* // into this slot (" lae", "\0\0\0\0", "Slae", "W ro" // across 6 sessions) — WebTV still accepts it, so the // client clearly doesn't read this field. Default to // zeros (the safest "uninitialized" equivalent). // Override via service_config.mdpr_codec. const codecCfg = this.service_config.mdpr_codec; let codec; if (codecCfg === 'slae' || codecCfg === 'cook') { codec = Buffer.from(codecCfg, 'ascii'); } else if (typeof codecCfg === 'string' && codecCfg.length === 4) { codec = Buffer.from(codecCfg, 'ascii'); } else { codec = Buffer.alloc(4, 0); // default: null bytes } codec.copy(chunkData, 28); } // Re-align and null-terminate string arrays let off = 40; const nameL = chunkData.readUInt8(off); const nameStr = chunkData.subarray(off + 1, off + 1 + nameL); off += 1 + nameL; const mimeL = chunkData.readUInt8(off); const mimeStr = chunkData.subarray(off + 1, off + 1 + mimeL); off += 1 + mimeL; const needNameNull = nameStr[nameL - 1] !== 0; const needMimeNull = mimeStr[mimeL - 1] !== 0; const finalNameL = nameL + (needNameNull ? 1 : 0); const finalMimeL = mimeL + (needMimeNull ? 1 : 0); // Construct replacement middle-section const strBuf = Buffer.alloc(1 + finalNameL + 1 + finalMimeL); let w = 0; strBuf.writeUInt8(finalNameL, w++); nameStr.copy(strBuf, w); w += nameL; if (needNameNull) strBuf.writeUInt8(0, w++); strBuf.writeUInt8(finalMimeL, w++); mimeStr.copy(strBuf, w); w += mimeL; if (needMimeNull) strBuf.writeUInt8(0, w++); const head = chunkData.subarray(0, 40); const tail = chunkData.subarray(off); const newChunk = Buffer.concat([head, strBuf, tail]); finalSize = newChunk.length; newChunk.writeUInt32BE(finalSize, 4); // update internal size chunkData = newChunk; } catch (e) { this.debugLog('buildDescriptor MDPR rewrite error', e.message); } } // Optional codec adaptation if (tag === 'MDPR' && this.service_config.adapt_codec_from_caps === true) { const caps = (session && Array.isArray(session.capabilities)) ? session.capabilities : []; if (caps.includes('cook')) { // Replace 'slae' codec with 'cook' if requested const at = chunkData.indexOf(Buffer.from('slae', 'ascii')); if (at >= 0) { const newChunk = Buffer.from(chunkData); Buffer.from('cook', 'ascii').copy(newChunk, at); chunkData = newChunk; } } } // Wrap in [0x72] [size_16] const wrap = Buffer.alloc(3); wrap[0] = 0x72; wrap.writeUInt16BE(finalSize & 0xFFFF, 1); outChunks.push(wrap); outChunks.push(chunkData); } if (tag === 'DATA') break; // stop parsing once media data starts offset += size; } // The real server appends a 5-byte 0x4C packet EOF marker before the session token tag outChunks.push(Buffer.from('4c00000000', 'hex')); } else { // Fallback generic chunks if media cannot be read or is invalid this.debugLog('Falling back to default metadata headers'); outChunks.push(Buffer.from('72003250524f50000000320000000050bf000050bf0000025800000258000000000000cc130000105200000000000000000001000972001a434f4e540000001a0000000000000008284329203230303500007200a64d445052000000a600000000000050bf000050bf000002580000025800000000000010520000cc130d417564696f2053747265616d0015617564696f2f782d706e2d7265616c617564696f00000000562e7261fd000500002e726135000000100005000000460003000002580000000000025d990000000000090258003c0000000056220000562200000010000167656e72636f6f6b010700000000000801000001020000174c', 'hex')); } // Include the session token as tag 0x23 [size_16 = 64] const token = (this.service_config.dynamic_session_token === true) ? this.buildSessionToken(session) : '8e475de1df1ddc5c58c5ecef20e64d26073fe6f98fc0077dd0eb4429e0d8c375'; const tokenBuf = Buffer.alloc(3 + 64); tokenBuf[0] = 0x23; tokenBuf.writeUInt16BE(64, 1); Buffer.from(token, 'ascii').copy(tokenBuf, 3); outChunks.push(tokenBuf); const out = Buffer.concat(outChunks); // The first 0x4F/0x08 chunk carries [serverId_u32_BE][sessionCounter_u32_BE]. // These are NOT a checksum of serverChallenge — verified against // multi_auth.pcap (6 sessions, constant serverId, incrementing counter). // Descriptor layout: [0x4F, 0x08, serverId(4), sessionCounter(4), ...] // so serverId occupies out[2..6] and sessionCounter occupies out[6..10]. out.writeUInt32BE(this.serverId >>> 0, 2); const sessionNumber = (session && typeof session.sessionNumber === 'number') ? session.sessionNumber : ++this.sessionCounter; out.writeUInt32BE(sessionNumber >>> 0, 6); return out; } getRealMediaChunk(buffer, tag) { if (!buffer || !tag || tag.length !== 4) return null; const needle = Buffer.from(tag, 'ascii'); const offset = buffer.indexOf(needle); if (offset < 0 || offset + 8 > buffer.length) return null; const size = buffer.readUInt32BE(offset + 4); if (size < 8 || offset + size > buffer.length) return null; return { tag, offset, size, chunk: buffer.slice(offset, offset + size) }; } // Optimized-strlen equivalent from IDA: scan Buffer for first null byte, // return length capped at 56 (0x38). Mirrors the assembly that reads 4 // bytes at a time looking for a zero byte. pnmStrlen(buf) { if (!Buffer.isBuffer(buf) || buf.length === 0) return 0; const cap = Math.min(buf.length, 56); for (let i = 0; i < cap; i++) { if (buf[i] === 0) return i; } return cap; } // Challenge::Challenge(this, a2, a3, src, a5) // 64-byte MD5 input layout: [a2_BE a2_BE | src[0..55] XOR a5[0..55] | zeros] // a3 is unused. a2 is written big-endian to both s[0..3] and s[4..7]. computeChallengeHash(a2, srcBuf, xorBuf) { const key = Buffer.alloc(64, 0); key.writeUInt32BE(a2 >>> 0, 0); key.writeUInt32BE(a2 >>> 0, 4); // a2 repeated in both halves of the 8-byte key if (srcBuf) { const len = this.pnmStrlen(srcBuf); srcBuf.copy(key, 8, 0, len); } if (xorBuf) { const xorLen = this.pnmStrlen(xorBuf); for (let i = 0; i < xorLen; i++) key[8 + i] ^= xorBuf[i]; } return crypto.createHash('md5').update(key).digest(); } // Challenge::response1 / response2(this, src, a3, a4, a5) // 64-byte MD5 input layout: [a4_BE a5_BE | src[0..55] XOR a3[0..55] | zeros] // a4 fills s[0..3], a5 fills s[4..7] (two independent 32-bit values). computeResponseHash(a4, a5, srcBuf, xorBuf) { const key = Buffer.alloc(64, 0); key.writeUInt32BE(a4 >>> 0, 0); key.writeUInt32BE(a5 >>> 0, 4); if (srcBuf) { const len = this.pnmStrlen(srcBuf); srcBuf.copy(key, 8, 0, len); } if (xorBuf) { const xorLen = this.pnmStrlen(xorBuf); for (let i = 0; i < xorLen; i++) key[8 + i] ^= xorBuf[i]; } return crypto.createHash('md5').update(key).digest(); } buildSessionToken(session = null) { const challenge = session?.clientChallenge || ''; const serverChallenge = session?.serverChallenge || 0; const challengeBuf = Buffer.from(challenge, 'latin1'); const requestedMedia = session?.requestedMedia || ''; const resolvedMedia = session?.mediaPath ? path.basename(session.mediaPath) : ''; const responseSource = resolvedMedia || requestedMedia || challenge; const respSrcBuf = Buffer.from(responseSource, 'latin1'); const timestamp = this.getClientTimestamp(session?.pnaFields) ?? Math.floor(Date.now() / 1000); const v12 = timestamp ^ 0x67E32B93; const initMD5 = this.computeChallengeHash(v12, challengeBuf, null).toString('hex'); const initMD5Buf = Buffer.from(initMD5, 'latin1'); // First half matches sub_44FE30(a2=filename, a3=initMD5, a4=serverChallenge, a5=0). const resp1 = this.computeResponseHash(serverChallenge, 0, respSrcBuf, initMD5Buf).toString('hex'); this.debugLog('session token seed', session?.id || '?', `clientChallenge=${challenge}`, `requestedMedia=${requestedMedia || '(fallback:clientChallenge)'}`, `serverChallenge=${serverChallenge.toString(16)}`, `v12=${v12}`, `resp1=${resp1}`, `initMD5=${initMD5}`); return resp1 + initMD5; } computeClientResponse(session) { const challenge = session?.clientChallenge || ''; const serverChallenge = session?.serverChallenge || 0; if (!challenge) return null; const challengeBuf = Buffer.from(challenge, 'latin1'); return this.computeResponseHash(serverChallenge, 0, challengeBuf, null).toString('hex'); } extractCapabilities(data) { const out = new Set(); const strings = data.toString('latin1').match(/[\x20-\x7E]{4,}/g) || []; strings.forEach((s) => { if (s.includes('pnrv') || s.includes('dnet') || s.includes('sipr') || s.includes('lpcJ') || s.includes('cook') || s.includes('WinNT_')) { const clean = s.trim().replace(/^[^A-Za-z0-9]+/, '').replace(/[^A-Za-z0-9_\-\.]+$/, ''); if (clean.length > 0) out.add(clean); } }); return Array.from(out).slice(0, 20); } parsePnaMessage(data) { const pnaOffset = data.indexOf(Buffer.from('PNA\x00\x0a', 'latin1')); if (pnaOffset < 0) return null; const fields = []; let offset = pnaOffset + 5; // Phase 1: TLV fields (u16 tag, u16 len, value) until we hit the // special 'tag 0' end-of-TLV sentinel. while (offset + 4 <= data.length) { const fieldId = data.readUInt16BE(offset); // Tag 0 is the 'masked client time' field. Per WebTV ROM // disassembly of Progressive Networks' pn_net::server_hello // this tag has NO length word — just a raw 4-byte value of // `time() ^ 0x67E32B93`. The server MUST echo the un-masked // value as its 4 challenge bytes or pn_net::hello_state will // close the TCP connection with error 34. if (fieldId === 0 && offset + 6 <= data.length) { const value = data.slice(offset + 2, offset + 6); fields.push({ id: 0, len: 4, value, implicitLen: true }); offset += 6; break; // tag 0 is always last in TLV phase } const fieldLen = data.readUInt16BE(offset + 2); offset += 4; if (fieldLen > 1024 || offset + fieldLen > data.length) { // Unparseable TLV entry — skip 1 byte from the field start and retry. offset -= 3; continue; } const value = data.slice(offset, offset + fieldLen); fields.push({ id: fieldId, len: fieldLen, value }); offset += fieldLen; if (fieldId === 11 && fieldLen === 0) { // End-of-header marker in older (non-tag-0) captures. return fields; } } // Phase 2: ASCII-marker section (single-byte marker, u16 BE length, // value). Known markers observed in captures & ROM disasm: // 'c' (0x63) — User-Agent string // 'l' (0x6c) — (always len 0 in WebTV PNM) // 'R' (0x52) — requested resource path (media filename) // 'y' (0x79) — end-of-request terminator // We fold these into the same `fields` array using the marker byte // as the id so callers that look up id === 82 etc. still work. while (offset < data.length) { const marker = data[offset]; if (marker === 0x79) { // 'y' terminator — optionally consumes nothing else. fields.push({ id: 0x79, len: 0, value: Buffer.alloc(0), asciiMarker: true }); offset += 1; break; } if (offset + 3 > data.length) break; const valLen = data.readUInt16BE(offset + 1); if (valLen > 1024 || offset + 3 + valLen > data.length) break; const value = data.slice(offset + 3, offset + 3 + valLen); fields.push({ id: marker, len: valLen, value, asciiMarker: true }); offset += 3 + valLen; } return fields; } } module.exports = WTVPNM;