T1.1: Add v2 MediaHeader type

This commit is contained in:
Siavash Sameni
2026-05-11 10:58:05 +04:00
parent 217567383d
commit 6af0539a72
5 changed files with 1405 additions and 80 deletions

View File

@@ -23,15 +23,15 @@ pub mod session;
pub mod traits;
// Re-export key types at crate root for convenience.
pub use bandwidth::{BandwidthEstimator, CongestionState};
pub use codec_id::{CodecId, QualityProfile};
pub use dred_tuner::{DredTuner, DredTuning};
pub use error::*;
pub use packet::{
CallAcceptMode, HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader,
PresenceUser, QualityReport, RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL,
FRAME_TYPE_MINI,
CallAcceptMode, FRAME_TYPE_FULL, FRAME_TYPE_MINI, HangupReason, MediaHeader, MediaHeaderV1,
MediaHeaderV2, MediaPacket, MiniFrameContext, MiniHeader, PresenceUser, QualityReport,
RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame,
};
pub use bandwidth::{BandwidthEstimator, CongestionState};
pub use dred_tuner::{DredTuner, DredTuning};
pub use quality::{AdaptiveQualityController, NetworkContext, Tier};
pub use session::{Session, SessionEvent, SessionState};
pub use traits::*;

View File

@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
use crate::CodecId;
/// 12-byte media packet header for the lossy link.
/// 12-byte v1 media packet header for the lossy link.
///
/// Wire layout:
/// ```text
@@ -17,7 +17,7 @@ use crate::CodecId;
/// Byte 11: CSRC count
/// ```
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MediaHeader {
pub struct MediaHeaderV1 {
/// Protocol version (0 = v1).
pub version: u8,
/// true = FEC repair packet, false = source media.
@@ -42,7 +42,7 @@ pub struct MediaHeader {
pub csrc_count: u8,
}
impl MediaHeader {
impl MediaHeaderV1 {
/// Header size in bytes on the wire.
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.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PresenceUser {
@@ -286,18 +368,13 @@ impl MediaPacket {
/// 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
/// first frame and every `MINI_FRAME_FULL_INTERVAL` frames thereafter.
pub fn encode_compact(
&self,
ctx: &mut MiniFrameContext,
frames_since_full: &mut u32,
) -> Bytes {
pub fn encode_compact(&self, ctx: &mut MiniFrameContext, frames_since_full: &mut u32) -> Bytes {
if *frames_since_full > 0 && *frames_since_full < MINI_FRAME_FULL_INTERVAL {
// --- mini frame ---
let ts_delta = self
.header
.timestamp
.wrapping_sub(ctx.last_header.unwrap().timestamp)
as u16;
.wrapping_sub(ctx.last_header.unwrap().timestamp) as u16;
let mini = MiniHeader {
timestamp_delta_ms: ts_delta,
payload_len: self.payload.len() as u16,
@@ -407,6 +484,12 @@ pub struct TrunkFrame {
pub packets: Vec<TrunkEntry>,
}
impl Default for TrunkFrame {
fn default() -> Self {
Self::new()
}
}
impl TrunkFrame {
/// Create an empty trunk frame.
pub fn new() -> Self {
@@ -460,7 +543,7 @@ impl TrunkFrame {
if buf.len() < 2 {
return None;
}
let mut cursor = &buf[..];
let mut cursor = buf;
let count = cursor.get_u16() as usize;
let mut packets = Vec::with_capacity(count);
for _ in 0..count {
@@ -626,8 +709,12 @@ pub enum SignalMessage {
},
/// Connection keepalive / RTT measurement.
Ping { timestamp_ms: u64 },
Pong { timestamp_ms: u64 },
Ping {
timestamp_ms: u64,
},
Pong {
timestamp_ms: u64,
},
/// End the call. `call_id` is optional for backwards compatibility
/// with older clients that send Hangup without it — the relay falls
@@ -640,7 +727,9 @@ pub enum SignalMessage {
/// featherChat bearer token for relay authentication.
/// 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).
Hold,
@@ -705,7 +794,6 @@ pub enum SignalMessage {
},
// ── Federation signals (relay-to-relay) ──
/// Federation: initial handshake — the connecting relay identifies itself.
FederationHello {
/// TLS certificate fingerprint of the connecting relay.
@@ -726,7 +814,6 @@ pub enum SignalMessage {
},
// ── Direct calling signals (client ↔ relay signaling) ──
/// Register on relay for direct calls. Sent on `_signal` connections
/// after optional AuthToken.
RegisterPresence {
@@ -882,7 +969,6 @@ pub enum SignalMessage {
},
// ── NAT reflection ("STUN for QUIC") ──────────────────────────────
/// Client → relay: "please tell me the source IP:port you see on
/// this connection". A QUIC-native replacement for classic STUN
/// 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: each side reports the result of its local dual-
/// path race to the other side through the relay. Both peers
/// 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 (Tailscale-inspired): mid-call candidate update sent
/// when a client's network changes (WiFi → cellular, IP change,
/// 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 probe coordination — exchanged when both peers
/// detect symmetric NAT. Carries the port allocation pattern
/// 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: relay-to-relay envelope for forwarding direct-call
/// signaling across a federation link. When Alice on Relay A
/// sends a `DirectCallOffer` for Bob whose fingerprint isn't
@@ -1029,7 +1111,6 @@ pub enum SignalMessage {
},
// ── Signal presence ───────────────────────────────────────────
/// Relay broadcasts the list of currently registered signal
/// users to all connected clients. Sent on every register/
/// deregister so clients can maintain a live lobby user list.
@@ -1039,7 +1120,6 @@ pub enum SignalMessage {
},
// ── Quality upgrade negotiation (#28, #29) ──────────────────
/// Peer proposes upgrading to a higher quality profile.
/// The other side can accept or reject based on its own network
/// conditions. Used for consensual upgrades that require both
@@ -1077,7 +1157,6 @@ pub enum SignalMessage {
},
// ── Per-participant quality (#30) ───────────────────────────
/// Peer reports its own quality capability — allows asymmetric
/// encoding where each side uses the best quality its connection
/// supports, rather than forcing all to the weakest link.
@@ -1205,6 +1284,27 @@ mod tests {
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]
fn quality_report_roundtrip() {
let qr = QualityReport {
@@ -1278,7 +1378,8 @@ mod tests {
SignalMessage::ReflectResponse { observed_addr } => {
assert_eq!(observed_addr, addr);
// 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");
}
_ => panic!("wrong variant after roundtrip"),
@@ -1311,7 +1412,10 @@ mod tests {
let json = serde_json::to_string(&forward).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
match decoded {
SignalMessage::FederatedSignalForward { inner, origin_relay_fp } => {
SignalMessage::FederatedSignalForward {
inner,
origin_relay_fp,
} => {
assert_eq!(origin_relay_fp, "relay-a-tls-fp");
match *inner {
SignalMessage::DirectCallOffer {
@@ -1348,8 +1452,13 @@ mod tests {
callee_mapped_addr: None,
callee_build_version: None,
},
SignalMessage::CallRinging { call_id: "c1".into() },
SignalMessage::Hangup { reason: HangupReason::Normal, call_id: None },
SignalMessage::CallRinging {
call_id: "c1".into(),
},
SignalMessage::Hangup {
reason: HangupReason::Normal,
call_id: None,
},
];
for inner in cases {
let inner_disc = std::mem::discriminant(&inner);
@@ -1392,7 +1501,10 @@ mod tests {
);
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
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"));
}
_ => panic!("wrong variant"),
@@ -1437,11 +1549,11 @@ mod tests {
let decoded: SignalMessage =
serde_json::from_str(&serde_json::to_string(&answer).unwrap()).unwrap();
match decoded {
SignalMessage::DirectCallAnswer { callee_reflexive_addr, .. } => {
assert_eq!(
callee_reflexive_addr.as_deref(),
Some("198.51.100.9:4433")
);
SignalMessage::DirectCallAnswer {
callee_reflexive_addr,
..
} => {
assert_eq!(callee_reflexive_addr.as_deref(), Some("198.51.100.9:4433"));
}
_ => panic!("wrong variant"),
}
@@ -1458,7 +1570,9 @@ mod tests {
let decoded: SignalMessage =
serde_json::from_str(&serde_json::to_string(&setup).unwrap()).unwrap();
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"));
}
_ => panic!("wrong variant"),
@@ -1484,7 +1598,10 @@ mod tests {
}"#;
let decoded: SignalMessage = serde_json::from_str(old_offer_json).unwrap();
match decoded {
SignalMessage::DirectCallOffer { caller_reflexive_addr, .. } => {
SignalMessage::DirectCallOffer {
caller_reflexive_addr,
..
} => {
assert!(caller_reflexive_addr.is_none());
}
_ => panic!("wrong variant"),
@@ -1499,7 +1616,9 @@ mod tests {
}"#;
let decoded: SignalMessage = serde_json::from_str(old_setup_json).unwrap();
match decoded {
SignalMessage::CallSetup { peer_direct_addr, .. } => {
SignalMessage::CallSetup {
peer_direct_addr, ..
} => {
assert!(peer_direct_addr.is_none());
}
_ => panic!("wrong variant"),
@@ -1512,19 +1631,23 @@ mod tests {
// not break JSON round-tripping of existing variants. Smoke-
// test a sample of the pre-existing ones.
let cases = vec![
SignalMessage::Ping { timestamp_ms: 12345 },
SignalMessage::Ping {
timestamp_ms: 12345,
},
SignalMessage::Hold,
SignalMessage::Hangup { reason: HangupReason::Normal, call_id: None },
SignalMessage::CallRinging { call_id: "abcd".into() },
SignalMessage::Hangup {
reason: HangupReason::Normal,
call_id: None,
},
SignalMessage::CallRinging {
call_id: "abcd".into(),
},
];
for m in cases {
let json = serde_json::to_string(&m).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
// Discriminant equality proves variant tag survived.
assert_eq!(
std::mem::discriminant(&m),
std::mem::discriminant(&decoded)
);
assert_eq!(std::mem::discriminant(&m), std::mem::discriminant(&decoded));
}
}
@@ -1609,7 +1732,10 @@ mod tests {
let json = serde_json::to_string(&msg).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
match decoded {
SignalMessage::PresenceUpdate { fingerprints, relay_addr } => {
SignalMessage::PresenceUpdate {
fingerprints,
relay_addr,
} => {
assert_eq!(fingerprints.len(), 2);
assert!(fingerprints.contains(&"aabb".to_string()));
assert!(fingerprints.contains(&"ccdd".to_string()));
@@ -1626,7 +1752,10 @@ mod tests {
let json = serde_json::to_string(&msg_empty).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
match decoded {
SignalMessage::PresenceUpdate { fingerprints, relay_addr } => {
SignalMessage::PresenceUpdate {
fingerprints,
relay_addr,
} => {
assert!(fingerprints.is_empty());
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);
if i == 0 || i == MINI_FRAME_FULL_INTERVAL {
assert_eq!(
wire[0], FRAME_TYPE_FULL,
"frame {i} should be FULL"
);
assert_eq!(wire[0], FRAME_TYPE_FULL, "frame {i} should be FULL");
} else {
assert_eq!(
wire[0], FRAME_TYPE_MINI,
"frame {i} should be MINI"
);
assert_eq!(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 decoded: SignalMessage = serde_json::from_str(&json).unwrap();
match decoded {
SignalMessage::QualityDirective { recommended_profile, reason } => {
SignalMessage::QualityDirective {
recommended_profile,
reason,
} => {
assert_eq!(recommended_profile.codec, CodecId::Opus6k);
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.
let mut frames_since_full: u32 = 0;
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 decoded: SignalMessage = serde_json::from_str(&json).unwrap();
match decoded {
SignalMessage::UpgradeProposal { proposal_id, proposed_profile, .. } => {
SignalMessage::UpgradeProposal {
proposal_id,
proposed_profile,
..
} => {
assert_eq!(proposal_id, "p1");
assert_eq!(proposed_profile, crate::QualityProfile::STUDIO_48K);
}
@@ -1972,7 +2105,9 @@ mod tests {
let json = serde_json::to_string(&msg).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
match decoded {
SignalMessage::UpgradeConfirm { confirmed_profile, .. } => {
SignalMessage::UpgradeConfirm {
confirmed_profile, ..
} => {
assert_eq!(confirmed_profile, crate::QualityProfile::STUDIO_64K);
}
_ => panic!("wrong variant"),
@@ -1990,7 +2125,11 @@ mod tests {
let json = serde_json::to_string(&msg).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
match decoded {
SignalMessage::QualityCapability { max_profile, loss_pct, .. } => {
SignalMessage::QualityCapability {
max_profile,
loss_pct,
..
} => {
assert_eq!(max_profile, crate::QualityProfile::GOOD);
assert!((loss_pct.unwrap() - 2.5).abs() < 0.01);
}
@@ -2005,10 +2144,7 @@ mod tests {
let msg = SignalMessage::CandidateUpdate {
call_id: "test-123".into(),
reflexive_addr: Some("203.0.113.5:4433".into()),
local_addrs: vec![
"192.168.1.10:4433".into(),
"10.0.0.5:4433".into(),
],
local_addrs: vec!["192.168.1.10:4433".into(), "10.0.0.5:4433".into()],
mapped_addr: Some("198.51.100.42:12345".into()),
generation: 7,
};

View File

@@ -3,9 +3,9 @@
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use crate::QualityProfile;
use crate::packet::QualityReport;
use crate::traits::QualityController;
use crate::QualityProfile;
/// Network quality tier — drives codec and FEC selection.
///
@@ -99,21 +99,16 @@ impl Tier {
}
/// 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 {
WiFi,
CellularLte,
Cellular5g,
Cellular3g,
#[default]
Unknown,
}
impl Default for NetworkContext {
fn default() -> Self {
Self::Unknown
}
}
/// Adaptive quality controller with hysteresis to prevent tier flapping.
///
/// - Downgrade: 3 consecutive reports in a worse tier (2 on cellular)
@@ -340,8 +335,7 @@ impl AdaptiveQualityController {
if probe.bad_reports > PROBE_MAX_BAD {
let _failed_probe = self.probe.take();
// Reset stable_since to trigger cooldown
self.stable_since =
Some(Instant::now() + Duration::from_secs(PROBE_COOLDOWN_SECS));
self.stable_since = Some(Instant::now() + Duration::from_secs(PROBE_COOLDOWN_SECS));
return None; // stay at current tier
}
@@ -746,7 +740,10 @@ mod tests {
ctrl.observe(&degraded); // second bad — exceeds PROBE_MAX_BAD (1)
// 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)
assert_eq!(ctrl.current_tier, Tier::Studio32k);
}
@@ -775,6 +772,9 @@ mod tests {
let excellent = make_report(0.1, 10);
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

File diff suppressed because it is too large Load Diff

View 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