T1.1: Add v2 MediaHeader type
This commit is contained in:
@@ -23,15 +23,15 @@ pub mod session;
|
|||||||
pub mod traits;
|
pub mod traits;
|
||||||
|
|
||||||
// Re-export key types at crate root for convenience.
|
// Re-export key types at crate root for convenience.
|
||||||
|
pub use bandwidth::{BandwidthEstimator, CongestionState};
|
||||||
pub use codec_id::{CodecId, QualityProfile};
|
pub use codec_id::{CodecId, QualityProfile};
|
||||||
|
pub use dred_tuner::{DredTuner, DredTuning};
|
||||||
pub use error::*;
|
pub use error::*;
|
||||||
pub use packet::{
|
pub use packet::{
|
||||||
CallAcceptMode, HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader,
|
CallAcceptMode, FRAME_TYPE_FULL, FRAME_TYPE_MINI, HangupReason, MediaHeader, MediaHeaderV1,
|
||||||
PresenceUser, QualityReport, RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL,
|
MediaHeaderV2, MediaPacket, MiniFrameContext, MiniHeader, PresenceUser, QualityReport,
|
||||||
FRAME_TYPE_MINI,
|
RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame,
|
||||||
};
|
};
|
||||||
pub use bandwidth::{BandwidthEstimator, CongestionState};
|
|
||||||
pub use dred_tuner::{DredTuner, DredTuning};
|
|
||||||
pub use quality::{AdaptiveQualityController, NetworkContext, Tier};
|
pub use quality::{AdaptiveQualityController, NetworkContext, Tier};
|
||||||
pub use session::{Session, SessionEvent, SessionState};
|
pub use session::{Session, SessionEvent, SessionState};
|
||||||
pub use traits::*;
|
pub use traits::*;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::CodecId;
|
use crate::CodecId;
|
||||||
|
|
||||||
/// 12-byte media packet header for the lossy link.
|
/// 12-byte v1 media packet header for the lossy link.
|
||||||
///
|
///
|
||||||
/// Wire layout:
|
/// Wire layout:
|
||||||
/// ```text
|
/// ```text
|
||||||
@@ -17,7 +17,7 @@ use crate::CodecId;
|
|||||||
/// Byte 11: CSRC count
|
/// Byte 11: CSRC count
|
||||||
/// ```
|
/// ```
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub struct MediaHeader {
|
pub struct MediaHeaderV1 {
|
||||||
/// Protocol version (0 = v1).
|
/// Protocol version (0 = v1).
|
||||||
pub version: u8,
|
pub version: u8,
|
||||||
/// true = FEC repair packet, false = source media.
|
/// true = FEC repair packet, false = source media.
|
||||||
@@ -42,7 +42,7 @@ pub struct MediaHeader {
|
|||||||
pub csrc_count: u8,
|
pub csrc_count: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MediaHeader {
|
impl MediaHeaderV1 {
|
||||||
/// Header size in bytes on the wire.
|
/// Header size in bytes on the wire.
|
||||||
pub const WIRE_SIZE: usize = 12;
|
pub const WIRE_SIZE: usize = 12;
|
||||||
|
|
||||||
@@ -156,6 +156,88 @@ impl MediaHeader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Temporary alias so existing code continues to compile.
|
||||||
|
/// Removed in T1.5 once all emit/parse sites migrate to v2.
|
||||||
|
pub type MediaHeader = MediaHeaderV1;
|
||||||
|
|
||||||
|
/// 16-byte v2 media header. See docs/PRD/PRD-wire-format-v2.md.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct MediaHeaderV2 {
|
||||||
|
pub version: u8, // always 2
|
||||||
|
pub flags: u8, // bit 7 T, bit 6 Q, bit 5 KeyFrame, bit 4 FrameEnd
|
||||||
|
pub media_type: u8, // TODO(T1.2): replace with MediaType
|
||||||
|
pub codec_id: CodecId,
|
||||||
|
pub stream_id: u8,
|
||||||
|
pub fec_ratio: u8, // 0..200 -> 0.0..2.0
|
||||||
|
pub seq: u32,
|
||||||
|
pub timestamp: u32,
|
||||||
|
pub fec_block: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MediaHeaderV2 {
|
||||||
|
pub const WIRE_SIZE: usize = 16;
|
||||||
|
pub const VERSION: u8 = 2;
|
||||||
|
|
||||||
|
pub fn write_to(&self, buf: &mut impl BufMut) {
|
||||||
|
buf.put_u8(self.version);
|
||||||
|
buf.put_u8(self.flags);
|
||||||
|
buf.put_u8(self.media_type);
|
||||||
|
buf.put_u8(self.codec_id.to_wire());
|
||||||
|
buf.put_u8(self.stream_id);
|
||||||
|
buf.put_u8(self.fec_ratio);
|
||||||
|
buf.put_u32(self.seq);
|
||||||
|
buf.put_u32(self.timestamp);
|
||||||
|
buf.put_u16(self.fec_block);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_from(buf: &mut impl Buf) -> Option<Self> {
|
||||||
|
if buf.remaining() < Self::WIRE_SIZE {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let version = buf.get_u8();
|
||||||
|
if version != Self::VERSION {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let flags = buf.get_u8();
|
||||||
|
let media_type = buf.get_u8();
|
||||||
|
let codec_id = CodecId::from_wire(buf.get_u8())?;
|
||||||
|
let stream_id = buf.get_u8();
|
||||||
|
let fec_ratio = buf.get_u8();
|
||||||
|
let seq = buf.get_u32();
|
||||||
|
let timestamp = buf.get_u32();
|
||||||
|
let fec_block = buf.get_u16();
|
||||||
|
Some(Self {
|
||||||
|
version,
|
||||||
|
flags,
|
||||||
|
media_type,
|
||||||
|
codec_id,
|
||||||
|
stream_id,
|
||||||
|
fec_ratio,
|
||||||
|
seq,
|
||||||
|
timestamp,
|
||||||
|
fec_block,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const FLAG_REPAIR: u8 = 0b1000_0000;
|
||||||
|
pub const FLAG_QUALITY: u8 = 0b0100_0000;
|
||||||
|
pub const FLAG_KEYFRAME: u8 = 0b0010_0000;
|
||||||
|
pub const FLAG_FRAME_END: u8 = 0b0001_0000;
|
||||||
|
|
||||||
|
pub fn is_repair(&self) -> bool {
|
||||||
|
self.flags & Self::FLAG_REPAIR != 0
|
||||||
|
}
|
||||||
|
pub fn has_quality(&self) -> bool {
|
||||||
|
self.flags & Self::FLAG_QUALITY != 0
|
||||||
|
}
|
||||||
|
pub fn is_keyframe(&self) -> bool {
|
||||||
|
self.flags & Self::FLAG_KEYFRAME != 0
|
||||||
|
}
|
||||||
|
pub fn is_frame_end(&self) -> bool {
|
||||||
|
self.flags & Self::FLAG_FRAME_END != 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A user visible in the signal presence list.
|
/// A user visible in the signal presence list.
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct PresenceUser {
|
pub struct PresenceUser {
|
||||||
@@ -286,18 +368,13 @@ impl MediaPacket {
|
|||||||
/// Uses the `MiniFrameContext` to decide whether to emit a compact 4-byte
|
/// Uses the `MiniFrameContext` to decide whether to emit a compact 4-byte
|
||||||
/// mini-header or a full 12-byte header. A full header is forced on the
|
/// mini-header or a full 12-byte header. A full header is forced on the
|
||||||
/// first frame and every `MINI_FRAME_FULL_INTERVAL` frames thereafter.
|
/// first frame and every `MINI_FRAME_FULL_INTERVAL` frames thereafter.
|
||||||
pub fn encode_compact(
|
pub fn encode_compact(&self, ctx: &mut MiniFrameContext, frames_since_full: &mut u32) -> Bytes {
|
||||||
&self,
|
|
||||||
ctx: &mut MiniFrameContext,
|
|
||||||
frames_since_full: &mut u32,
|
|
||||||
) -> Bytes {
|
|
||||||
if *frames_since_full > 0 && *frames_since_full < MINI_FRAME_FULL_INTERVAL {
|
if *frames_since_full > 0 && *frames_since_full < MINI_FRAME_FULL_INTERVAL {
|
||||||
// --- mini frame ---
|
// --- mini frame ---
|
||||||
let ts_delta = self
|
let ts_delta = self
|
||||||
.header
|
.header
|
||||||
.timestamp
|
.timestamp
|
||||||
.wrapping_sub(ctx.last_header.unwrap().timestamp)
|
.wrapping_sub(ctx.last_header.unwrap().timestamp) as u16;
|
||||||
as u16;
|
|
||||||
let mini = MiniHeader {
|
let mini = MiniHeader {
|
||||||
timestamp_delta_ms: ts_delta,
|
timestamp_delta_ms: ts_delta,
|
||||||
payload_len: self.payload.len() as u16,
|
payload_len: self.payload.len() as u16,
|
||||||
@@ -407,6 +484,12 @@ pub struct TrunkFrame {
|
|||||||
pub packets: Vec<TrunkEntry>,
|
pub packets: Vec<TrunkEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for TrunkFrame {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TrunkFrame {
|
impl TrunkFrame {
|
||||||
/// Create an empty trunk frame.
|
/// Create an empty trunk frame.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
@@ -460,7 +543,7 @@ impl TrunkFrame {
|
|||||||
if buf.len() < 2 {
|
if buf.len() < 2 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let mut cursor = &buf[..];
|
let mut cursor = buf;
|
||||||
let count = cursor.get_u16() as usize;
|
let count = cursor.get_u16() as usize;
|
||||||
let mut packets = Vec::with_capacity(count);
|
let mut packets = Vec::with_capacity(count);
|
||||||
for _ in 0..count {
|
for _ in 0..count {
|
||||||
@@ -626,8 +709,12 @@ pub enum SignalMessage {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/// Connection keepalive / RTT measurement.
|
/// Connection keepalive / RTT measurement.
|
||||||
Ping { timestamp_ms: u64 },
|
Ping {
|
||||||
Pong { timestamp_ms: u64 },
|
timestamp_ms: u64,
|
||||||
|
},
|
||||||
|
Pong {
|
||||||
|
timestamp_ms: u64,
|
||||||
|
},
|
||||||
|
|
||||||
/// End the call. `call_id` is optional for backwards compatibility
|
/// End the call. `call_id` is optional for backwards compatibility
|
||||||
/// with older clients that send Hangup without it — the relay falls
|
/// with older clients that send Hangup without it — the relay falls
|
||||||
@@ -640,7 +727,9 @@ pub enum SignalMessage {
|
|||||||
|
|
||||||
/// featherChat bearer token for relay authentication.
|
/// featherChat bearer token for relay authentication.
|
||||||
/// Sent as the first signal message when --auth-url is configured.
|
/// Sent as the first signal message when --auth-url is configured.
|
||||||
AuthToken { token: String },
|
AuthToken {
|
||||||
|
token: String,
|
||||||
|
},
|
||||||
|
|
||||||
/// Put the call on hold (stop sending media, keep session alive).
|
/// Put the call on hold (stop sending media, keep session alive).
|
||||||
Hold,
|
Hold,
|
||||||
@@ -705,7 +794,6 @@ pub enum SignalMessage {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Federation signals (relay-to-relay) ──
|
// ── Federation signals (relay-to-relay) ──
|
||||||
|
|
||||||
/// Federation: initial handshake — the connecting relay identifies itself.
|
/// Federation: initial handshake — the connecting relay identifies itself.
|
||||||
FederationHello {
|
FederationHello {
|
||||||
/// TLS certificate fingerprint of the connecting relay.
|
/// TLS certificate fingerprint of the connecting relay.
|
||||||
@@ -726,7 +814,6 @@ pub enum SignalMessage {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Direct calling signals (client ↔ relay signaling) ──
|
// ── Direct calling signals (client ↔ relay signaling) ──
|
||||||
|
|
||||||
/// Register on relay for direct calls. Sent on `_signal` connections
|
/// Register on relay for direct calls. Sent on `_signal` connections
|
||||||
/// after optional AuthToken.
|
/// after optional AuthToken.
|
||||||
RegisterPresence {
|
RegisterPresence {
|
||||||
@@ -882,7 +969,6 @@ pub enum SignalMessage {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── NAT reflection ("STUN for QUIC") ──────────────────────────────
|
// ── NAT reflection ("STUN for QUIC") ──────────────────────────────
|
||||||
|
|
||||||
/// Client → relay: "please tell me the source IP:port you see on
|
/// Client → relay: "please tell me the source IP:port you see on
|
||||||
/// this connection". A QUIC-native replacement for classic STUN
|
/// this connection". A QUIC-native replacement for classic STUN
|
||||||
/// that reuses the TLS-authenticated signal channel to the relay
|
/// that reuses the TLS-authenticated signal channel to the relay
|
||||||
@@ -905,7 +991,6 @@ pub enum SignalMessage {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Phase 6: ICE-style path negotiation ─────────────────────
|
// ── Phase 6: ICE-style path negotiation ─────────────────────
|
||||||
|
|
||||||
/// Phase 6: each side reports the result of its local dual-
|
/// Phase 6: each side reports the result of its local dual-
|
||||||
/// path race to the other side through the relay. Both peers
|
/// path race to the other side through the relay. Both peers
|
||||||
/// send this after their race completes; both wait for the
|
/// send this after their race completes; both wait for the
|
||||||
@@ -930,7 +1015,6 @@ pub enum SignalMessage {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Phase 8: mid-call ICE re-gathering ────────────────────────
|
// ── Phase 8: mid-call ICE re-gathering ────────────────────────
|
||||||
|
|
||||||
/// Phase 8 (Tailscale-inspired): mid-call candidate update sent
|
/// Phase 8 (Tailscale-inspired): mid-call candidate update sent
|
||||||
/// when a client's network changes (WiFi → cellular, IP change,
|
/// when a client's network changes (WiFi → cellular, IP change,
|
||||||
/// etc.). The relay forwards this to the call peer, who can
|
/// etc.). The relay forwards this to the call peer, who can
|
||||||
@@ -956,7 +1040,6 @@ pub enum SignalMessage {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Hard NAT traversal (port prediction) ──────────────────────
|
// ── Hard NAT traversal (port prediction) ──────────────────────
|
||||||
|
|
||||||
/// Hard NAT probe coordination — exchanged when both peers
|
/// Hard NAT probe coordination — exchanged when both peers
|
||||||
/// detect symmetric NAT. Carries the port allocation pattern
|
/// detect symmetric NAT. Carries the port allocation pattern
|
||||||
/// and recent port sequence so the peer can predict which port
|
/// and recent port sequence so the peer can predict which port
|
||||||
@@ -989,7 +1072,6 @@ pub enum SignalMessage {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Phase 4: cross-relay direct-call signaling ────────────────────
|
// ── Phase 4: cross-relay direct-call signaling ────────────────────
|
||||||
|
|
||||||
/// Phase 4: relay-to-relay envelope for forwarding direct-call
|
/// Phase 4: relay-to-relay envelope for forwarding direct-call
|
||||||
/// signaling across a federation link. When Alice on Relay A
|
/// signaling across a federation link. When Alice on Relay A
|
||||||
/// sends a `DirectCallOffer` for Bob whose fingerprint isn't
|
/// sends a `DirectCallOffer` for Bob whose fingerprint isn't
|
||||||
@@ -1029,7 +1111,6 @@ pub enum SignalMessage {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Signal presence ───────────────────────────────────────────
|
// ── Signal presence ───────────────────────────────────────────
|
||||||
|
|
||||||
/// Relay broadcasts the list of currently registered signal
|
/// Relay broadcasts the list of currently registered signal
|
||||||
/// users to all connected clients. Sent on every register/
|
/// users to all connected clients. Sent on every register/
|
||||||
/// deregister so clients can maintain a live lobby user list.
|
/// deregister so clients can maintain a live lobby user list.
|
||||||
@@ -1039,7 +1120,6 @@ pub enum SignalMessage {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Quality upgrade negotiation (#28, #29) ──────────────────
|
// ── Quality upgrade negotiation (#28, #29) ──────────────────
|
||||||
|
|
||||||
/// Peer proposes upgrading to a higher quality profile.
|
/// Peer proposes upgrading to a higher quality profile.
|
||||||
/// The other side can accept or reject based on its own network
|
/// The other side can accept or reject based on its own network
|
||||||
/// conditions. Used for consensual upgrades that require both
|
/// conditions. Used for consensual upgrades that require both
|
||||||
@@ -1077,7 +1157,6 @@ pub enum SignalMessage {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Per-participant quality (#30) ───────────────────────────
|
// ── Per-participant quality (#30) ───────────────────────────
|
||||||
|
|
||||||
/// Peer reports its own quality capability — allows asymmetric
|
/// Peer reports its own quality capability — allows asymmetric
|
||||||
/// encoding where each side uses the best quality its connection
|
/// encoding where each side uses the best quality its connection
|
||||||
/// supports, rather than forcing all to the weakest link.
|
/// supports, rather than forcing all to the weakest link.
|
||||||
@@ -1205,6 +1284,27 @@ mod tests {
|
|||||||
assert_eq!(header, decoded);
|
assert_eq!(header, decoded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn media_header_v2_roundtrip() {
|
||||||
|
let h = MediaHeaderV2 {
|
||||||
|
version: 2,
|
||||||
|
flags: MediaHeaderV2::FLAG_QUALITY,
|
||||||
|
media_type: 0, // TODO(T1.2): MediaType::Audio
|
||||||
|
codec_id: CodecId::Opus24k,
|
||||||
|
stream_id: 0,
|
||||||
|
fec_ratio: 50,
|
||||||
|
seq: 0xDEAD_BEEF,
|
||||||
|
timestamp: 0x1234_5678,
|
||||||
|
fec_block: 0xABCD,
|
||||||
|
};
|
||||||
|
let mut buf = BytesMut::with_capacity(MediaHeaderV2::WIRE_SIZE);
|
||||||
|
h.write_to(&mut buf);
|
||||||
|
assert_eq!(buf.len(), 16);
|
||||||
|
let mut cursor = std::io::Cursor::new(&buf[..]);
|
||||||
|
let parsed = MediaHeaderV2::read_from(&mut cursor).unwrap();
|
||||||
|
assert_eq!(h, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn quality_report_roundtrip() {
|
fn quality_report_roundtrip() {
|
||||||
let qr = QualityReport {
|
let qr = QualityReport {
|
||||||
@@ -1278,7 +1378,8 @@ mod tests {
|
|||||||
SignalMessage::ReflectResponse { observed_addr } => {
|
SignalMessage::ReflectResponse { observed_addr } => {
|
||||||
assert_eq!(observed_addr, addr);
|
assert_eq!(observed_addr, addr);
|
||||||
// Must parse back to a SocketAddr cleanly.
|
// Must parse back to a SocketAddr cleanly.
|
||||||
let _parsed: std::net::SocketAddr = observed_addr.parse()
|
let _parsed: std::net::SocketAddr = observed_addr
|
||||||
|
.parse()
|
||||||
.expect("observed_addr must parse as SocketAddr");
|
.expect("observed_addr must parse as SocketAddr");
|
||||||
}
|
}
|
||||||
_ => panic!("wrong variant after roundtrip"),
|
_ => panic!("wrong variant after roundtrip"),
|
||||||
@@ -1311,7 +1412,10 @@ mod tests {
|
|||||||
let json = serde_json::to_string(&forward).unwrap();
|
let json = serde_json::to_string(&forward).unwrap();
|
||||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
SignalMessage::FederatedSignalForward { inner, origin_relay_fp } => {
|
SignalMessage::FederatedSignalForward {
|
||||||
|
inner,
|
||||||
|
origin_relay_fp,
|
||||||
|
} => {
|
||||||
assert_eq!(origin_relay_fp, "relay-a-tls-fp");
|
assert_eq!(origin_relay_fp, "relay-a-tls-fp");
|
||||||
match *inner {
|
match *inner {
|
||||||
SignalMessage::DirectCallOffer {
|
SignalMessage::DirectCallOffer {
|
||||||
@@ -1348,8 +1452,13 @@ mod tests {
|
|||||||
callee_mapped_addr: None,
|
callee_mapped_addr: None,
|
||||||
callee_build_version: None,
|
callee_build_version: None,
|
||||||
},
|
},
|
||||||
SignalMessage::CallRinging { call_id: "c1".into() },
|
SignalMessage::CallRinging {
|
||||||
SignalMessage::Hangup { reason: HangupReason::Normal, call_id: None },
|
call_id: "c1".into(),
|
||||||
|
},
|
||||||
|
SignalMessage::Hangup {
|
||||||
|
reason: HangupReason::Normal,
|
||||||
|
call_id: None,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
for inner in cases {
|
for inner in cases {
|
||||||
let inner_disc = std::mem::discriminant(&inner);
|
let inner_disc = std::mem::discriminant(&inner);
|
||||||
@@ -1392,7 +1501,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
SignalMessage::DirectCallOffer { caller_reflexive_addr, .. } => {
|
SignalMessage::DirectCallOffer {
|
||||||
|
caller_reflexive_addr,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
assert_eq!(caller_reflexive_addr.as_deref(), Some("192.0.2.1:4433"));
|
assert_eq!(caller_reflexive_addr.as_deref(), Some("192.0.2.1:4433"));
|
||||||
}
|
}
|
||||||
_ => panic!("wrong variant"),
|
_ => panic!("wrong variant"),
|
||||||
@@ -1437,11 +1549,11 @@ mod tests {
|
|||||||
let decoded: SignalMessage =
|
let decoded: SignalMessage =
|
||||||
serde_json::from_str(&serde_json::to_string(&answer).unwrap()).unwrap();
|
serde_json::from_str(&serde_json::to_string(&answer).unwrap()).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
SignalMessage::DirectCallAnswer { callee_reflexive_addr, .. } => {
|
SignalMessage::DirectCallAnswer {
|
||||||
assert_eq!(
|
callee_reflexive_addr,
|
||||||
callee_reflexive_addr.as_deref(),
|
..
|
||||||
Some("198.51.100.9:4433")
|
} => {
|
||||||
);
|
assert_eq!(callee_reflexive_addr.as_deref(), Some("198.51.100.9:4433"));
|
||||||
}
|
}
|
||||||
_ => panic!("wrong variant"),
|
_ => panic!("wrong variant"),
|
||||||
}
|
}
|
||||||
@@ -1458,7 +1570,9 @@ mod tests {
|
|||||||
let decoded: SignalMessage =
|
let decoded: SignalMessage =
|
||||||
serde_json::from_str(&serde_json::to_string(&setup).unwrap()).unwrap();
|
serde_json::from_str(&serde_json::to_string(&setup).unwrap()).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
SignalMessage::CallSetup { peer_direct_addr, .. } => {
|
SignalMessage::CallSetup {
|
||||||
|
peer_direct_addr, ..
|
||||||
|
} => {
|
||||||
assert_eq!(peer_direct_addr.as_deref(), Some("192.0.2.1:4433"));
|
assert_eq!(peer_direct_addr.as_deref(), Some("192.0.2.1:4433"));
|
||||||
}
|
}
|
||||||
_ => panic!("wrong variant"),
|
_ => panic!("wrong variant"),
|
||||||
@@ -1484,7 +1598,10 @@ mod tests {
|
|||||||
}"#;
|
}"#;
|
||||||
let decoded: SignalMessage = serde_json::from_str(old_offer_json).unwrap();
|
let decoded: SignalMessage = serde_json::from_str(old_offer_json).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
SignalMessage::DirectCallOffer { caller_reflexive_addr, .. } => {
|
SignalMessage::DirectCallOffer {
|
||||||
|
caller_reflexive_addr,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
assert!(caller_reflexive_addr.is_none());
|
assert!(caller_reflexive_addr.is_none());
|
||||||
}
|
}
|
||||||
_ => panic!("wrong variant"),
|
_ => panic!("wrong variant"),
|
||||||
@@ -1499,7 +1616,9 @@ mod tests {
|
|||||||
}"#;
|
}"#;
|
||||||
let decoded: SignalMessage = serde_json::from_str(old_setup_json).unwrap();
|
let decoded: SignalMessage = serde_json::from_str(old_setup_json).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
SignalMessage::CallSetup { peer_direct_addr, .. } => {
|
SignalMessage::CallSetup {
|
||||||
|
peer_direct_addr, ..
|
||||||
|
} => {
|
||||||
assert!(peer_direct_addr.is_none());
|
assert!(peer_direct_addr.is_none());
|
||||||
}
|
}
|
||||||
_ => panic!("wrong variant"),
|
_ => panic!("wrong variant"),
|
||||||
@@ -1512,19 +1631,23 @@ mod tests {
|
|||||||
// not break JSON round-tripping of existing variants. Smoke-
|
// not break JSON round-tripping of existing variants. Smoke-
|
||||||
// test a sample of the pre-existing ones.
|
// test a sample of the pre-existing ones.
|
||||||
let cases = vec![
|
let cases = vec![
|
||||||
SignalMessage::Ping { timestamp_ms: 12345 },
|
SignalMessage::Ping {
|
||||||
|
timestamp_ms: 12345,
|
||||||
|
},
|
||||||
SignalMessage::Hold,
|
SignalMessage::Hold,
|
||||||
SignalMessage::Hangup { reason: HangupReason::Normal, call_id: None },
|
SignalMessage::Hangup {
|
||||||
SignalMessage::CallRinging { call_id: "abcd".into() },
|
reason: HangupReason::Normal,
|
||||||
|
call_id: None,
|
||||||
|
},
|
||||||
|
SignalMessage::CallRinging {
|
||||||
|
call_id: "abcd".into(),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
for m in cases {
|
for m in cases {
|
||||||
let json = serde_json::to_string(&m).unwrap();
|
let json = serde_json::to_string(&m).unwrap();
|
||||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||||
// Discriminant equality proves variant tag survived.
|
// Discriminant equality proves variant tag survived.
|
||||||
assert_eq!(
|
assert_eq!(std::mem::discriminant(&m), std::mem::discriminant(&decoded));
|
||||||
std::mem::discriminant(&m),
|
|
||||||
std::mem::discriminant(&decoded)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1609,7 +1732,10 @@ mod tests {
|
|||||||
let json = serde_json::to_string(&msg).unwrap();
|
let json = serde_json::to_string(&msg).unwrap();
|
||||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
SignalMessage::PresenceUpdate { fingerprints, relay_addr } => {
|
SignalMessage::PresenceUpdate {
|
||||||
|
fingerprints,
|
||||||
|
relay_addr,
|
||||||
|
} => {
|
||||||
assert_eq!(fingerprints.len(), 2);
|
assert_eq!(fingerprints.len(), 2);
|
||||||
assert!(fingerprints.contains(&"aabb".to_string()));
|
assert!(fingerprints.contains(&"aabb".to_string()));
|
||||||
assert!(fingerprints.contains(&"ccdd".to_string()));
|
assert!(fingerprints.contains(&"ccdd".to_string()));
|
||||||
@@ -1626,7 +1752,10 @@ mod tests {
|
|||||||
let json = serde_json::to_string(&msg_empty).unwrap();
|
let json = serde_json::to_string(&msg_empty).unwrap();
|
||||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
SignalMessage::PresenceUpdate { fingerprints, relay_addr } => {
|
SignalMessage::PresenceUpdate {
|
||||||
|
fingerprints,
|
||||||
|
relay_addr,
|
||||||
|
} => {
|
||||||
assert!(fingerprints.is_empty());
|
assert!(fingerprints.is_empty());
|
||||||
assert_eq!(relay_addr, "10.0.0.2:4433");
|
assert_eq!(relay_addr, "10.0.0.2:4433");
|
||||||
}
|
}
|
||||||
@@ -1859,15 +1988,9 @@ mod tests {
|
|||||||
let wire = pkt.encode_compact(&mut ctx, &mut frames_since_full);
|
let wire = pkt.encode_compact(&mut ctx, &mut frames_since_full);
|
||||||
|
|
||||||
if i == 0 || i == MINI_FRAME_FULL_INTERVAL {
|
if i == 0 || i == MINI_FRAME_FULL_INTERVAL {
|
||||||
assert_eq!(
|
assert_eq!(wire[0], FRAME_TYPE_FULL, "frame {i} should be FULL");
|
||||||
wire[0], FRAME_TYPE_FULL,
|
|
||||||
"frame {i} should be FULL"
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
assert_eq!(
|
assert_eq!(wire[0], FRAME_TYPE_MINI, "frame {i} should be MINI");
|
||||||
wire[0], FRAME_TYPE_MINI,
|
|
||||||
"frame {i} should be MINI"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1881,7 +2004,10 @@ mod tests {
|
|||||||
let json = serde_json::to_string(&msg).unwrap();
|
let json = serde_json::to_string(&msg).unwrap();
|
||||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
SignalMessage::QualityDirective { recommended_profile, reason } => {
|
SignalMessage::QualityDirective {
|
||||||
|
recommended_profile,
|
||||||
|
reason,
|
||||||
|
} => {
|
||||||
assert_eq!(recommended_profile.codec, CodecId::Opus6k);
|
assert_eq!(recommended_profile.codec, CodecId::Opus6k);
|
||||||
assert_eq!(reason.as_deref(), Some("weakest link degraded"));
|
assert_eq!(reason.as_deref(), Some("weakest link degraded"));
|
||||||
}
|
}
|
||||||
@@ -1920,7 +2046,10 @@ mod tests {
|
|||||||
// We test the raw path: frames_since_full forced to 0 every time.
|
// We test the raw path: frames_since_full forced to 0 every time.
|
||||||
let mut frames_since_full: u32 = 0;
|
let mut frames_since_full: u32 = 0;
|
||||||
let wire = pkt.encode_compact(&mut ctx, &mut frames_since_full);
|
let wire = pkt.encode_compact(&mut ctx, &mut frames_since_full);
|
||||||
assert_eq!(wire[0], FRAME_TYPE_FULL, "frame {i} should be FULL when disabled");
|
assert_eq!(
|
||||||
|
wire[0], FRAME_TYPE_FULL,
|
||||||
|
"frame {i} should be FULL when disabled"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1938,7 +2067,11 @@ mod tests {
|
|||||||
let json = serde_json::to_string(&msg).unwrap();
|
let json = serde_json::to_string(&msg).unwrap();
|
||||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
SignalMessage::UpgradeProposal { proposal_id, proposed_profile, .. } => {
|
SignalMessage::UpgradeProposal {
|
||||||
|
proposal_id,
|
||||||
|
proposed_profile,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
assert_eq!(proposal_id, "p1");
|
assert_eq!(proposal_id, "p1");
|
||||||
assert_eq!(proposed_profile, crate::QualityProfile::STUDIO_48K);
|
assert_eq!(proposed_profile, crate::QualityProfile::STUDIO_48K);
|
||||||
}
|
}
|
||||||
@@ -1972,7 +2105,9 @@ mod tests {
|
|||||||
let json = serde_json::to_string(&msg).unwrap();
|
let json = serde_json::to_string(&msg).unwrap();
|
||||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
SignalMessage::UpgradeConfirm { confirmed_profile, .. } => {
|
SignalMessage::UpgradeConfirm {
|
||||||
|
confirmed_profile, ..
|
||||||
|
} => {
|
||||||
assert_eq!(confirmed_profile, crate::QualityProfile::STUDIO_64K);
|
assert_eq!(confirmed_profile, crate::QualityProfile::STUDIO_64K);
|
||||||
}
|
}
|
||||||
_ => panic!("wrong variant"),
|
_ => panic!("wrong variant"),
|
||||||
@@ -1990,7 +2125,11 @@ mod tests {
|
|||||||
let json = serde_json::to_string(&msg).unwrap();
|
let json = serde_json::to_string(&msg).unwrap();
|
||||||
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
SignalMessage::QualityCapability { max_profile, loss_pct, .. } => {
|
SignalMessage::QualityCapability {
|
||||||
|
max_profile,
|
||||||
|
loss_pct,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
assert_eq!(max_profile, crate::QualityProfile::GOOD);
|
assert_eq!(max_profile, crate::QualityProfile::GOOD);
|
||||||
assert!((loss_pct.unwrap() - 2.5).abs() < 0.01);
|
assert!((loss_pct.unwrap() - 2.5).abs() < 0.01);
|
||||||
}
|
}
|
||||||
@@ -2005,10 +2144,7 @@ mod tests {
|
|||||||
let msg = SignalMessage::CandidateUpdate {
|
let msg = SignalMessage::CandidateUpdate {
|
||||||
call_id: "test-123".into(),
|
call_id: "test-123".into(),
|
||||||
reflexive_addr: Some("203.0.113.5:4433".into()),
|
reflexive_addr: Some("203.0.113.5:4433".into()),
|
||||||
local_addrs: vec![
|
local_addrs: vec!["192.168.1.10:4433".into(), "10.0.0.5:4433".into()],
|
||||||
"192.168.1.10:4433".into(),
|
|
||||||
"10.0.0.5:4433".into(),
|
|
||||||
],
|
|
||||||
mapped_addr: Some("198.51.100.42:12345".into()),
|
mapped_addr: Some("198.51.100.42:12345".into()),
|
||||||
generation: 7,
|
generation: 7,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use crate::QualityProfile;
|
||||||
use crate::packet::QualityReport;
|
use crate::packet::QualityReport;
|
||||||
use crate::traits::QualityController;
|
use crate::traits::QualityController;
|
||||||
use crate::QualityProfile;
|
|
||||||
|
|
||||||
/// Network quality tier — drives codec and FEC selection.
|
/// Network quality tier — drives codec and FEC selection.
|
||||||
///
|
///
|
||||||
@@ -99,21 +99,16 @@ impl Tier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Describes the network transport type for context-aware quality decisions.
|
/// Describes the network transport type for context-aware quality decisions.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
pub enum NetworkContext {
|
pub enum NetworkContext {
|
||||||
WiFi,
|
WiFi,
|
||||||
CellularLte,
|
CellularLte,
|
||||||
Cellular5g,
|
Cellular5g,
|
||||||
Cellular3g,
|
Cellular3g,
|
||||||
|
#[default]
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for NetworkContext {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adaptive quality controller with hysteresis to prevent tier flapping.
|
/// Adaptive quality controller with hysteresis to prevent tier flapping.
|
||||||
///
|
///
|
||||||
/// - Downgrade: 3 consecutive reports in a worse tier (2 on cellular)
|
/// - Downgrade: 3 consecutive reports in a worse tier (2 on cellular)
|
||||||
@@ -340,8 +335,7 @@ impl AdaptiveQualityController {
|
|||||||
if probe.bad_reports > PROBE_MAX_BAD {
|
if probe.bad_reports > PROBE_MAX_BAD {
|
||||||
let _failed_probe = self.probe.take();
|
let _failed_probe = self.probe.take();
|
||||||
// Reset stable_since to trigger cooldown
|
// Reset stable_since to trigger cooldown
|
||||||
self.stable_since =
|
self.stable_since = Some(Instant::now() + Duration::from_secs(PROBE_COOLDOWN_SECS));
|
||||||
Some(Instant::now() + Duration::from_secs(PROBE_COOLDOWN_SECS));
|
|
||||||
return None; // stay at current tier
|
return None; // stay at current tier
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,7 +740,10 @@ mod tests {
|
|||||||
ctrl.observe(°raded); // second bad — exceeds PROBE_MAX_BAD (1)
|
ctrl.observe(°raded); // second bad — exceeds PROBE_MAX_BAD (1)
|
||||||
|
|
||||||
// Probe should be cancelled
|
// Probe should be cancelled
|
||||||
assert!(ctrl.probe.is_none(), "probe should be cancelled after bad reports");
|
assert!(
|
||||||
|
ctrl.probe.is_none(),
|
||||||
|
"probe should be cancelled after bad reports"
|
||||||
|
);
|
||||||
// Should still be at Studio32k (not upgraded)
|
// Should still be at Studio32k (not upgraded)
|
||||||
assert_eq!(ctrl.current_tier, Tier::Studio32k);
|
assert_eq!(ctrl.current_tier, Tier::Studio32k);
|
||||||
}
|
}
|
||||||
@@ -775,6 +772,9 @@ mod tests {
|
|||||||
|
|
||||||
let excellent = make_report(0.1, 10);
|
let excellent = make_report(0.1, 10);
|
||||||
let result = ctrl.observe(&excellent);
|
let result = ctrl.observe(&excellent);
|
||||||
assert!(result.is_none(), "should not probe when already at Studio64k");
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"should not probe when already at Studio64k"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1101
docs/PRD/TASKS.md
Normal file
1101
docs/PRD/TASKS.md
Normal file
File diff suppressed because it is too large
Load Diff
88
docs/PRD/reports/T1.1-report.md
Normal file
88
docs/PRD/reports/T1.1-report.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# T1.1 — Add v2 `MediaHeader` type
|
||||||
|
|
||||||
|
**Status:** Pending Review
|
||||||
|
**Agent:** Kimi Code CLI
|
||||||
|
**Started:** 2026-05-11T06:09Z
|
||||||
|
**Completed:** 2026-05-11T06:54Z
|
||||||
|
**Commit:** see git log
|
||||||
|
**PRD:** ../PRD-wire-format-v2.md
|
||||||
|
|
||||||
|
## What I changed
|
||||||
|
|
||||||
|
- `crates/wzp-proto/src/packet.rs:20` — renamed existing `MediaHeader` → `MediaHeaderV1` (kept all impls intact)
|
||||||
|
- `crates/wzp-proto/src/packet.rs:157` — added `pub type MediaHeader = MediaHeaderV1;` backward-compat alias so the workspace continues to compile
|
||||||
|
- `crates/wzp-proto/src/packet.rs:160-238` — added new `MediaHeaderV2` struct (16 bytes, byte-aligned) with `write_to`, `read_from`, and flag accessors
|
||||||
|
- `crates/wzp-proto/src/packet.rs:1270-1285` — added `media_header_v2_roundtrip` test
|
||||||
|
- `crates/wzp-proto/src/lib.rs:28` — re-exported `MediaHeaderV1` and `MediaHeaderV2`
|
||||||
|
- `crates/wzp-proto/src/packet.rs:487-493` — added `impl Default for TrunkFrame` (pre-existing clippy fix)
|
||||||
|
- `crates/wzp-proto/src/packet.rs:540` — removed redundant slicing `&buf[..]` → `buf` (pre-existing clippy fix)
|
||||||
|
- `crates/wzp-proto/src/quality.rs:102-109` — derived `Default` for `NetworkContext` with `#[default]` on `Unknown` (pre-existing clippy fix)
|
||||||
|
|
||||||
|
## Why these choices
|
||||||
|
|
||||||
|
Rust does not allow a type alias and a struct with the same name in the same module. The task requires both (a) keeping the old struct accessible as `MediaHeader` so the workspace builds, and (b) adding a new struct also called `MediaHeader`. The pragmatic resolution is to name the new struct `MediaHeaderV2` and export it; T1.5 will delete `MediaHeaderV1`, remove the alias, and rename `MediaHeaderV2` → `MediaHeader` once all call sites are migrated.
|
||||||
|
|
||||||
|
`CodecId::to_wire` already returns `u8` and was usable immediately. `MediaType` does not exist yet (T1.2), so the `media_type` field is `u8` with a `// TODO(T1.2)` comment.
|
||||||
|
|
||||||
|
## Deviations from the task spec
|
||||||
|
|
||||||
|
1. **Step 3 (struct name):** The new struct is named `MediaHeaderV2` instead of `MediaHeader`. This is required because `pub type MediaHeader = MediaHeaderV1;` occupies the `MediaHeader` name in `packet.rs`. T1.5 will perform the final rename.
|
||||||
|
2. **Step 4 (`MediaType` placeholder):** Used `u8` for `media_type` with an inline `// TODO(T1.2)` comment, matching the fallback instruction in the task.
|
||||||
|
3. **Clippy fixes:** Fixed three pre-existing clippy errors in `wzp-proto` (`new_without_default`, `redundant_slicing`, `derivable_impls`) so the crate passes `-D warnings`.
|
||||||
|
|
||||||
|
## Verification output
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cargo test -p wzp-proto media_header_v2_roundtrip
|
||||||
|
running 1 test
|
||||||
|
test packet::tests::media_header_v2_roundtrip ... ok
|
||||||
|
|
||||||
|
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 105 filtered out; finished in 0.00s
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cargo build -p wzp-proto -p wzp-codec -p wzp-fec -p wzp-crypto -p wzp-transport -p wzp-relay -p wzp-client -p wzp-web -p wzp-native
|
||||||
|
Compiling wzp-proto v0.1.0
|
||||||
|
...
|
||||||
|
Finished `dev` profile [unoptimized + debuginfo] target(s) in 27.24s
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cargo test -p wzp-proto -p wzp-codec -p wzp-fec -p wzp-crypto -p wzp-transport -p wzp-relay -p wzp-client -p wzp-web -p wzp-native --no-fail-fast
|
||||||
|
...
|
||||||
|
test result: ok. 565 passed; 0 failed; ...
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cargo clippy -p wzp-proto --all-targets -- -D warnings
|
||||||
|
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.38s
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cargo fmt --all -- --check
|
||||||
|
# (clean)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test summary
|
||||||
|
|
||||||
|
- Tests added: 1 (`media_header_v2_roundtrip`)
|
||||||
|
- Tests modified: 0
|
||||||
|
- Workspace test count before: 564 pass / 0 fail (non-Android subset)
|
||||||
|
- Workspace test count after: 565 pass / 0 fail (non-Android subset)
|
||||||
|
- `cargo clippy --workspace --all-targets -- -D warnings`: pass for `wzp-proto`; 3 pre-existing failures remain in `deps/featherchat/warzone/crates/warzone-protocol` (git submodule, outside our control)
|
||||||
|
- `cargo fmt --all -- --check`: pass
|
||||||
|
|
||||||
|
## Risks / follow-ups
|
||||||
|
|
||||||
|
- Pre-existing clippy errors in the `featherchat` git submodule (`warzone-protocol`) remain unresolved because they are in a dependency subtree.
|
||||||
|
- `wzp-android` cannot be built or tested on macOS without the Android NDK. All verification uses the non-Android workspace subset.
|
||||||
|
- `MediaHeaderV2` must be renamed to `MediaHeader` in T1.5 after `MediaHeaderV1` is deleted and all call sites are migrated.
|
||||||
|
- `media_type: u8` should become `media_type: MediaType` once T1.2 lands.
|
||||||
|
|
||||||
|
## Reviewer checklist (filled in by reviewer)
|
||||||
|
|
||||||
|
- [ ] Code matches PRD intent
|
||||||
|
- [ ] Verification output is real (re-run if suspicious)
|
||||||
|
- [ ] No backward-incompat surprises
|
||||||
|
- [ ] Tests cover the new behavior
|
||||||
|
- [ ] Approved
|
||||||
Reference in New Issue
Block a user