From f06e7e9bd38eaf3381cc864e3bf2f86108d46f48 Mon Sep 17 00:00:00 2001 From: zefie Date: Tue, 21 Apr 2026 18:15:37 -0400 Subject: [PATCH] improve compatibility of pnm server with RA3, RA5, and non-20.7k G2 --- zefie_wtvp_minisrv/includes/classes/WTVPNM.js | 896 +++++++++++++++--- zefie_wtvp_minisrv/includes/config.json | 2 +- zefie_wtvp_minisrv/realaudio3.pcap | Bin 0 -> 33650 bytes 3 files changed, 771 insertions(+), 127 deletions(-) create mode 100644 zefie_wtvp_minisrv/realaudio3.pcap diff --git a/zefie_wtvp_minisrv/includes/classes/WTVPNM.js b/zefie_wtvp_minisrv/includes/classes/WTVPNM.js index 0b1013c4..e09115b5 100644 --- a/zefie_wtvp_minisrv/includes/classes/WTVPNM.js +++ b/zefie_wtvp_minisrv/includes/classes/WTVPNM.js @@ -2,7 +2,8 @@ // This server only supports UDP streams, so mplayer (and others that are TCP only) will not work // It does support seeking and pausing via the TCP control channel, but does not support bitrate switching or any of the // other advanced features of the RealServer protocol. It should be compatible with WebTV 2.5 and RP8 clients, but has only been tested with RP8. -// It also has only been tested with 20.7kbps Mono G2 files. It is also not compatible with live streams at this time. +// RealAudio 3, RealAudio 5, RealAudio G2 and RealAudio 8 (not WebTV compatible) files. +// It is also not compatible with live streams at this time. const net = require('net'); const fs = require('fs'); @@ -91,7 +92,12 @@ class WTVPNM { paused: false, // 'EOS' marker (single 0x45 byte) has been sent on TCP; prevent // duplicate sends if stream-complete fires more than once. - eosSent: false + eosSent: false, + // Per-session RDT wire profile selected from parsed media metadata + // (avg bitrate, etc.). Falls back to global defaults when unset. + rdtDataTypeLo: null, + rdtSyncType: null, + audioChannels: null }; this.sessions.set(socket, session); @@ -262,11 +268,11 @@ class WTVPNM { // 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 + // 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. @@ -292,7 +298,7 @@ class WTVPNM { } off += 1; } else if (op === 0x53) { - if (buf.length - off < 5) break; // need more data + if (buf.length - off < 5) break; const targetMs = buf.readUInt32BE(off + 1); this.debugLog('ctrl seek', session.id, `target=${targetMs}ms`); this.seekUdpStream(session, targetMs); @@ -306,9 +312,6 @@ class WTVPNM { this.debugLog('ctrl stats', session.id, `len=${slen}`, txt.slice(0, 120)); off += 3 + slen; } else { - // Some clients send opaque binary blobs during retune/teardown. - // Skip to the next known opcode in one step to avoid byte-by-byte - // desync spam and excessive parser churn. let nextKnown = -1; for (let i = off + 1; i < buf.length; i++) { if (knownOps.has(buf[i])) { @@ -331,7 +334,7 @@ class WTVPNM { off = nextKnown; } } - // Preserve any trailing incomplete command for next receive. + session.ctrlBuf = off < buf.length ? buf.slice(off) : Buffer.alloc(0); } @@ -339,7 +342,7 @@ class WTVPNM { if (!session || session.paused) return; session.paused = true; if (session.udpTimer) { - clearInterval(session.udpTimer); + clearTimeout(session.udpTimer); session.udpTimer = null; } this.debugLog('udp stream paused', session.id); @@ -348,9 +351,6 @@ class WTVPNM { 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(); @@ -370,6 +370,17 @@ class WTVPNM { if (frames[i].ts > targetMs) break; if (frames[i].flags & 0x02) idx = i; } + + // Guard: if seek is beyond the file, cap to the last frame + if (idx >= frames.length) { + this.debugLog('⚠️ seekUdpStream: seek target beyond file end', session.id, + `targetMs=${targetMs}ms`, + `calculated idx=${idx}`, + `frames.length=${frames.length}`, + `capping to last frame`); + idx = Math.max(0, frames.length - 1); + } + 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 @@ -379,6 +390,9 @@ class WTVPNM { // 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. + // Re-arm burst prefill on seek so older clients can re-lock decoder + // state quickly after timestamp discontinuities. + session.burstFramesSent = 0; session.eosSent = false; this.debugLog('udp stream seek', session.id, `target=${targetMs}ms`, @@ -431,14 +445,26 @@ class WTVPNM { } getRequestedMediaName(fields, rawData) { - if (!Array.isArray(fields) || fields.length === 0) return this.scanRawForMediaName(rawData); + if (!Array.isArray(fields) || fields.length === 0) { + this.debugLog('getRequestedMediaName: no fields, using scanRawForMediaName'); + 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'); + this.debugLog('getRequestedMediaName: found field 82', `len=${fileField.len}`, `raw=${raw.slice(0, 60)}`); const normalized = this.normalizeRequestedMediaPath(raw); - if (normalized) return normalized; + if (normalized) { + this.debugLog('getRequestedMediaName: field 82 normalized', normalized); + return normalized; + } + this.debugLog('getRequestedMediaName: field 82 normalized to null'); + } else { + this.debugLog('getRequestedMediaName: field 82 not found or empty', + `fields=${fields.length}`, + `field ids=[${fields.map(f => `${f?.id}`).join(',')}]`); } // Some clients may carry filename in another TLV field; scan all text values. @@ -447,20 +473,39 @@ class WTVPNM { 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) { + this.debugLog('getRequestedMediaName: found filename in field', `id=${field.id}`, `match=${match[1]}`); const normalized = this.normalizeRequestedMediaPath(match[1]); - if (normalized) return normalized; + if (normalized) { + this.debugLog('getRequestedMediaName: alt field normalized', normalized); + return normalized; + } } } // Fallback: scan raw data buffer for media filename pattern. + this.debugLog('getRequestedMediaName: no fields matched, using scanRawForMediaName'); return this.scanRawForMediaName(rawData); } scanRawForMediaName(rawData) { - if (!Buffer.isBuffer(rawData)) return null; + if (!Buffer.isBuffer(rawData)) { + this.debugLog('scanRawForMediaName: input not a buffer'); + 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 ? this.normalizeRequestedMediaPath(match[1]) : null; + if (match) { + this.debugLog('scanRawForMediaName: regex match found', `match=${match[1]}`); + const normalized = this.normalizeRequestedMediaPath(match[1]); + if (normalized) { + this.debugLog('scanRawForMediaName: normalized', normalized); + return normalized; + } + this.debugLog('scanRawForMediaName: regex match but normalized to null'); + } else { + this.debugLog('scanRawForMediaName: no regex match', `dataLen=${str.length}`, `preview=${str.slice(0, 100).replace(/[^\x20-\x7E]/g, '.')}`); + } + return null; } getClientChallenge(fields) { @@ -615,11 +660,74 @@ class WTVPNM { if (!session || session.mediaFrames) return; if (!session.mediaPath || !fs.existsSync(session.mediaPath)) { + this.debugLog('prepareMediaData: media path missing or not found', session.id, session.mediaPath); return; } try { const media = fs.readFileSync(session.mediaPath); + this.debugLog('prepareMediaData: loaded media file', session.id, `size=${media.length} bytes`); + + const classicRa = this.parseClassicRaHeader(media); + if (classicRa) { + session.avgBitRate = classicRa.avgBitRate; + session.audioChannels = classicRa.channels; + session.rdtPacketMode = 'classic-len'; + session.syncEvery = Number.isInteger(this.service_config.rdt_sync_every_classic) + ? Math.max(1, this.service_config.rdt_sync_every_classic) + : 8; + + const cfgDataTypeLo = Number.isInteger(this.service_config.rdt_data_type_lo) + ? (this.service_config.rdt_data_type_lo & 0xff) + : null; + const cfgSyncType = Number.isInteger(this.service_config.rdt_sync_type) + ? (this.service_config.rdt_sync_type & 0xffff) + : null; + + if (cfgDataTypeLo !== null && cfgSyncType !== null) { + session.rdtDataTypeLo = cfgDataTypeLo; + session.rdtSyncType = cfgSyncType; + } else { + const useLegacyProfile = classicRa.channels === 1 || classicRa.channels === null; + session.rdtDataTypeLo = useLegacyProfile ? 0x64 : 0x50; + session.rdtSyncType = 0x0455; + } + + const payload = media.subarray(classicRa.dataOffset); + const packetSize = Math.max(1, classicRa.packetSize); + let tsStepMs = Number.isInteger(this.service_config.classic_ra_frame_ms) + ? Math.max(1, this.service_config.classic_ra_frame_ms) + : (classicRa.frameMs > 0 + ? classicRa.frameMs + : Math.max(1, Math.round((packetSize * 8000) / Math.max(1, classicRa.avgBitRate)))); + + const frames = []; + let frameIdx = 0; + for (let o = 0; o < payload.length; o += packetSize) { + const end = Math.min(o + packetSize, payload.length); + const audio = payload.subarray(o, end); + frames.push({ + ts: frameIdx * tsStepMs, + flags: 0x0002, + audio + }); + frameIdx++; + } + + session.mediaFrames = frames; + session.mediaFrameIdx = 0; + session.frameMs = tsStepMs; // Store frame cadence for pacing + this.debugLog('prepareMediaData: classic RA parsed', session.id, + `codec=${classicRa.codec || 'unknown'}`, + `channels=${classicRa.channels || 'unknown'}`, + `packetSize=${packetSize}`, + `avgBitRate=${classicRa.avgBitRate}`, + `frames=${frames.length}`, + `tsStep=${tsStepMs}ms`, + `dataOffset=${classicRa.dataOffset}`, + `mode=${session.rdtDataTypeLo === 0x64 ? 'legacy' : 'realserver'}`); + return; + } // Read avgBitRate from the PROP chunk so we can pace UDP packets // correctly. Underpacing (> real bitrate ms) causes client-side @@ -633,6 +741,91 @@ class WTVPNM { session.avgBitRate = avgBitRate; this.debugLog('media avgBitRate', session.id, `${avgBitRate} bps`); } + } else { + this.debugLog('prepareMediaData: PROP chunk', session.id, propChunk ? `size=${propChunk.size}` : 'missing'); + } + + // Parse channel count from MDPR type-specific codec bytes. + // This avoids using filename/extension heuristics and gives us a + // format-driven selector for the on-wire RDT header profile. + let mdprChannels = null; + const mdprChunk = this.getRealMediaChunk(media, 'MDPR'); + if (mdprChunk && mdprChunk.size >= 48) { + try { + const mdpr = mdprChunk.chunk; + let mdprOff = 40; + if (mdprOff < mdpr.length) { + const nameLen = mdpr.readUInt8(mdprOff); + mdprOff += 1 + nameLen; + if (mdprOff < mdpr.length) { + const mimeLen = mdpr.readUInt8(mdprOff); + mdprOff += 1 + mimeLen; + if (mdprOff + 4 <= mdpr.length) { + const typeSpecificLen = mdpr.readUInt32BE(mdprOff); + mdprOff += 4; + if (mdprOff + typeSpecificLen <= mdpr.length) { + const tsd = mdpr.subarray(mdprOff, mdprOff + typeSpecificLen); + const channelOffsets = [60, 80]; + for (const cOff of channelOffsets) { + if (cOff + 2 > tsd.length) continue; + const channelCandidate = tsd.readUInt16BE(cOff); + if (channelCandidate === 1 || channelCandidate === 2) { + mdprChannels = channelCandidate; + break; + } + } + } + } + } + } + } catch (e) { + this.debugLog('prepareMediaData: MDPR channel parse failed', session.id, e.message); + } + } + session.audioChannels = mdprChannels; + + // Select an RDT header profile from parsed stream metadata. + // Stereo (2ch) needs the RealServer-like profile from capture, + // while mono (1ch) keeps the legacy profile. + const cfgDataTypeLo = Number.isInteger(this.service_config.rdt_data_type_lo) + ? (this.service_config.rdt_data_type_lo & 0xff) + : null; + const cfgSyncType = Number.isInteger(this.service_config.rdt_sync_type) + ? (this.service_config.rdt_sync_type & 0xffff) + : null; + + if (cfgDataTypeLo !== null && cfgSyncType !== null) { + session.rdtDataTypeLo = cfgDataTypeLo; + session.rdtSyncType = cfgSyncType; + this.debugLog('rdt profile fixed by config', session.id, + `dataTypeLo=0x${session.rdtDataTypeLo.toString(16).padStart(2, '0')}`, + `syncType=0x${session.rdtSyncType.toString(16).padStart(4, '0')}`); + } else if (mdprChannels === 1 || mdprChannels === 2) { + const useLegacyProfile = mdprChannels === 1; + session.rdtDataTypeLo = useLegacyProfile ? 0x64 : 0x50; + session.rdtSyncType = useLegacyProfile ? 0x0477 : 0x04ba; + this.debugLog('rdt profile from MDPR channels', session.id, + `channels=${mdprChannels}`, + useLegacyProfile ? 'mode=legacy' : 'mode=realserver', + `dataTypeLo=0x${session.rdtDataTypeLo.toString(16).padStart(2, '0')}`, + `syncType=0x${session.rdtSyncType.toString(16).padStart(4, '0')}`); + } else { + const threshold = Number.isInteger(this.service_config.rdt_stereo_bitrate_threshold) + ? this.service_config.rdt_stereo_bitrate_threshold + : 30000; + const useLegacyProfile = Number.isFinite(session.avgBitRate) && session.avgBitRate > 0 + ? session.avgBitRate < threshold + : false; + + session.rdtDataTypeLo = useLegacyProfile ? 0x64 : 0x50; + session.rdtSyncType = useLegacyProfile ? 0x0477 : 0x04ba; + this.debugLog('rdt profile auto', session.id, + `channels=${mdprChannels === null ? 'unknown' : mdprChannels}`, + `avgBitRate=${session.avgBitRate || 'unknown'}`, + `threshold=${threshold}`, + useLegacyProfile ? 'mode=legacy' : 'mode=realserver', + `dataTypeLo=0x${session.rdtDataTypeLo.toString(16).padStart(2, '0')}`, + `syncType=0x${session.rdtSyncType.toString(16).padStart(4, '0')}`); } // Parse DATA chunk records. RA v4 DATA chunk: @@ -643,30 +836,100 @@ class WTVPNM { // 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); + this.debugLog('media DATA chunk missing or too small', session.id, dataChunk ? `size=${dataChunk.size}` : 'missing'); return; } - const numPkts = media.readUInt32BE(dataChunk.offset + 10); + + const dataOffset = dataChunk.offset; + const dataSize = dataChunk.size; + const chunkVersion = media.readUInt16BE(dataOffset + 8); + const numPkts = media.readUInt32BE(dataOffset + 10); + const nextDataOfs = media.readUInt32BE(dataOffset + 14); + + this.debugLog('prepareMediaData: DATA chunk header', session.id, + `ver=${chunkVersion}`, `numPkts=${numPkts}`, `nextOfs=${nextDataOfs}`, `chunkSize=${dataSize}`); + const frames = []; - let o = dataChunk.offset + 18; - const end = dataChunk.offset + dataChunk.size; + let o = dataOffset + 18; + const end = dataOffset + dataSize; + let frameIdx = 0; + while (o + 12 <= end && frames.length < numPkts) { + const recVer = media.readUInt16BE(o); const len = media.readUInt16BE(o + 2); - if (len < 12 || o + len > end) break; + const stream = media.readUInt16BE(o + 4); const ts = media.readUInt32BE(o + 6); const flags = media.readUInt16BE(o + 10); + + if (len < 12 || o + len > end) { + this.debugLog('prepareMediaData: frame parse stop', session.id, + `frame=${frameIdx}`, `len=${len}`, `o+len=${o + len}`, `end=${end}`); + break; + } + const audio = media.slice(o + 12, o + len); frames.push({ ts, flags, audio }); + + if (frameIdx < 5) { + const frameHex = media.slice(o, Math.min(o + 32, end)).toString('hex'); + const audioHex = audio.slice(0, Math.min(16, audio.length)).toString('hex'); + const audioHash = crypto.createHash('sha1').update(audio).digest('hex').slice(0, 12); + const prevAudio = frameIdx > 0 ? frames[frameIdx - 1]?.audio : null; + const sameAsPrev = !!prevAudio && prevAudio.length === audio.length && prevAudio.equals(audio); + this.debugLog('prepareMediaData: frame', session.id, + `idx=${frameIdx}`, `offset=${o}`, `len=${len}`, `audioLen=${audio.length}`, + `frameHex=${frameHex}`, `audioHex=${audioHex}`, + `audioSha1=${audioHash}`, `sameAsPrev=${sameAsPrev}`); + + // Check if audio is all zeros + let isAllZero = audio.length > 0; + for (let i = 0; i < Math.min(100, audio.length); i++) { + if (audio[i] !== 0) { + isAllZero = false; + break; + } + } + if (isAllZero) { + this.debugLog('⚠️ prepareMediaData: frame audio is ALL ZEROS!', session.id, `idx=${frameIdx}`, `audioLen=${audio.length}`); + } + } + o += len; + frameIdx++; } + + const timestampSample = frames.slice(0, Math.min(frames.length, 5)).map((frame) => frame.ts); + const hasUsefulInitialTimestamps = timestampSample.length > 1 + && timestampSample.every((ts, idx) => idx === 0 || ts > timestampSample[idx - 1]); + if (!hasUsefulInitialTimestamps && frames.length > 0) { + const firstAudioLen = frames[0].audio?.length || 0; + const syntheticStepMs = session.avgBitRate > 0 && firstAudioLen > 0 + ? Math.max(1, Math.round((firstAudioLen * 8000) / session.avgBitRate)) + : 232; + for (let i = 0; i < frames.length; i++) { + frames[i].ts = i * syntheticStepMs; + } + this.debugLog('prepareMediaData: synthesized timestamps', session.id, + `nativeSample=[${timestampSample.join(',')}]`, + `step=${syntheticStepMs}ms`, + `count=${frames.length}`, + `lastTs=${frames[frames.length - 1].ts}`); + } + session.mediaFrames = frames; session.mediaFrameIdx = 0; + const lastFrame = frames.length > 0 ? frames[frames.length - 1] : null; + this.debugLog('prepareMediaData: complete', session.id, + `frames=${frames.length}`, + `duration=${lastFrame?.ts || 0}ms`); + this.debugLog('media frames parsed', session.id, `count=${frames.length}`, `expected=${numPkts}`, - `firstLen=${frames[0]?.audio.length}`); + `firstLen=${frames[0]?.audio.length}`, + `lastLen=${frames[frames.length-1]?.audio.length}`); } catch (e) { - this.debugLog('media payload load failed', session.id, e.message); + this.debugLog('media payload load failed', session.id, e.message, e.stack); } } @@ -681,16 +944,47 @@ class WTVPNM { // 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 syncEvery = Number.isInteger(session?.syncEvery) && session.syncEvery > 0 + ? session.syncEvery + : 5; + + // Compute actual average frame siz + // e from all frames + let totalAudioBytes = 0; + if (session.mediaFrames && session.mediaFrames.length > 0) { + for (const frame of session.mediaFrames) { + totalAudioBytes += frame.audio?.length || 0; + } + } + const frameCount = session.mediaFrames?.length || 1; + const bodyLen = frameCount > 0 ? totalAudioBytes / frameCount : 600; + + // Account for all overhead in actual UDP packets sent: + // - 12-byte RDT header per packet + // - 10-byte sync frame every syncEvery packets + const rdtHeaderSize = 12; + const avgBytesPerPacket = rdtHeaderSize + bodyLen + (10 / syncEvery); + + // Compute pacing interval from avgBitRate. + // Use the bitrate directly without buffer since avgBitRate is computed + // from actual file payload and duration. + // On Windows, setTimeout fires slower than requested due to timer + // granularity. Compensation scales with interval: shorter intervals + // need more compensation. Formula: 1 - (fixedOverhead / calculatedInterval) + let intervalMs; + if (session.avgBitRate > 0) { + intervalMs = (avgBytesPerPacket * 8000) / session.avgBitRate; + // Adaptive Windows timer compensation: 13ms is empirical fixed overhead + const compensation = Math.max(0.90, 1 - (13 / intervalMs)); + intervalMs *= compensation; + } else { + intervalMs = 220; + } const startDelayMs = 72; - const redundantSeqs = [0, 1]; + const redundantSeqs = Array.isArray(this.service_config.redundant_initial_seqs) + ? this.service_config.redundant_initial_seqs.filter((value) => Number.isInteger(value) && value >= 0) + : []; // Pre-start burst: send the first N ms of audio at double rate to // pre-fill the client buffer before settling into normal pacing. @@ -722,7 +1016,7 @@ class WTVPNM { // 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) + // explicitly so any configured initial retransmit (and seeks) // can pair any wall-seq with any frame index. const sendPacket = (seq, frame) => { if (socket.destroyed || !session.udpSocket) return; @@ -797,10 +1091,8 @@ class WTVPNM { 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. + // Optional initial redundant burst for clients that benefit from + // replaying the first packets before the normal interval starts. const frames = session.mediaFrames || []; for (const s of redundantSeqs) { const f = frames[s]; @@ -820,8 +1112,11 @@ class WTVPNM { // Captured example at seq 4: `00 0a 04 77 62 00 00 0a dc 00`. buildSyncFrame(session, seq) { const out = Buffer.alloc(10); + const syncType = Number.isInteger(this.service_config.rdt_sync_type) + ? (this.service_config.rdt_sync_type & 0xffff) + : (Number.isInteger(session?.rdtSyncType) ? (session.rdtSyncType & 0xffff) : 0x04ba); out.writeUInt16BE(0x000a, 0); // length - out.writeUInt16BE(0x0477, 2); // type (latency report) + out.writeUInt16BE(syncType, 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, @@ -837,6 +1132,10 @@ class WTVPNM { buildMediaPayload(session, pSeq, pFrame) { const seq = pSeq !== undefined ? pSeq : (session ? session.udpSeq || 0 : 0); if (session && pSeq === undefined) session.udpSeq = seq + 1; + const packetMode = session?.rdtPacketMode || 'rdt'; + const dataTypeLo = Number.isInteger(this.service_config.rdt_data_type_lo) + ? (this.service_config.rdt_data_type_lo & 0xff) + : (Number.isInteger(session?.rdtDataTypeLo) ? (session.rdtDataTypeLo & 0xff) : 0x50); // Pick the frame: caller can pass one explicitly (interval / burst / // seek path) or fall back to indexing by seq against mediaFrames. @@ -859,14 +1158,25 @@ class WTVPNM { // 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); + 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; + if (packetMode === 'classic-len') { + out.writeUInt16BE(out.length & 0xffff, 0); + out.writeUInt16BE(seq & 0xffff, 2); + out[4] = 0x5a; out[5] = b5; + out.writeUInt32BE(0, 6); + out.writeUInt16BE(0, 10); + } else { + out[0] = 0x02; out[1] = dataTypeLo; + out.writeUInt16BE(seq & 0xffff, 2); + out[4] = 0x5a; out[5] = b5; + } + if (this.getDebugEnabled() && seq < 3) { + this.debugLog('buildMediaPayload: no frame', `seq=${seq}`, `sessionSeq=${session?.mediaFrameIdx}`); + } return out; } @@ -875,22 +1185,36 @@ class WTVPNM { // 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) + // [0..1] 02 xx — packet type/flags (default 0x50) // [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; + if (packetMode === 'classic-len') { + out.writeUInt16BE(out.length & 0xffff, 0); + out.writeUInt16BE(seq & 0xffff, 2); + out[4] = 0x5a; + out[5] = b5; + } else { + out[0] = 0x02; + out[1] = dataTypeLo; + 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); + + if (this.getDebugEnabled() && seq < 3) { + this.debugLog('buildMediaPayload: frame', `seq=${seq}`, `ts=${frame.ts}`, + `flags=0x${frame.flags.toString(16)}`, `audioLen=${audioLen}`, + `audioHex=${frame.audio.slice(0, 16).toString('hex')}`); + } + return out; } @@ -973,6 +1297,7 @@ class WTVPNM { offset += rmfSize; let chunksFound = []; + const descriptorChunks = new Map(); while (offset < raBuffer.length) { const tag = raBuffer.toString('latin1', offset, offset + 4); const size = raBuffer.readUInt32BE(offset + 4); @@ -1039,72 +1364,102 @@ class WTVPNM { } } - // Clean MDPR chunk by ensuring string fields are null-terminated and codec is injected + // Preserve the source MDPR unless a specific override is requested. 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); + const mdprFullHex = chunkData.toString('hex'); + this.debugLog('buildDescriptor MDPR full hex ENTIRE', session?.id || '?', + `len=${chunkData.length}`, `hex=${mdprFullHex}`); + + // RealMedia MDPR structure (after 8-byte "MDPR"+size header): + // 8-9: object version (u16) + // 10-11: stream number (u16) + // 12-15: max bitrate (u32) + // 16-19: avg bitrate (u32) + // 20-23: max packet size (u32) + // 24-27: avg packet size (u32) + // 28-31: start time (u32) + // 32-35: preroll (u32) + // 36-39: duration (u32) + const mdprObjVer = chunkData.readUInt16BE(8); + const mdprStreamNum = chunkData.readUInt16BE(10); + const mdprMaxBitrate = chunkData.readUInt32BE(12); + const mdprAvgBitrate = chunkData.readUInt32BE(16); + const mdprMaxPacketSize = chunkData.readUInt32BE(20); + const mdprAvgPacketSize = chunkData.readUInt32BE(24); + const mdprStartTime = chunkData.readUInt32BE(28); + const mdprPreroll = chunkData.readUInt32BE(32); + const mdprDuration = chunkData.readUInt32BE(36); + + this.debugLog('buildDescriptor MDPR before cleanup', session?.id || '?', + `objVer=${mdprObjVer}`, + `streamNum=${mdprStreamNum}`, + `maxBr=${mdprMaxBitrate} bps`, + `avgBr=${mdprAvgBitrate} bps`, + `maxPkt=${mdprMaxPacketSize} B`, + `avgPkt=${mdprAvgPacketSize} B`, + `start=${mdprStartTime} ms`, + `preroll=${mdprPreroll} ms`, + `duration=${mdprDuration} ms`, + `mdprLen=${chunkData.length}`); + + const codecCfg = this.service_config.mdpr_codec; + if (typeof codecCfg === 'string' && codecCfg.length === 4) { + const newChunk = Buffer.from(chunkData); + Buffer.from(codecCfg, 'ascii').copy(newChunk, 28); + chunkData = newChunk; + this.debugLog('buildDescriptor MDPR codec override', session?.id || '?', `codec=${codecCfg}`); + } else { + this.debugLog('buildDescriptor MDPR preserved', session?.id || '?', 'using source chunk without rewrite'); } - // 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; + // Normalize string payload shape to match RealServer: + // StreamName and MIME are length-prefixed fields in the MDPR tail. + // RealServer includes explicit trailing NUL bytes in both fields, + // which increases MDPR size (commonly 0xA4 -> 0xA6). + let off = 40; + if (off + 1 < chunkData.length) { + const nameL = chunkData.readUInt8(off); + const nameStart = off + 1; + const nameEnd = nameStart + nameL; + if (nameEnd < chunkData.length) { + off = nameEnd; + const mimeL = chunkData.readUInt8(off); + const mimeStart = off + 1; + const mimeEnd = mimeStart + mimeL; + if (mimeEnd <= chunkData.length) { + const nameStr = chunkData.subarray(nameStart, nameEnd); + const mimeStr = chunkData.subarray(mimeStart, mimeEnd); + const needNameNull = nameL > 0 && nameStr[nameL - 1] !== 0; + const needMimeNull = mimeL > 0 && mimeStr[mimeL - 1] !== 0; - const needNameNull = nameStr[nameL - 1] !== 0; - const needMimeNull = mimeStr[mimeL - 1] !== 0; + if (needNameNull || needMimeNull) { + const finalNameL = nameL + (needNameNull ? 1 : 0); + const finalMimeL = mimeL + (needMimeNull ? 1 : 0); + 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 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; + const head = chunkData.subarray(0, 40); + const tail = chunkData.subarray(mimeEnd); + const newChunk = Buffer.concat([head, strBuf, tail]); + finalSize = newChunk.length; + newChunk.writeUInt32BE(finalSize, 4); + chunkData = newChunk; + this.debugLog('buildDescriptor MDPR string normalize', session?.id || '?', + `newLen=${finalSize}`, + `nameL=${finalNameL}`, + `mimeL=${finalMimeL}`); + } + } + } + } } catch (e) { this.debugLog('buildDescriptor MDPR rewrite error', e.message); } @@ -1114,21 +1469,44 @@ class WTVPNM { const wrap = Buffer.alloc(3); wrap[0] = 0x72; wrap.writeUInt16BE(finalSize & 0xFFFF, 1); - outChunks.push(wrap); - outChunks.push(chunkData); + descriptorChunks.set(tag, [wrap, chunkData]); } if (tag === 'DATA') break; // stop parsing once media data starts offset += size; } + + for (const tag of ['PROP', 'CONT', 'MDPR']) { + const chunkParts = descriptorChunks.get(tag); + if (chunkParts) outChunks.push(...chunkParts); + } // The real server appends a 5-byte 0x4C packet EOF marker before the session token tag outChunks.push(Buffer.from('4c00000000', 'hex')); + } else if (raBuffer) { + const classicRa = this.parseClassicRaHeader(raBuffer); + if (classicRa) { + const descriptorChunks = this.buildClassicRaDescriptorChunks(classicRa, raBuffer); + for (const tag of ['PROP', 'CONT', 'MDPR']) { + const chunkData = descriptorChunks[tag]; + if (!chunkData) continue; + const wrap = Buffer.alloc(3); + wrap[0] = 0x72; + wrap.writeUInt16BE(chunkData.length & 0xffff, 1); + outChunks.push(wrap, chunkData); + } + outChunks.push(Buffer.from('4c00000000', 'hex')); + this.debugLog('buildDescriptor: classic RA fallback', session?.id || '?', + `codec=${classicRa.codec || 'unknown'}`, + `channels=${classicRa.channels || 'unknown'}`, + `packet=${classicRa.packetSize}`, + `dataOffset=${classicRa.dataOffset}`); + } else { + throw(new Error('Media file missing or unsupported format; expected .RMF or classic .ra')); + } } 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')); + throw(new Error('Media file missing or invalid .RMF format; cannot build descriptor')); } // Include the session token as tag 0x23 [size_16 = 64] @@ -1158,6 +1536,251 @@ class WTVPNM { + parseClassicRaHeader(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length < 64) return null; + if (!(buffer[0] === 0x2e && buffer[1] === 0x72 && buffer[2] === 0x61 && buffer[3] === 0xfd)) return null; + + const header = { + version: buffer.readUInt16BE(4), + fourcc: buffer.toString('latin1', 8, 12), + packetSize: null, + channels: null, + sampleRate: null, + sampleSize: null, + interleaver: null, + codec: null, + title: null, + dataOffset: 0, + avgBitRate: 20000, + frameMs: 232, + durationMs: 0 + }; + + const packetA = buffer.readUInt16BE(42); + const packetB = buffer.readUInt16BE(26); + header.packetSize = (packetA > 0 && packetA <= 2000) ? packetA : ((packetB > 0 && packetB <= 2000) ? packetB : 600); + + const channels = buffer.readUInt16BE(54); + header.channels = (channels === 1 || channels === 2) ? channels : null; + header.sampleRate = buffer.readUInt16BE(48); + header.sampleSize = buffer.readUInt16BE(52); + + // Duration is at a fixed offset in the RA4 header (offset 32) + const rawDuration = buffer.readUInt32BE(32); + if (rawDuration > 0 && rawDuration < 86_400_000) { + header.durationMs = rawDuration; + } + + let off = 56; + // interleaver: uint8-length pascal string + if (off < buffer.length) { + const ilen = buffer.readUInt8(off); off++; + if (ilen >= 1 && ilen <= 32 && off + ilen <= buffer.length) { + header.interleaver = buffer.subarray(off, off + ilen).toString('latin1'); + off += ilen; + } + } + // codec: uint8-length pascal string + if (off < buffer.length) { + const clen = buffer.readUInt8(off); off++; + if (clen >= 1 && clen <= 32 && off + clen <= buffer.length) { + header.codec = buffer.subarray(off, off + clen).toString('latin1'); + off += clen; + } + } + // After codec, classic RA4 commonly stores: + // u16 aux/opaque marker, u16 titleLen, title bytes, optional NUL pad. + // Example from realaudio3.pcap: 00 02 00 14 "Dialing WebTV (Mono)" 00 00 00 + if (off + 4 <= buffer.length) { + off += 2; // aux/opaque marker (kept for alignment only) + const titleLen = buffer.readUInt16BE(off); off += 2; + if (titleLen > 0 && titleLen <= 255 && off + titleLen <= buffer.length) { + header.title = buffer.subarray(off, off + titleLen).toString('latin1').replace(/\x00+$/g, ''); + off += titleLen; + } else { + // Fallback for variants that use 8-bit title length + off -= 2; + if (off < buffer.length) { + const titleLen8 = buffer.readUInt8(off); off++; + if (titleLen8 > 0 && titleLen8 <= 255 && off + titleLen8 <= buffer.length) { + header.title = buffer.subarray(off, off + titleLen8).toString('latin1').replace(/\x00+$/g, ''); + off += titleLen8; + } + } + } + } + + // Some files pad with 1-4 NUL bytes before packetized data. + let nulPad = 0; + while (off < buffer.length && buffer[off] === 0x00 && nulPad < 4) { + off++; + nulPad++; + } + + header.dataOffset = Math.min(Math.max(off, 64), buffer.length); + + if (!(header.durationMs > 0 && header.durationMs < 86_400_000)) { + header.durationMs = 0; + } + + // Compute frame cadence from known profiles or header duration. + // Known profiles are preferred; header duration is only used if no profile applies. + if (header.codec === 'dnet' && header.dataOffset < buffer.length) { + const payloadBytes = buffer.length - header.dataOffset; + const packetCount = payloadBytes / header.packetSize; + + // Try known dnet profiles by (channels, packetSize) combinations + let profileFrameMs = null; + let profileChosen = false; + + if (header.channels === 1 && header.packetSize === 278) { + profileFrameMs = 139; // mono RA3/4 @ ~16kbps + // Always use this profile for mono 278; duration inference will correct if needed + header.frameMs = profileFrameMs; + profileChosen = true; + } else if (header.channels === 2 && header.packetSize === 480) { + // Multiple bitrates possible for stereo 480: + // Try 80kbps (frameMs=48) and 20kbps (frameMs=192) + const dur80k = packetCount * 48; + const dur20k = packetCount * 192; + const ratio80k = header.durationMs > 0 ? (header.durationMs / dur80k) : 0; + const ratio20k = header.durationMs > 0 ? (header.durationMs / dur20k) : 0; + + // Try to match header with a known profile + if (ratio80k >= 0.9 && ratio80k <= 1.1) { + header.frameMs = 48; + profileChosen = true; + } else if (ratio20k >= 0.9 && ratio20k <= 1.1) { + header.frameMs = 192; + profileChosen = true; + } else if (!header.durationMs) { + // No header duration: default to 20kbps + header.frameMs = 192; + profileChosen = true; + } else { + // Header duration doesn't match either profile closely. + // Don't trust the header; pick profile based on file size. + // Larger files tend to be 80kbps; smaller files 20kbps. + // Use ~600KB as threshold: 20kbps*240s ≈ 600KB + if (payloadBytes > 1_000_000) { + header.frameMs = 48; // 80kbps profile + } else { + header.frameMs = 192; // 20kbps profile + } + profileChosen = true; + } + } else if (header.channels === 1 && header.packetSize === 384) { + profileFrameMs = 95; // RA5 @ ~32kbps + // Always use this profile for mono 384; duration inference will correct if needed + header.frameMs = profileFrameMs; + profileChosen = true; + } + + // If no profile matched and we didn't already set frameMs, compute from header + if (!profileChosen && header.durationMs > 0) { + header.frameMs = Math.max(1, Math.round(header.durationMs / packetCount)); + } + } + + // Generic cadence fallback when still no valid frameMs (non-dnet codec) + if (!(header.frameMs > 0) || header.frameMs === 232) { + header.frameMs = Math.max(1, Math.round((header.packetSize * 8000) / Math.max(1, header.avgBitRate))); + } + + // Infer duration from packet count and cadence. This corrects header duration + // when it's mismatched to actual packet timing. + if (header.dataOffset < buffer.length && header.packetSize > 0 && header.frameMs > 0) { + const payloadBytes = buffer.length - header.dataOffset; + const packetCount = Math.ceil(payloadBytes / header.packetSize); + const inferredDurationMs = packetCount * header.frameMs; + if (inferredDurationMs > 0 && inferredDurationMs < 86_400_000) { + const hasDuration = header.durationMs > 0; + const ratio = hasDuration ? (header.durationMs / inferredDurationMs) : 0; + if (!hasDuration || ratio < 0.75 || ratio > 1.25) { + header.durationMs = inferredDurationMs; + } + } + } + + // Now compute bitrate from corrected duration and actual file payload. + if (header.durationMs > 0 && header.dataOffset < buffer.length) { + const payloadBytes = buffer.length - header.dataOffset; + const computedAvg = Math.round((payloadBytes * 8 * 1000) / header.durationMs); + if (computedAvg >= 2000 && computedAvg <= 256000) { + header.avgBitRate = computedAvg; + } + } + + if (Number.isInteger(this.service_config.classic_ra_avg_bitrate)) { + header.avgBitRate = Math.max(1000, this.service_config.classic_ra_avg_bitrate); + } + + return header; + } + + buildClassicRaDescriptorChunks(classicRa, buffer) { + const avgBr = classicRa.avgBitRate || 20000; + const maxBr = avgBr; + const pkt = classicRa.packetSize || 600; + const prerollMs = 2000; + const durationMs = classicRa.durationMs || 0; + + const prop = Buffer.alloc(50); + prop.write('PROP', 0, 'ascii'); + prop.writeUInt32BE(50, 4); + prop.writeUInt16BE(0, 8); + prop.writeUInt32BE(maxBr >>> 0, 10); + prop.writeUInt32BE(avgBr >>> 0, 14); + prop.writeUInt32BE(pkt >>> 0, 18); + prop.writeUInt32BE(pkt >>> 0, 22); + prop.writeUInt32BE(0, 26); + prop.writeUInt32BE(0, 30); + prop.writeUInt32BE(durationMs >>> 0, 34); + prop.writeUInt32BE(prerollMs >>> 0, 38); + prop.writeUInt32BE(0, 42); + prop.writeUInt16BE(1, 46); + prop.writeUInt16BE(9, 48); + + const titleBuf = Buffer.from(classicRa.title || '', 'latin1'); + const contSize = 10 + 2 + titleBuf.length + 2 + 0 + 2 + 0 + 2 + 0; + const cont = Buffer.alloc(contSize); + cont.write('CONT', 0, 'ascii'); + cont.writeUInt32BE(contSize, 4); + cont.writeUInt16BE(0, 8); + let cOff = 10; + cont.writeUInt16BE(titleBuf.length, cOff); cOff += 2; + titleBuf.copy(cont, cOff); cOff += titleBuf.length; + cont.writeUInt16BE(0, cOff); cOff += 2; + cont.writeUInt16BE(0, cOff); cOff += 2; + cont.writeUInt16BE(0, cOff); + + const nameBuf = Buffer.from('Audio Stream\x00', 'latin1'); + const mimeBuf = Buffer.from('audio/x-pn-realaudio\x00', 'latin1'); + const tsd = buffer.subarray(0, Math.max(1, classicRa.dataOffset)); + const mdprSize = 40 + 1 + nameBuf.length + 1 + mimeBuf.length + 4 + tsd.length; + const mdpr = Buffer.alloc(mdprSize); + mdpr.write('MDPR', 0, 'ascii'); + mdpr.writeUInt32BE(mdprSize, 4); + mdpr.writeUInt16BE(0, 8); + mdpr.writeUInt16BE(0, 10); + mdpr.writeUInt32BE(maxBr >>> 0, 12); + mdpr.writeUInt32BE(avgBr >>> 0, 16); + mdpr.writeUInt32BE(pkt >>> 0, 20); + mdpr.writeUInt32BE(pkt >>> 0, 24); + mdpr.writeUInt32BE(0, 28); + mdpr.writeUInt32BE(prerollMs >>> 0, 32); + mdpr.writeUInt32BE(durationMs >>> 0, 36); + let mOff = 40; + mdpr.writeUInt8(nameBuf.length, mOff); mOff += 1; + nameBuf.copy(mdpr, mOff); mOff += nameBuf.length; + mdpr.writeUInt8(mimeBuf.length, mOff); mOff += 1; + mimeBuf.copy(mdpr, mOff); mOff += mimeBuf.length; + mdpr.writeUInt32BE(tsd.length >>> 0, mOff); mOff += 4; + tsd.copy(mdpr, mOff); + + return { PROP: prop, CONT: cont, MDPR: mdpr }; + } + getRealMediaChunk(buffer, tag) { if (!buffer || !tag || tag.length !== 4) return null; const needle = Buffer.from(tag, 'ascii'); @@ -1280,9 +1903,13 @@ class WTVPNM { const fields = []; let offset = pnaOffset + 5; + const dbg = this.getDebugEnabled(); // Phase 1: TLV fields (u16 tag, u16 len, value) until we hit the - // special 'tag 0' end-of-TLV sentinel. + // special 'tag 0' end-of-TLV sentinel OR field 11 with len 0. + // NOTE: field 11 with len 0 is NOT a guaranteed end marker; + // phase 2 may still follow. We scan TLV until we can't parse more, + // then unconditionally proceed to phase 2. while (offset + 4 <= data.length) { const fieldId = data.readUInt16BE(offset); @@ -1296,15 +1923,17 @@ class WTVPNM { const value = data.slice(offset + 2, offset + 6); fields.push({ id: 0, len: 4, value, implicitLen: true }); offset += 6; + if (dbg) this.debugLog('pna phase 1 end at offset', offset, 'field 0 (special)'); 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; + // Unparseable TLV entry — likely end of phase 1, break to phase 2 + if (dbg) this.debugLog('pna tlv end (unparse)', `id=0x${fieldId.toString(16)}`, `len=${fieldLen}`, `offset=${offset}`); + offset -= 4; // Back up to try as phase 2 + break; } const value = data.slice(offset, offset + fieldLen); @@ -1315,12 +1944,12 @@ class WTVPNM { }); offset += fieldLen; - if (fieldId === 11 && fieldLen === 0) { - // End-of-header marker in older (non-tag-0) captures. - return fields; - } + // Field 11 with len 0 is just a marker, doesn't guarantee end of phase 1 + // (phase 2 may still follow). Only break explicitly on tag 0. } + if (dbg) this.debugLog('pna phase 1 end at offset', offset, `phase1_fields=${fields.length}`, `remaining=${data.length - offset} bytes`); + // Phase 2: ASCII-marker section (single-byte marker, u16 BE length, // value). Known markers observed in captures & ROM disasm: // 'c' (0x63) — User-Agent string @@ -1329,22 +1958,37 @@ class WTVPNM { // '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. + const phase2Start = offset; + let phase2Count = 0; while (offset < data.length) { const marker = data[offset]; + const markerChar = String.fromCharCode(marker); if (marker === 0x79) { // 'y' terminator — optionally consumes nothing else. fields.push({ id: 0x79, len: 0, value: Buffer.alloc(0), asciiMarker: true }); offset += 1; + if (dbg) this.debugLog('pna phase 2 found terminator y at offset', offset - 1); + break; + } + if (offset + 3 > data.length) { + if (dbg) this.debugLog('pna phase 2 break: not enough data', `offset=${offset}`, `need 3, have=${data.length - offset}`); break; } - if (offset + 3 > data.length) break; const valLen = data.readUInt16BE(offset + 1); - if (valLen > 1024 || offset + 3 + valLen > data.length) break; + if (valLen > 1024 || offset + 3 + valLen > data.length) { + if (dbg) this.debugLog('pna phase 2 break: bad valLen', `marker=0x${marker.toString(16)}(${markerChar})`, `valLen=${valLen}`, `at offset=${offset}`); + break; + } const value = data.slice(offset + 3, offset + 3 + valLen); + const valuePreview = value.toString('latin1').slice(0, 60).replace(/[^\x20-\x7E]/g, '.'); fields.push({ id: marker, len: valLen, value, asciiMarker: true }); + phase2Count++; + if (dbg) this.debugLog('pna phase 2 marker', `0x${marker.toString(16)}(${markerChar})`, `len=${valLen}`, `val=${valuePreview}`); offset += 3 + valLen; } + if (dbg) this.debugLog('pna phase 2 complete', `start_offset=${phase2Start}`, `end_offset=${offset}`, `phase2_markers=${phase2Count}`, `total_fields=${fields.length}`); + return fields; } diff --git a/zefie_wtvp_minisrv/includes/config.json b/zefie_wtvp_minisrv/includes/config.json index 89447d9f..9fb48db5 100644 --- a/zefie_wtvp_minisrv/includes/config.json +++ b/zefie_wtvp_minisrv/includes/config.json @@ -401,7 +401,7 @@ "protocol_handler": "pnm", "descriptor_after_hello_ms": 85, "burst_prestart_ms": 5000, - "debug": true, + "debug": false, "allow_indexing": true } }, diff --git a/zefie_wtvp_minisrv/realaudio3.pcap b/zefie_wtvp_minisrv/realaudio3.pcap new file mode 100644 index 0000000000000000000000000000000000000000..bdb05005ff5a49fdc22e642594f3c76ea2d5eedb GIT binary patch literal 33650 zcma&u2V4_b_c;8accgcfjx?nuG(~z3Ae{h8hh!3ZQAEW?Q05B&Fw9teYe5L{fu zaOW}r`=Q(L1^~3w@4vq>114O665Rj*=ri8XV+8=Z&T=+p?q_htyaH{{O_9H&6;|B54B0ssysMo#u7;Akr-dlKT=0L2JMJ2c8EIaqT*n3G%`@#$imXp-`gr2>x{KD z^>YXT-GairtsSvWhE_H$Gv*x*2^|F1o882?~+hj5HHPB}OV@8ch?Bd)3H ztKzHTucD&jqoShbt?_$b|6t$nW1*2yARY0@@Tfl#yhFWxFgQ%)G35Z9cVL8$cz~+5 zwvS(cKlI=4UyL{w5$TUtj`k0SM*)Y|Qr+_K;UReC$YY^U7MvWEt&P-_{V)-rvI@#j zSqo*jWlx`wkVts%|HM*L(Lio_6yG3B=zsndp^8vdRny+O2Q(ZACIAztBd(!}R99Ei zg6qqp{Qps>L;veoe5CZJPR~LIH5^CQn&!Xt{_kUFkAX3K?7W)%$FWmFkA0+M$Mx%1 za_jFnos~4eXQ!@hysH?z_5(ytszsuP>$`2E=@2GNU zuyVM+H;($_e|Ew72M0z5K}Vz-E9$$6}tZ)6|p4s!w_S~#c=LS{g+O7VvL`}qwpFaft{0P6PdBZsD%ZVZl7 zQ2IaPKu3<`6!a=%V*s!MurUolE2;yDL9gHmUH}vICwf5K52=RGMh2+)`Dm(mYiX(Z z`e+kQ> z`+whNrryI$y@!cAT6NJSCVEqCxIKLqO5HKVpDl{xK1RD-U;-Uh; zpg<54gu?~~2KZt^apA#XAz@J=Vd3E+!Qqi$6g0sHVuC_2fDpiSgz5B3`)FC$jsi}x zWOoqiTLNEdhxr0ZsE}9;4(Gj3eXokRi@%Sf^FFn`2=P5eA^1>e((u9g>x!csEKJ1J z_NvI^0Kgt#iu8|&+#BwF%n+`xQ7D)H=^p-FUwiDT^r2kx`TnE680hg*SM9hqaO)5f z9OoM&6JP*6ZFu`1^N6ZlH8qag|BNHFfs4ueQxgEs92}7B&HUeiQ40l*41ZBuw>@;@ z05q^0@>`2c>MB&MoKUeIe699-V0WN6@ldhC+rI}!6KY3<2gWP>kAZPgpCB}_MJSoJ z%m9-}IQ;vzjMQY)+0p{+(8dXEJD`mp+PK02H9LD-=y(I5hXw#W_um_h0951927S?0 zul?sX{4+G9aDd0i*2WR~fd{_l_g6TOiZZsdhki)?eaHWZ1;>Qq(E$AaYYv2e%>qjD z5BRWx{;vTrISo^rWNp|I`ducc9Drt&_s9Wuev%y1K|^(spjqPt?CaQrE30KD%u)IKP{xTwzrBmLMs5F;fwTDM|i9I zX=;1>BenfheSHJG;k?R+uxy`K3N^3)m>l7}dO1UrEbv5owJR#)@W(Dqf;(16CTrS7e%q7Dx^5_%{b zC|B_IKT1eXECDJZZWbBnp=jZUBhHBG&?smz&`1IR8lDfJ`(xM!jO@%im6asc6r+A_ z#4WBB48`fKZ452y6>P+1ebLFJ7e9aNxzq9PHSTqEM%9K#^D4$B+++9EITu4r_BqjY zpUMfkORnjgP5XCnHdfmAJuGfORU9|A7f7bOlbW^%`7DX)z951aR6Wj#Cs}$FQu>9a zri$iupQtvP>maV#^@R`KRd5&0JSk|;h~Sv82=Fg9+)>wC_#|KGr9*yi_lt^w^jiiz z>r6KZnPm1GBd>CXy&6dr3=!<7py@jbO;g1@JS>={uQ?a%@asl5J8N<{@JNwOT&|&|B~fw08A(L7Zx2+dfNIs9ECOnx `&Zd zEM0)J)GhiuOCJ?oCU0KXJb&Ynw98?TwE49jJAGdoQ^5 zI7JABp!g`yP{{3-l&s`YR|GJPYwFf(ubQ<_m#ay>|FqcpL(5tGx`&8*B*8626&Xcz z7f8jh7Sd|kuqq>;488Dqxnbw7LlLu8!tPojQA!&~6vEmXMazC#djH9gB$t;9b<@Y` zO(zGtKO8RoBxKcfcUTDPTFh5L%;B>*Z|a81#OK>;R}#*D9x1{;M7=H7$JYv20j~L) z10&Wm%j4wt{SOK}M5jIp;cDFi@&rgPrpbrs$7txoql*$1r4@O?dUhbqq*|p8pO{AW zl`*&zwJC9xqzO~JT0Gh68Fe}y=GazurwFXeoW51pPzchcg>|7B2GZra^ILaSenj!- zrPs%YBb_Ttylb?ZNJDc%=j_7e4M#~PbG9R!_Cqsyw2nS18&Qu&pVS!m^Y43YeCO%I zp{4F}wFx;5Ftoyuggl3E_uP?aF~C%t`m|Nuk>kc>;X?xYQ=lPpNs!wG)y(XN{anP( zApyF#If&DW=PRNTCzXa|4~Wb(=9nBa_7$PKX(mYKl1sgPl`CI=z%^;enunp2FYig) z?PXP$Y0$kAtQjOC0Ble|U9e=?aE7&Snd(dB0C(RxE9J;0J5x(Kc7<^n#iy_i%pZ7Uz%BPLG_vq#}eInI0SPB(?=d?DPB@e)i&9EQ#pv zbZpN^0q~1~O~}o1~HJ6wXfb_$i+Pf8NuFxzeV%pX-r{%Rq_L ztp^DV{W5KxwwHeF^|m-MZC+A zg}LtOB^bGW{jp1P_ar8+Ka6oPYA?H++$}f9%)1A_^;I_Ae^~jbG;1iMD ztY$RU7E0TuuHfDv_sbRIlhB;0RtWHcaY;b;8ot{+r2$v_y z@Q#P$!TGkg4Tt%6%>&%xStG-h@11d{^EbFb{>K${7?-n87zje?{0{`mEtr`v0SL#C z=#toU8zshd3(Z3>Rcw#4ui1?ofNFr48*m(d6xQAMo_>2>SE{bVR$b11NSA@C+XCy- zHvgro;MXzAT>bFCX&<0go4t-U=D`Bj*q+@Csk_Ur6=ty?RL$x0IIue4GosOHJ3~sB z@_m?**(PjzgQiA8ypTmaGi@&L4bq3AF^L~+5hVqhhC3%T0<$4U_xtUOQ*Nh3kVw%Y zwYqJ$eK+EsSEwTj&{3HdA^vSFvS3+=UFoF|N8K8Kw?p0oxu z5jZmnoIFWUcPh{)M5ahiXCH@f*VEk&)qidsPwwngm5oPPD!LbkzZ^>*RMb$fmQ|kW z0g2;|v(HMOsMn#+UJYO@|5D4u#a~zI^sDvm%biB=!;m|R2j8LOnsAi9TQY;;Zk32} z0?}X^L#6;69nrj@O_cj0-L5!R(Mf^}o}{H3d?kjQ$%B)c$JXpQWDX&XT`g6@I*>he+SS|zZMu74U|uw3I{)}+o;RIFqJXWX zA{9iojf1=!j**)cO(lj(C=JBuGv1geAkXzpy@zSfMdOQ=pUji0wfW^0GMtL#z4{Q! zah1*ybX z-e(w$kG?AN6(H!3RXUg~3hJCY?d6>Kvf2#AF@A;IVK0%@uv<*oI=(^rC#*Zc%>p2y z_6*+s-Pr-MyAHs*%n5(j1$Z8t0j%^+KF~Xnw*VYv0gAF+f4EDJnj`$*qtGa`ewVyoQ)Ovg!?ziuaZqzgX?AU-wzdPsU8~XlL^Cw*>=8tS*+AGg zRLLwcUz6s;L*b6|To$pnl`t*>J95+NWpoTzc1Dimp0XHV$#zu+t%I&S+&L$GuKyK# zhr!IklRYA!n9oTbUkM!6{&E#p)9lH{B=6^9z(mGce*&)<5-pFw)fr5 zhcWoO9K*}TpQLWO*ak23+(ejOtLU+Oc)_?}c-s0IE6Bm+LC;lMednt84y_K$uKYsb zne(JGOyon$d&-^iQb`MiL=7aYYi+uHU$@Jhs%y7Z_lPc}%M9z%qhZ|?@*mw1m+Z+H zoy~w+>2cE!kMFLl*-XqZeRSoRahgoH=qY!lnwe-9ZZx|g_(;Mb0TIM=+nU<~m3wz~ zT+>lmz(&QYxH4bp1FB=hY|vtWz4i62>w^fM#+5d<>uMO%P_j}Q-9;w8D~w22+OgHI zVFhyb&T^N;p33iKdo|~n|EQ?weR4bs50DxQCY$!_xLr%JAFRtwu;?Y%oSP~>+po40 zqwmZr{^p@kP0UQPC|hKUMb2RQk#^!3+IP8^+0KfN(OmZ^IE(Kkj~xvT_tWu+as|m0 zmrUgui!UgbXlmn^RTW&_@DWuw=!&DwABiY6?M@gM>FmkUNOo&YCmSMt}t@1k3FO?vfbqi zyDg(`S=VMlqPl-&CQ`6jce-cM>O;J(5CvV7LCn@mm)4VAt`1U`9gi$asSB<08j*HV zdZkW$XYyFpvE5Uh|Mp;1_Py1%@?!!AxE^WJT8lg$ARIHOo*8Bak6F}Q@U?F~+eUBl zJpU10{C@JT0mn}-kn8fN#rbtcIItv>v$)!ggq&JnEKxb5f#Z|Y1=gY}4|`&`EK7Qx z%h@B%*W(5YOKWHZjjlP)7b?__gBP98nh+vgS5_w8purA}*M)!-q&sq8`@U}XUaIbi zt-9boNS77XWe9_H#oqpIi#OLobj&D)U2?uMf|c^(&Yxi*4Ng(4(JC?2K@^(3Il_$|#Ii+(*L#Q4)o zYJ`%vg330ktSoO?6-->LZqu>d>r|-deVCqhAd+tF##Qo@a7|tC48hPpSn~Us27D?0 zG+CKgB^o%*2+~`jnZ)(v!&fUdnVSZJ{E5?K$^he{)YP<>=oA@EoFW={LGSwX$s-&< z+#FMg)U!tf7JPmgDZNsLNRn-}Ad$gA(1QZ)i3a22IrQU3E&($%oVfJIGt(seU^7uw z)`^2mt|yY*LHPY6NCeP4p|GwIcKg0=w8ib;6? z;#`N6^{o_B=4U5n{Su;_xKrD6{YJX>@SZ$1a0$~zxC+$OBxQ!?T`T->v8L80B=Sqd z!YfyI|D6kOG^Z@0o+()8PWJT!tryE*N7ACIZt+{=^v2V}naOHPWJ6cQej4%58o_C1 zVTbl)w4`@>@rsku)6S1?qCXqa>CXZgt14Hk21gYTr1r;17Atwi`J39f%k~L1zzLV9 zOp_O9CJRNtR3l$=(B0PsU5}V)13^(-vp_$WK<9hruLO~OGcVlSi60yB6@$+!NJx-4 z7FYXS&a}Xa{K7Ti+#|hb;!ZMo%Kanpl<@S2XZ1Y2iY~;&qz?I#Qa#%T!+n=2fQI>3 zSeFC8eP6fRhpKC_RTq65(q)Hr853dMp^`tkPr$*S}NZ$GiLF9@|lrqX|~gEGXzgU zy2%9e_(|au63ISA<+lE^D_wN6H1&4zgm;`g?=uu#7qJSK$9J+9y?%Z1!slYoF3;7V zWA7yzpQUUxl%99K_K2+azF0c2*suI7sTfgT66RZ;?!7=JnLezywl{Cv%P6>c`Fv7| zmBp>zPme3f7&eNZ*!wSf^Gnv{C&gd-9gl*>lF`=f97qJd#)ck@u=(=TaPr zM+4=nxIf;JU4K-z*K&uft30okVQCym<3=c>BRI_Ohj%VbqCsc^A8&bT9qEiKI3>WTxfE<@E;-4%IAmy@b{9o7{O{!3T! zF2C@prfD8AFlYdkl2@2XD9y3`nbE^-S=yi`*+6nKP`azb?$dxLVsPNoG0=G=f6yO| zp&fge?rrI9{8aFjxHeu?ER{YLpGwMUnCyv28zB%?(e9_?nSpfE;uZ7#rF|h!h#oha zNO+;awo&v;`FY_(6eV&i!9ja^qhgMgC|`Bx{gMo}Bw>EkT+`Zx?ZHy@;z!r;sd-A= zllj4P6b{*hnz1KNX6+61u0kM1h%;!wAaT(~@j_f1o@bvMcJDaSq}z>*Dw?d(<`hOu zI21F^G`Di$RJbPSE`Hm0-$FM(_MR!9y~N>>gqvC#Q%1|eHVA)^SZWTv12~Hq0aF2V z=`L6|f0}jMu37vXtjl=t@45gF`+lfv_JX_SIvO}i1r%kwu37v%HAk|4k3yqBR)cc1 z1I`h%Je;Eu+5hBd_m7h%93xKGaF1D>MaB6M(>a$}GwDQ=@0f6*aSGvS-p=<;W!XDW zO5{N#nq~?Md`2=~OsNFcr#4yK<(m>pDlG-<|Ks+5X{oC)_m8^YiFB zif|@Xi=f)LK7vG~k59|7Duy3!MPSq9Js%z~4>2gtZNw99oqd)UYB|qn>dxWcl zEpO$Cy`05jrx(x9^E4ywTo_^)EDx-Vi^>wzT%E#>)EG8sJ3Z!mO4j765%QlG@jh`K zF@0Eud>Rv0$u_Zrw=@Y*r5U)pqI7QF+d9+b-GXr|Liy7HOAwdmICG_sXRvZX^kRj& zzN=pF$ODO_=*5}G_fz#+DFu0t5i=q^#>G>3X|}V7^IQ)XIf}EdCZof8g+KLO#-6=t zzB_u7Vx8JVY{h}zGkRL?AU!<|qwsUOlb0GN>vV5DBct?q8}N9MsM-w*0ZIDZPYh0= z!PD{kJI&h-1j|Ab=bk#s%|r(_&yWW39dX1Xd20v?n$klg;i2AxxSa97?^|9R@D3I3Am-i+ zY`?B4-cbkccxA076M}N6Vvh(qgINl%EZVH$gU{E^SB>hP&~xy7|9YeVJ{!t7pyQdv zog-uT_?2CC&np_1X^JZ`eF0n}#iQqUB!XwG6!}83msv0LrV=MwhOm;fdxEkMMRe`l zcY5-NB@$5=Q%T8Kd4;6Hhiu)cpW=iIhj+}W2JyB2@OkJ6DO6*L2EE~rzAS+3B!OyA z!Iy}=r^r=j--hB;DXn=#Qf(N8KqM`Iq>aTjk)gGZgNt?`J=etDqk~eDgAb;QJ!Xgq z;zbYzx1xc}2EBC)QHx<|mI5hmFcl6~hZPa;^BG z@H^`pkL8#PSxzjjv9{OT?pWv#)GK&I`I+|NtJ}HdxXj)^zReJLI;~)Ev zKfSI*GZjzw#>>(mn3m3?qxpv?kBLdv?s;*a4-zM65>yXAcrbULUODDL@zuxpGr|lP zd#};!8mf;*h?pOLpE7u%tJFqTPH>E@M}GoHwop6Zj9k9Q&&{$R)A;Jl0?qufc1mCc zjd0(Z-%a<-*pCzRsT1S9=>2vHlUzZ-?WftEllFky&sx2&<=iAYmCv?;}Fs>c>>n9aHjoS@sud^UDJ185) z>uZN?DHQE*LxKkiM4z|yv&qOX=)3SKZRB&)?2W%{0P488YVCf%QM0MI^A-JEd&GM1 z!|-!u4>mD=B{w}MJrVM>3IL6L-7BzL=>Rmj5d>fy0aM`DO zz3!O82V7J*U)$;7HtgMx8fBasDi3oIjvpjE?3%2QY?&v9o#*Q zed||Vg1|+o)}9=NpF28@ZtIN?S1vOEZn)OmiwVk-MrsJDbdeLdyA{T!GI25z>i; z5zl2OULhNUh^U0Gt9_m$=Q_JX-KQ2vMR9{n z2Ku`@fdJl36rY8ydY#+&c+d+EDQ(S?n_X&O9-OUp-GHXW1vD99=-WY|g|4LeSP0x% z&biSgQEydj<`Fpv&97%uDN@AG=rzPghthW(&6Cj0}OoNUq>x`I~OkT zS{|H1)*R9+AYc(i1+}SeyPzwp$5E>e7Lrj`rt}#@GA=SZL+{EIImZjw?B}r_#RXpL zO*vUDO3H4^r4>P$g`fLA_9dxX(UbV;+e_5R_iqptHWJqoGOIPf+KA8$EMot{6WtHx zNvUrf4yE3%#9WHL$u#W4?gi^kLu=i(>&PUHsJcO0bvYs+T>+}@2CRGJ-Cw#2-7cF+ zZUoGm7g_5jZCM9-8I4LZ$7&oVNHs&Mqf#XaUm_S)R!w|I+>FeP#xjrgU^inwdD^2u zdJB(zHVZTjvmTnBRp<0&S;4lm*&oVfjSo+MAdzWkya6+(Qm3XH;6z-@U={;1U7pmF zGe9IgwB0TGR_#V6$$d@O_O;^ZdE=w&dyck6TwFC=)(}LAb*MgIGkCbl+xdu7LSqcy zf3j6!dAL~SDNPy2Nqf(qq5JjYPV;}gxBu`$dQM6t0Z)9eTy4GI?#Vl>>a5JoO7k4D zyHayJo;@DzEilHsBmTp_=efVu6!@IOSCz?OB312_rIWGBj}~r#+RTmQg`^e4yKwE1 zpqhi%VO`r)_HDanNmE#te#^NasB1R;bFR6L7LIZlin48v%&0l?+nS@YMJPvtaE{o| z!8vj}{b%;x9c#LL<;8`!M|o$vLV`Xvja=EBPk)5sh^S-nzdL5?Ii>!J=`t!JI19y{ z7xfw;f4$Gk?7$^-l|6|txTuZ~D%Wc;1si%ZzMQlepigzR4J}K^RJ(9EqCPD7hLc^S zLWwQ2_@1Y1**rFd0Z#9%LC$=qz)PlFj=cIoI-x=3XTXhK!v;rWfyQ_{5-oZUc)3SyE%rVt3z z6bgO@4V^!2qAb_Ix)B|m+v{3Wb&qV-eaH;y3cZ5)Xmc{yL^g&(Xhoo*pci(qbz z-OgVN&_-2G&m~%3Z2Qj2=9K1rM^)(!!SEn|n=RI5g!_c1()V@xragNtd~+lFhr@kc zOwXzLt?0dI8`FDqJ3SR)4>o&XmD32VBwsJUZ++lROK?RUN2QbmSyH|ts5#5mtUSB> zh(WGoN{qxkj%yOr4q?&p=xWjFjrlUy=NPf-9#C>NThl+OY5}<*I+jvdpX<}W2u^?& z7d7nKHRw=9ZWaP{4c0R)%3y`x!~*!XhY=1G2aRUvzDUEYWBc*0KmxTpYD z@5n291y#rUoSr{kod_D4>$ECa{_rrSgeHR~ZIyMrDnpXo)q+jBaUO|Yp60AP7g>V_ z#!Bbtt0zWi9ZTG!@}q&2={Jo#Dc& zQ)OzRh%6nfb!C{Y;V{bzu`$}*0T}a|%im8wM4$HNSoo8P*qL+kW=-18f|z3GI!@j6 z!65v(t7A%Y0NG|F>EYry2N!38XZe`s6BqeQ_4u^R(E7B>&P(abHfRU>F@RPsEH-av zB2xfulMohrw`X%L+f&}8xXG(-G-^i6Rco=!IjH1T4y=2umTr69epr`wcB?M$8l)=% z>vGD%x(8DJ%nVE|y7P+i=0!Kk#AsHv%E4Tm-<<>-QC9C#-F8VxE;=0VQ47tk7l>@hXk^u()Rm`u%O9(pDX zCgH|H;d5tA03=?m9n7f`fSzRsRQYXQg!QL-8At(GsC^3mex!O#F>^u z+)LO%`D@otX3MQ)oM4=JYP#3E^Ye{|WEL}XKynSa9{O}H{Doc#GW%8KiwW@pTL=3l zORSdBwa@m78cLj1GT%@itV*jE9AEPmM=0tS&Kuv1HMwsuAu=wdc+5N9?c6asBFmZB zGeZ~GAE+Fpb#i00b2iuJ*|kRZru-!$yO6v)Un6PKuhMeQxAI-1=wv)X%Mb8n2*{%& z>%4R-G#oW`s&U1>M)})Y(7J&PR_1l`b`C0x7@1Cf=XAlf(~H`V6n6F}5#Kqv%sLIL4^L(qWML47@2J7zF59=QPbGAZpZ?fn_Z?qEY(wX_GI#Zjmm6JJ(0+~5{ zX}d?>yT;iaVL5`%JLYKQ+`ELTG<{cmE!;ON*T<#UbIcwkAE-P#9C7zth+4l4e`Ta% z)(a=Yr+%{IntGu>53}v!zjCWIW=$Qvn=VVB=3JYMOh%3U3id13c~hK}!4xZLDl{=5>tk`(D`3m1+A4l9vQg%ok| zHAXuB+=HvfZ1lh1y?CClo%`xQZ->1}LYxb6!vP##J#{COm8<;DVeQ189>2@+hBl^) zte$74B;VfDTMxmLurs{w_CS0Z9X&hglG`;vs@hZeZTz?w0@j_C+P<%omZ9oyxyntW zPy(N6+ey{!g>}3CyyH`F=Kq|A?k)(faaO)M7Nh=Da?zzhqFH;8z07gqLCl6M&Eku^ zRl_4Aj7CKn<2o2z$iqGSOPX(k?PQsr9`I*1S~PUL%Cpn0(47phT!j0Q^|S5iREN|L(%eJe+j@Wfcj=Ka_- zmIY@rk%R-u6=?7&1yPlNO3gZXM$UrxQE7CoB5QDBdi>!0+W1Uow_Qth4YTQa`PkeJ zcdUv$z0+fpJGmt-dv1U3aea+w@`81xO}6jIq$8=inpF1?zG-z`A=R|IE8a zyx-%RYck(>DyXs5e#v@{OiYpFa<8pXM}0b{@LZ1YjwRc+?v{Ku|D%~_KZjTai zu5UO7b>%ZR2cilnr*7-ICFg19j3z+Wr&Dv;oEX`goL%fcM*7uQKSk`{9WQ#%KpTH+ zSnEWgtCIj*BuhiqlO2qPE;6i66XutL@tL$O8A`en2+0pB`t?5kJU?CQd30*iRZ_Kg zp2$n-6>SVoMQOQMhQyoehFL%JzL4_a%xPB_qx>HtM_!g8L^x)~uyzF2`s^{_VpPT_ zny9Q*j_BT0#Lu2Ju!BF8*rnEdOZMg5wriHAp2wtB`@1gGHS0oM^AYN~W;!@Z4-{p)&OHLFfqq0JhN56gM)tg!HQ!FR*m6SnUZ!$D-n?Tf-$u0q z(=Wt@sV`+~ta%?*8ucLa#C6|Ws*?h46sw{&eslf8rE1w*V_EHfhoaYP%dtA(CHc4F zTE5LDq_$j?;a5y|hKw2WMe`D^TPh)DHW%>Nf-&bddE!pepzuv@30^i6q@^}veE!7` zS_7y#i)zI_f!AmQtCHTy<6Z-RjFXitq#Jc$`@T+k3DyO=w(9y>LAv6wE;km|{cQf{ z2)J`RHUSe@e>Q!$dQ5{p;sonl#LJ-*@8w=`M0ZPj>e6~JryaKbApiPHhqK2y#Qw)& zKt}9d@pX}1*UOWSnQ4J%TK$iOJeA815w{rNShwhUimGTG4JK4)Xn>7p9?SD2y3b`TBsS_At$l&A5c8lH4*MKI(2sf0NWt~PSNv!I_ z2w1!oaLF+8y)hhSz>Wsn)&(l~SuXmG zX`FXd_L-B+VOQqd((Ln|q@C&N&xyQDmRP(rCzXaNnhR#BYn@53kC8)aVuo7Yd0byV+Xm4uIl=Bo)Ra_7s?RIefc<7BGZs$@N!&&0`ehl)_sZkK`J-Dj zcI9NMIGH&0=J3u>9;)|!j^Hn5?-TGlae5-^9cA?MtG5Q>S9+)&1ElV(rb$NShezJwL!%4Y$@_HL*~`jR^6=|KNsoPvN)k z>tuYXy5?JT)eb_slCUmMJgi%__Ge~@IB7ILeL6lQF3vk<-@DxFtS7SX@IdeQ%-1pt zetdkSb5Bw6ox85)Y>4|#J%OwbqH$%Q^SE$$J`8=bcc3 z-WmC$*kQ$)IUHnCmTaZB#G{=aQ2_V4F5gsR1|H3WB!cvtGM#IOT^cy6G^#76TVF2E zW}msYdAsg~3BBQ&>cjrV(=8N~mTXui%66A&c^~D$)MPX}qlGUb0IM;fznrA=+=Vg6 ziR+DKfS$dxdyiT=(jt6dnL$8sL0e)duV0yya3+1hlSm&F+x{9kR4R>R=h}2$ywci`;yfzMpwt;J#5f*TM#)84hFbakXSeF z&T?E{8vXn_M%ILdL~>!N6+6$Qd4$&t9})K!Dt&#W(W6W2e*&*dapLq2yK{T`25~* z;v&P|TG##p8%u}PieR%59h1X!D@)(HjZOQ;UmH~2E?$pKkL%b|#X9rUmE(9Cbx$))rkc8U~63%Ja!+n*?N zb0-CWM8px{RVLS)by~$MW_KFfs0F;p+{Zv1fM#7U_R#MWeM=c;Knrm6TG@8j@UV2$pGutzu$h{ zp)8uJ`;T>pEzyv!46Ms13G0fR{+V|*Y8c;`@gKWqT*dx5H0u#UeJ`u0{b5(7S01&h zxuJTo~&_+D{Ii1HntKo zn(^8xh=0>_8ZVdgF#_G^oKGRgAr9DQRKwA~82(5Bxrt|=$ZXds} zU+=5T-jex0UCPYW(hHE_%M&4zcIa0jRY^Nurhf94n4VANMaXCtjmaqs+JW8uvOs{T z3fZu!JsFRgL!)Hxz9ry6^$X|(QmJuGr*GG&2i~0wy`R<>-$&n79O!of91@>#QP!@J z2oN=TPN*bH)sPZDfip9|V}0qM$8 zb!%Wj-E&0({t;5J-RGK5Fhhbjd|JrF*+pS}w zQarC$t-i-V1zQ#z`oJU0k?Yd>xAUe^o{@WOd=@YD_0ye3U$M?025t^Z*)btS)SyhiEEg9bX<{5KBJY$d9Evz)EeK$!CX6#hHL+*X+UXdIl*cG zy0}SNP^=*B-=Kg$YO0hA?HpyXEpM9cSn}Ei(DMIMoi-@N?wnRrNgJonA;r@WE}5l` zYSS{m(Du00e4o`i%k%2@=5ND9{3h=iho@79MVp=$zAA``Sm!tCS1s){b{SFd z5$~i9eF+d%Yt0ZEi0^WrmV);(Dpij>N?%Ex%@FX+{@uBXXml_0-UJ71@LSQEeR!7#H zDZ6KX}5@u@(1_ig(9Gy6pG?heZ> z%GD%EGHI8ltLWv~o&aG@1UZ=4L7BJ|H%Os?d4>4><*+XHZ!ySd-J&VFz`(@s$6nB!DhP(7Z8PSirn`M3k# zS*3}=?meH6s|uNtojKjW(nq15$!u@d(8dmd=t^!qe2G$h{{iACxxanA&cYVm=-73d>Ldmei)1xLM zc7g5pI~Y6_9G2|QkgkwcUA;!mcWLd%y;&)m>G{T9pUPjc7t;x?7)ekA2P1K8?fKx; zLZz*0d{M6A;I5DvfzD&fpybsAA`d#p!d{*1#($_p!2O}G`^PROuJ?8p{F>*K2OgzO zHE1=2-dv#27umCr$-#r;t||9>z4S_G!jmpoUS)F`4IQ_zW-x2_cXCTA^$L`E-9oW* z-p_d)lg6a1Pr<5qAH-W{vufDiy}t)6oilCRdlQYE;U@+7R`whwR`As)ZE!JL(FqL5 z{5+(UyrXJwG>6(lV(Lu!4Fr*N15329XjIL`R?iT2C;$X}H>{g$wS8YF_ZHUuac67o zf+$E=5!Mw9fOUfo|Ct%??k3fdfAHkvg1UdCT))^yEY7*Sn&BWsHccitRpuy)-Br+& z5I(y$#Sm{FW3Gip)||?pgGBN>mK1e$2>6R_D2(0`+b6!cdKZ92|B{ zW@5?qajX?AMoWB!Zv10~c_lr+SAs*U#z|-p`b$` zE$xENF_WP^ey@@A9YV&#RLE${O4;M!>)v&G7tujd`Rwr|!$n>7pJq>N?g zjp*iMrE{d2#7sw*W@Jp4)y+wMb`l-7lBqs-U#YTw2E)UQ-%(n)$I+1C#Z}yxv3&M^ zrK_g{C1XUTDm<-8h0wb*YzkvuU6*@=xY{E=zP;#-+g-(k&jk|27jmnE z*4vX9PZQ&rl$C)zOUOu zJ(K_a*;ZXM14vgH))hJd>z<1Jt9Io_C03MT`16;A`uw_=3U`h<&(7RE!fBw8o#)HX zk5QL4YZRXg#_uBWOiK>Ffr@AsdqGm?3Ym+Pn7#?i& zf9&=tJ(p=@9Zg$l(Z0%H2qZr~DiHO}@yqo>uO1GsQ0WKP#yh$41BFd3_p0WFyV=hQ z2Hsb(SP-!0*8kd>{`^$mAtq8=wfx6J=%^(V(gaxiC7@b)Jv+XzbjLx_uZl;$Ci-3o zC7}Civa4&x;mRY_u5FMOb^~%rD%Ve`%68Azq?>Lud7jm+faQ*WYly)`S}gRb3&2Sx zZeVv@+kviy)AIvS1mv2jI8hST27PXVqI;$pkNceHwjxgkjI~3_)n!A?Kpb!`lM=OYwrf!jG-Khr7ILDL&uY zCjVL-z{tFsryb=YMw`ngM5)VT5ri0(w(_rpC&QCn{pX5%nuJU_tg=#c(UcY<$p_SB zytSYihL2f}o|#62zVRf9F+_d3pGULo2(%ItI;bZ$j*8Yk#e2>~w@*XwKDq?)c%E?R zL&x&eh{}Pm`QbHCZ>A?YCU}DQltgAIcW*$uzjR*k1t)e{p+(_$d6y?X5}uMtPn&1( zU=T6tD6A_>q}}#hv%Dp&`{U@}b)l}=5bBzpsppy*;3xm_F+jMpVr6qv3Lt$1YQuf%mkZrI8x6r{uwM2A1bRW@y6 zgB-lySiXIm9pb5HXjWBxu)tFyX1QSKq@MMZqe5j=-pR}TY`+qi`zPo;i(Kea74<=T z_5-nk=4^;(Of+J^W^rg&Sc(g$tY0q({XL4)8?T)RbX9_-LS41=`US{ zV<$b5veSkT3CH$-v6a!jv6=8RqF_nE%;74coC2Nqido*hyy_6#P~Qz8P}Pe14!p-# ziyb;Pqw-9NUc}~r@qSy02R3JNnlFBi1zK-(Eoa8Iuhm~$a3Xeh8Z5b9I>Ts=yymHd zJxTl84#dWFzyG1YB!k?KS!xFMd>PwU%e<#hIPMv+Nyi`C#0)N)vbhe^XC83RjhK@a4cB^FC@0`S*mC{ zS0uOOBras!WD9RdRN@qKARM^Zef`nv3*M3}&uJ><`67w4XD$8kLc#JZF$8(u`_2Kg;GfIWBTRe(<~+c;l77 zG75pvzjyHfjXjqykL{aWSrxy&r}D_fnxaAE>|i$mte}9^6rsYYJ#(<`3d8n&oq`-y z_aC3~paWkgQG<0w5wNaF{h!+H7GzD-%)h=;L*dyRYDN_}6R|}Fwl&(4#9)^;6&dGF z<)v?{2Yp69cE8!Dr&ZWki+!zaxTsuxkw#9G=!pmW0%yF42ud743Vh-)s2$)4hGQ=D zeXfhZm+)6JID-b`E}PKH5A+hGps%05|3ZwovE}~fyL}xb7I3O{B0)NjPc6OSu8zL) zmP_I&TO!v}^ZgQXHm*2}2UXZ2q*^+wGDtC+K|AR6dm4S$_?&Afzj3bmN7BBNCGR$z z6xwH#j3VhIHpDSQvn>SQi=p zr*`jVR>o=Fj%KYvtz~~EIPJD<&1q)Lyf0KMx*+B-+j7-T;4G7IPs8^$tJb~i+Rjya zZ+@I>QafUv>_VTkOmpxl`*_Ebvh(uyg#549-okz#A?4sK$SlBJC$6Y9-eZ=5?+ z_W$*DC*V+h|Kq@CY+16CJzI98EHl<*OP0vK43Z_=%phBflD#Np-xb-1vCm{F*+R-T z7z~PtQY2Bdeg5}O`Tjq7e$R)8=TV$z=5@|J_kHhupZD<^ITP=?xr;BcuM#c&pb6(& zX}hjYSB@2Uh&WFP0X<$su4nzvdjKyklCkhrT z@(0sSK`Zz!yd7s+`&^C}&KtjAmQmG$$9dndMVXOu2`$d3Bob^KY#0PMFYAjpswI)P}hBL6}Ie^i~YVb=eM4}=kr3IxE0_IBf*`l>cNyM?aP{*MD|3kK zcJ5f+>@Y{>>VqZ+)E?IUy!k4Y@wQqV2G)RTU2`@Vlu6>R|2P$#ib@kZ%c{bKsc3At z6$1IE2szxDfhbI9ClL^N57V090(px@+eUYAh+TprGa(m4_yS%-h!j$bl%xsoOkQFi zBK*X(H4%Ks_?IQsWD=P)ILU*;58?;+B4IoU0+kZ_+|l0q<9aO&lSjZ4rf}Q69zez+ z0oC5_fy01zp~1)w%zqJnD+B0Oi|vo=!eGB^CIZFIAW&`DIg0M!-?}tzfG(V(I|=II z_4dZ@%BAwMoiLo8P1pOi#ik+`OS24Dx->tUR;gufH;A#~j2=InU&Qz%laa4CRZ-@4 zA=Kl}J1;--njXK6QFL0?cpCjnb8pD?^lh;oJ{Hpph$-n0%B|(ru(#D+N}_H;P)Wk5$SXDdAPpl-#POm155_hYZ{dI=wj&Gvr?u`eEGhO_kY* zDhA0NxYNRT6M-5r>Fyz=DfPmyBPSclh0X`cPS|tmo!{6mAhk;>q{ni zF!mv9;5sPCFHpBt^}i!BIm$D>Z>Rp&T}uIU5umP+2dImU-J5s6g*^3LJ}C5jDMup1 z`>Bk)-(>?Kt82ktG*4|qttfeioG&)peTj=5S!dRR8dYmP!`3c6(lDVdD@%IN-_S!+ zGCK!LHpRa9n&<%;zxevxlkvB#)pXO@U6`G*-%RMwMYH968!=%C5*?AH+Q zBxcOb5Dc>%`$|%)xO**tO>!olXR*E3uZ7zuaY-tHr^Nncf(d_-0&0V zN%GB`V?8m_T1pF z2J;RzFWfmA?E|sw?drgfo<}>q)aOY6j~? zVSi~cfMXXTDMwk!`u(qaK|s{3`oDY4bYLrwfmZ&Gn)fP-{yqZa$|*+x_1}H8`UL1B zRZ1VNgMD;yZ#}GxK=bOlc^x_7l1^(U$ND+xquz&Vlbb>aK|dI_4vksak$}ABSI1HQ z{4Olz#wBm1(|w!u3^p~Sts1+kWRpS{BkqWqC6^w}`U+&xKrg-#zj@J*j=@TikBwal z%KYY}ph38NTfwt|WQh)5NBV5Oywp2*XUwO}Av_btZ1PTpA`QJcM6PYdsLAS*r|I+u zhvKvc4kR*e(7N%`X4$ZL7U@89@pN;~>deubDw6BbbiTq4XRl7YhbTK85lZYyHR&UX z%3StesAFs8Ud0M=`o?ZrPfeU%3ej*oZo4EmJ954KA81Twfy$B9Z`W;99wnYDVY|iR z%NCs%t*1xODG$60i4dQ3E|lRt3hD~`{NHgM6KC)@ZaFs`|OgFooh^d{i+?R3Q zT`Weewgh~BVX$zTYaB3Bu9P}|SCMP3uho?!XHQ-KA3K9hw|Jmj2t%=cj+E#(smQxLRG<8M!BUiavLO~J&CLmriE*AGg z(0=t@#y5nTsE}KD-hk`f`lE!%8^*T8=mm1*8QFItHY%=Z_Ohs?Y&Y5l2LC2IR1(b+ zgpPGM^MIC@^NozFo~>Bl3gziTH+^oOr!$6i!F+KHG1%;Ct|d^H7Qa7xaj(wlpRt=m z(PjTz7ugT!YEX2OLEU@Qd%9KJ!-7*Y_md*b?{XaDR^oBn$Ydp^J*AGOiEKv*a0QvU zj^1wPu+-q8=I2wNR`8qGU8?xPKP}zMpWFx86*s*0fO$u5C~vZYR`@uCEA59?GodJA zOro;Bhhlzlz8e(6LNgR50^g3^z7w_FAD1GxeTJaniw^)y*rz~wyL{#@g=>pxt z%pu--yqzb6o}vBJbq+E8&oUY$wNoin3?csCn-Lp2kWd25;I-d6lnO|Qef!nx;OO=t zbba4VmhxS9CZO(=gLptrGLI*N9 zNds%Ib}IRv4fnnI@MvPKtEX`b%1nnCPOOe)_j>g+oGznQVKcd$tEr9)b@QsRWs<0# z|Jy*9CADB)<#(YD`d=`dzCE#clge=0XU4;;nS#148#y7(Yh+XFzHg4c*(drxL9?vy znTkEx&iLp(>570&EoP*5K4DHD`0ibxe*jNCU+TbuCY4Tj_pQ@Irj7k=9%WfjCq`kP zUoxflz+4z)Rob%CA+0bRvb2bGoq6+NAAcCg1SXt_van%)&BfFc42B1-Jrk6t~hu-twqr-2X*ED_XKr5L^zQS zGs5ze_C0(3Jlo~RI8k@^S^=xLvjOFkt3OZ6mD+;CsZbo zF$)Tw^aa{Uz*sn+V>u(P~qsrT^M-M~RNblrwR2MWS z4@91Rm%|&F`Khu^Z0RTSNNza^yc>$_&UA~0s-~`}c4 z6o-`sbg{(!d579ipyn>{^+)$MMfdREx+~*=?r~68R0-6L>fRf>o!%k~6~mgM@!CxU zrW;P%H8vZ5tH)m_gc&|s)~92=EjhLX-?R@Tu66zMomNjh;-`gFmUYy8m&8oQj~{&1 zNGdL^_3oL}VrngY#-@Af2|el5lMDUak~YQ#R}T?=Y3gFV^X5vfg|M_W);id|GLSPu z38OE6OY_BdFmDYhsJ;(CiS^;~YBRUz@7XyEcyeEuv=-x2f4n&>`&?LtbhJ{VY`v>d zWu&*@+=l&8X11o50|}ZB)Q_8wvfmjQv}MW?i?f3S42jp?OjlBPNrbkLsLHyzVLHyG zfqXj^Hx`L=(mvT`oibvL>*d?h5Q&R~D;iPm`A0>ld79=fr{vZ-KM2B{E7WD!1$8el z?T^Uz>E5U4GX1S9i2!u9DY}nA-NwCnSH>t*%;BbYm44d2_Y(DmEoj}+;JAgni=3$@ zUsSYYBugsaomXuclBwirmk{kJ*7{UJXZ12MGog7zWa}LElrU4QRl!EW)CtkyXdwS( zZpIsWLw_bWE4;l_$ym-cPcS$4QFR_rjf7}In~a%fwCEbzmc08l@?^y5eJvi3Ckkuq z?YuD@%RGu6e2f;&!#^|gO3QpiHS)z)7!@m}@2iW>ud2-*(aRWNd-jYmy*>BKpz>>7 zNu@!CBZ6+Erx%l|8{Ri$V)e&XnJfmizOhKwYZfmhMe}uE7L5xd&_^f0J`ytq`)I~=@7!pg za8wgZE?eUZH7lwKu~I0o$^3^uEsOK9tc~Hb$oyUU#~Dz;%P3-gK<~^_>8p&6!|7G; zhstcQty$ZqPcET`)7w$sLcHO#bz(i`KZ~=pHR2l&64gqu5>a$q4>Ut775wX#5*dTf z7vZLW^uZjwfoER@^_Tc)_+nYhtvMAlv9rBOmF&_mXeP#dLX*!9iED;7EOuXEb`1|C zU9g>%cpq3;zx~PB2&il{GJ?jXI56Q!P^cHA!&yL2>UIRa)onEKg@D9N%_+_-+#u4F zW5%CW*S}=q`57Gl36A^b7tjJ{Gd+8ym2S{I;!xZ189BSFM{USx7>&H+_a%Ek;C@L6 z9D*r%4(c|m?T_pBDu4dgRj>g2=qvKyeFT*?EeCXUD7wIP)I}!mz1oYidQ1%UH$G_S z60EE%eOFcPUz;oaCX7I-f42)D%U*)pNo+r`*wphU_H>L3*{Mc!x9~+A_H)bOjy-ZW z{-Ytjg<}TA6^6v6$e~9bL;SA039HY`eJ_gqyc3jur>Z7jo+;i8wT2u>_gCR}!;|jb z#d{h-DjJib+&H=Uthd@cTAJ!a))a5$7H+w%3UXIVIR?{ukc6J$16~2FArD;<* zOkFcYA8*qaFfG|6X)L%|6HH9#gm~Sb3fAn|5Vp4edSuO}x_*&P0F|rgfON;=ZaW&* zSZ_nbc+w2p#h&wiR+vz^M7P9y+=qn2*T3gFtAR^jLnV1SVGhCXD?x;HjLI3^d4e^4 zZ?1phPZXznv`HdMYZI3n4SR6-D!*EIWNzU>2l`|A64Xb<4j$eu*mi&2fGUQCV=&XH z$j`_|0tqQFI0=*sVH>sVe5HU=FkJYqMI~RFiGHGs5X0kl$#@)CB?E;kT!x5Vapy{c()#ojfb|Mc{gxd77=tgeknW8DhlI$6aQ< zIHHJ3>Of%{mvaMZcP)V8q)Lp!(3xAb{DLQl5_gL63@Z*j}C zh?SnLt7~C)d|gxtt=|8vZTt?4VT5)k22*KR{id)ctYYUhT`@WA`Y8qKo}|?Dj7Ky858*kuXrV)MIb# zzPUCgs3Cd0$eE>FT>{4R_V!$%EBWvNf_+&V;%G&%WLJ~$)y;QX56TDhK#%1q$IxT-$5m3mD7BV%AtK+>NFD_h1B$&c#X7S5Quz|Q1p!lm3S#`{kv_NGqyLTBsW+HosX$4&zqmf z9c-91f($q@%K^F^(fi}Nz1o+5bZ<~}fmLn)&JF!`9nduZbtRynF2Zte>^6j-iQ+P^ zib=UQ#NJSse{pnWA>1uOAekczp57&(VY9nHb?mNyvM5s?r~QIzfl;7iNY$BPj1}1{ z-F--(6P29bTEfOsYupA@{AB7j7`Lx=VB4B3Wp|V))Fq~l#+zsd#`e}2ib&QbUmIgG zK#p#UJ5}EvbUSX*ieRR{fgWkGp3_y+BUPPYvalvHX3UP+!Apa>o(7F)qOd|y>HH7z z9wm-RVQlr52cf4g93pi4VXn#^ZyKjTTrD~|^!`R|g*K$b3k>@BD( zoVb5I$zJWtKe|N}U53APZL|R0lN8+~P}gpHZ|ssD0gE0pzePp|wV0nbSKEwR;Vib; zfj`+Ab&?u;WyWLrYbtzGJ9#n&YUrm5u#jj88PvAGpjt5F}$>>Mc_{NGgXZ|4T z+_MlNna|ayEO@@yWS?k@y`%hV%`+|ToOnZE(Ul8xBj4}+G~=8(+pc&YD-}UU#VmBI zvktpn^CWM)_`}Wxx`q=}Q7mnb*bLHeuZsFZi05HVPbKD+gKO#I;?4NYaCs*gG=n;3 zHU=t_Gy0M+IXa{+@m_b{&YtZWOV#vsc5{}d?3>XWH+eH4jw5&I8$OU;e89})4mG!m zIUja?U)-D{kU`^l1iC$9Tx6T2Po}YgJ*f>ehW8i=K;Vsqcw!Q$TSwTRceq#k^0)4> z!=Uc=?Z0)Uz`GTOpspk@s2gDSM^_Gc>G??3%)NC3N2LTA28AAT^Gu$XsZ=6o%Ux!w zBVXBbAvTHqCMLF_%)$OKCj^mxaz_e8R!+vgGo7 zmu&TFbcu$$x30W%dnA5EA?OcROI3e?S=Kd>)q-m85H>Ov%sU8js){eSBM zQS*uaWoXcYtyBZ8{2etz{(SuDqex00$^N?)sO*tippQ;b`luM}BjTaGBS4>NH)88E zaV#AD$t*w!%wCK;Nj&D)_Vf#oz32jFFHQ+;I=B#_Iy;fw!VntCuCjdF33)s`%j(H; zna2i9xZzH3HlnRRQX+Dk#nxBKu?Bh_T2RAZ-tB*8UCV_~<=~e&D)>V8tN0mOizUAD zGphMhk>+K3+-d!cUT*c6)%0}JlPU*duyz7{sgQ=_-Aat2iPz@?)xFGuoAt_G**LKq zdf?%GQwNbI!O~MYN%J}~ha!SchlEz8McUW-wTfrKu%=bC zy@d((>bv}=FEs+2b)G~gT@F~EV*lWqwR=|M4zh-#Ht6k~I2#CV@p1BijonD(fU}!c z!v_;`$;sR!FkibwgQlDp7eq|q6L>RJB}#A-d62C|!{^AYiW&jWl(VN(Za2z4%c^_I z-=sU!L_j5>%*e=FP%p*N7q_t9gO)arIxyPn6WXoBbpi^p4{f#+aM4(zzZH3?a1hkhmjCa#PVpExcDJtlJ$Bc{09|8> zZY!wUf98*_ObfT!4OJDCaT4wu^HcAZ0Y>~}_sGXHdZDUpQS(?08}(dk{pNcbmG-%9 zBT>HU&jQoy0q=o?9V3 zH}|gdFizrOZRIRRTMg&s^znvYHczVI&N6=m-SZZb>t~2epu}VJ=3O`4U-fwOn3rn93pk;H ze$p`<55I~7uRu6|mIQvDFXmj=RoSUyHwLdE)H)&~N+zk&TIh^Q8>Vt~$`?rU&eM&C^sevS z3RjK=UT928+)b&69R;{) z@1lfDynK!CrZ2rw%XdHeogsg`O~qsA?)%5X1)ou!92G74vWf}cT?98Hw({hgs;x41 zj=~>Ty8`P;n4g6<_*|okJ`T2{zgydW9-6!b1bn$u34m_Hw+OC$NH|; zD2=<XK^Vr58`=)9}Xyp*80zc051*!C;%*xW%Spg?S&bE6gjjN+m>`h|Ow=l=id*b#bTm$8~$v z8UN^RP;^`W)@8X0=$;03Wt>6X-2bgtn-3Si63mSANQsM+YG4z&oN!>n@zpPZW|_-7 zIkdiRkF^D6XC8|bF+XRyJ-EC1v-|GHr5RMD-_BY3H#YY2Li#n@+Y@J+t;XKh0PBRi zxy-so-K!3Mmj&wUuqeM;WeA9PYJ%!jEF;Ce;VyJ8sdC^e5nR36>{b0??d|l-dedf8 zn=^`6WJ8$bbkNS;vJNdRNl%rK-j75N#cF@zl%4MJcxrvmJhgiqfQm)er_^yjtq}>3C|NE}zrGRI+(24!+ZzFbVM(#{2_R^AO>$ z72UWKU3#=qWz+*9)YoP1(kHE&rHVxP9O69v+^&(=dYGT0>$X3x+pEs_TUW`4qO13} zZV4NpYevys0d>_r?VZ(>BUrbFOtjo)87_Dj#`7wP6c^2oB31ywaVfxN>R6uMa7Tt=Ept5zj0~H{jjHI*Bpmkw_HmGbBqaz&hWmcfx(-R$dU_ z(iL1i&e0r&{~np-?ly?fX?!37>h4_HACc`W%fT3J~Z@11`Coym#dZ#JuH?uL=&9OPj$mjj%R=mcV-;Q3Qa z13_83y&laSI#G(`z^r} zgTUr758}5jbB^kY*IHeb{E6fvVQ82i(wcvUf2NWYYyo6c0Qum!U8tXT8<|mz@A$#= z$>pK*r<)q#ap*y`S@1@WJgziDR!6-nrgO9v5>{+!KV4KCmsx6HJ37xTOOp^^b`pV` zh)3bEz_b5z;@HTWZMJO<^-0Hq-;x^%nT(CT-7+qc+)?>KlqcgrEo?9!Qc`EGKn;2O zrQbu&8+z$R()o;s++&h1&J_~q&!aerXcPv; z4`zQQfjI=rs6src4%8j%{qO8WWgm*}zWJpKyl%7rb>$L3UGm;~SlQ6Bdee7I*tdyH zH1M}d+6sQgvGD%7tIM)KL*7ajv>V^Rb1H3ioh4q@9kTA{{9p-#N~`eAcslv=P#cD= zWjOdukDnvNA;<8*SUkyfzLl>`%{^6)rIUO*;Yd;sVQ9#d|DA4DT0*LsCG-4w$ar|W z_0P_}pH<7(9YhL?{h5nM59(c0!3T zMT(R{Wa-`vjus1=ocV>|hK#JPZU*K;>fkURCF)s!HNZ-e9p7m1?RM-bML5`zfP`Bou5Cx%+(&G1LFaCYSr$P(rek%Q2_o@Y; zYems51a+_N+v5hjTY<*18bK zI%J}(FZ!s@Ypf`Z;p+UjfKM;`;89b5d=dWz5xs6&^x@Ig&g!^+hN&teCztp1J}@YP zKb*U2tazCNf=^cAY8|*jL%3aR`bfYrTZVtywjv65&Nc}#=Yt5_eI}-BTlSicJH%yn z+hBVv-y~{`igV>~v@^@ik3U#?$n$<}0|zwGG8`EZ_?p*90pu4OkwReIHTrt*YZ^Ua zen@x90ZW^~6n2xbI~n`}DF~bAh0Ap!TU3Ye=WbcbYFb0sS}y%kd-+R;*9+2}&J%zd zVSj!F_hQ!pm><$%k)%7Kpswul{_MrQ+Lyo2jVhF<+@JP6?aOfpRLnJ6V1+)Y8E(IKC=a~7xg`eayrT%^RC@AG>IBL z%W9m`VnT~C!dj-uou?`oK%3qkGqUS^0L)p=8OqG}B96Vko*w2tv?lpZFGeWv+=Ap( z{DW*fylOT{mr6&P7(}v*XGXKFSf?z9^P2{Vb4}NUp@57zd=aCGpnans#3EsQDQ0;A zTB1qal*!C4tUL=BH*XvyOJge5E{LEFb`{W&agjJti|pP=u1^n~AQNu};VGZ1`tW#*G)Aw7y7L-sC{Q;4WbsCc6?d$Zlp!rBRiqdA9y zOktxy-Nake|9|&U2o*4J_iA4Py5OGQQxFKS%`mtN0X4V->F>R|#UD|&qh+TvT7C=& z1MQ>&?N|VR4SoT)r77Qkf1{jZ)u^X|cA)50upRKpr-uq@1MZT zJg|4c_L*+~>vd9gN!_bjNoil&qE-iZom{T}$1bUlE!crwQnP-)P9uGqeeZX#+T@@2 YOQyWvBm3U(W8nSTgL{B4KY~F1ACfs70{{R3 literal 0 HcmV?d00001