phasm-core 0.2.4

Pure-Rust steganography engine — hide encrypted messages in JPEG photos
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
// Copyright (c) 2026 Christoph Gaffga
// SPDX-License-Identifier: GPL-3.0-only
// https://github.com/cgaffga/phasmcore
//
// Phase 6D.8 chunk 6G — top-level decode entry point.
//
// `h264_stego_decode_yuv_string(annex_b, passphrase) → String`
//
// Pipeline:
//   1. Walk the Annex-B byte stream via the bin-decoder slice walker
//      → `DomainCover` (positions + bit values, four bypass-bin
//      domains). Chunk 6F's parity gate proves this cover is byte-
//      identical to the encode-side Pass-1 PositionLoggerHook
//      output on the same bitstream.
//   2. Brute-force search candidate `m_total` (total framed-bytes
//      bit count) over a bounded range. For each candidate:
//      a. Re-derive per-domain message lengths via
//         `split_message_per_domain` (encode-side splitter run in
//         reverse on a stub of length m_total).
//      b. Per domain: `w_d = n_d / m_d`; generate `hhat_d` with
//         passphrase-derived seed; `stc_extract` → m_d bits.
//      c. Concatenate per-domain bits in the canonical
//         `coeff_sign | coeff_suffix | mvd_sign | mvd_suffix` order.
//      d. Pack bits → bytes (MSB-first per `frame::build_frame`).
//      e. Try `frame::parse_frame` — CRC validates the candidate.
//      f. On CRC pass: `crypto::decrypt` + `payload::decode_payload`
//         → UTF-8 string.
//
// Brute-force bound: m_total starts at FRAME_OVERHEAD * 8 and ends
// at min(MAX_FRAME_BITS, total_cover_bits). With 32-bit CRC,
// false-positive rate per candidate is 2^-32; the bounded search
// is exact in practice.

use crate::stego::error::StegoError;
use crate::stego::frame::{self, FRAME_OVERHEAD};
use crate::stego::stc::extract::stc_extract;
use crate::stego::stc::hhat::generate_hhat;
use crate::stego::{crypto, payload};
use crate::stego::payload::PayloadData;

use crate::codec::h264::cabac::bin_decoder::{
    walk_annex_b_for_cover, walk_annex_b_for_cover_with_options,
    walk_nalus_for_cover, WalkOptions,
};
use crate::codec::h264::NalUnit;

use super::hook::EmbedDomain;
use super::keys::CabacStegoMasterKeys;
use super::orchestrate::{split_message_per_domain, DomainMessages};
use super::DomainCover;

/// Decode an Annex-B byte stream produced by
/// `h264_stego_encode_yuv_string` (or the lower-level
/// `h264_stego_encode_i_frames_only`) and recover the original
/// UTF-8 message.
///
/// **Single-GOP I-frame-only scope** matches chunk-5 encode side.
/// P-slice support comes after §30 MVD wiring lands.
pub fn h264_stego_decode_yuv_string(
    annex_b: &[u8],
    passphrase: &str,
) -> Result<String, StegoError> {
    let walk = walk_annex_b_for_cover(annex_b)
        .map_err(|e| StegoError::InvalidVideo(format!("walk: {e}")))?;
    decode_from_cover(walk.cover, passphrase)
}

/// Variant entry point that takes a pre-parsed NAL unit list
/// (e.g., from MP4-demuxed length-prefixed NAL bytes plus the
/// avcC SPS / PPS). Used by the chunk-7 cfg-gated branch in the
/// legacy `h264_ghost_decode` MP4 path.
pub fn h264_stego_decode_nalus_string(
    nalus: &[NalUnit],
    passphrase: &str,
) -> Result<String, StegoError> {
    let walk = walk_nalus_for_cover(nalus)
        .map_err(|e| StegoError::InvalidVideo(format!("walk nalus: {e}")))?;
    decode_from_cover(walk.cover, passphrase)
}

/// Shared body: brute-force m_total over the recovered cover.
fn decode_from_cover(
    cover: DomainCover,
    passphrase: &str,
) -> Result<String, StegoError> {

    let keys = CabacStegoMasterKeys::derive(passphrase)?;
    let seeds = [
        (EmbedDomain::CoeffSignBypass,
         keys.per_gop_seeds(EmbedDomain::CoeffSignBypass, 0).hhat_seed),
        (EmbedDomain::CoeffSuffixLsb,
         keys.per_gop_seeds(EmbedDomain::CoeffSuffixLsb, 0).hhat_seed),
        (EmbedDomain::MvdSignBypass,
         keys.per_gop_seeds(EmbedDomain::MvdSignBypass, 0).hhat_seed),
        (EmbedDomain::MvdSuffixLsb,
         keys.per_gop_seeds(EmbedDomain::MvdSuffixLsb, 0).hhat_seed),
    ];

    // STC constraint length used at encode time. Must match the
    // chunk-5 driver default (h=4 in `h264_stego_encode_yuv_string`).
    const STC_H: usize = 4;

    let capacities = cover.capacity();
    let total_n_bits: usize = capacities.total();
    if total_n_bits == 0 {
        return Err(StegoError::InvalidVideo("empty cover".into()));
    }

    // Brute-force m_total in byte-aligned increments. Frame is
    // byte-aligned so step by 8. Cap at min(MAX_FRAME_BITS,
    // total cover capacity) to bound the search.
    let max_m_total_bits = (frame::MAX_FRAME_BITS).min(total_n_bits);
    let min_m_total_bits = FRAME_OVERHEAD * 8;

    let mut m_total = min_m_total_bits;
    while m_total <= max_m_total_bits {
        if let Some(plaintext) = try_decode_at(
            &cover, &seeds, STC_H, m_total, passphrase,
        ) {
            return Ok(plaintext);
        }
        m_total += 8;
    }

    Err(StegoError::FrameCorrupted)
}

/// Phase 6D.8 §30D-C — 4-domain decode entry point. Pairs with
/// `h264_stego_encode_yuv_string_4domain`. Walker opts into
/// `record_mvd: true` so MVD positions+bits land in the cover;
/// brute force m_total uses fill-MVD-first allocation matching
/// the encoder's 3-pass orchestrator.
pub fn h264_stego_decode_yuv_string_4domain(
    annex_b: &[u8],
    passphrase: &str,
) -> Result<String, StegoError> {
    let opts = WalkOptions { record_mvd: true };
    let walk = walk_annex_b_for_cover_with_options(annex_b, opts)
        .map_err(|e| StegoError::InvalidVideo(format!("walk: {e}")))?;
    decode_from_cover_4domain(walk.cover, passphrase)
}

/// Phase 6E-C1b — decode a shadow message from the Annex-B byte
/// stream produced by `h264_stego_encode_yuv_string_with_shadow`.
/// Pairs the shadow side: walks the stego bytes → 4-domain cover,
/// then brute-forces parity tiers + first-block-peek for `fdl`.
/// AES-256-GCM-SIV authentication validates the chosen tier.
///
/// §6E-C2 polish — uses `shadow_extract_all4` (single-cover priority
/// sort across all 4 domains) to match the encoder's polish-era
/// position selection (`prepare_shadow_over_emit_cover`).
pub fn h264_stego_shadow_decode(
    annex_b: &[u8],
    passphrase: &str,
) -> Result<String, StegoError> {
    let opts = WalkOptions { record_mvd: true };
    let walk = walk_annex_b_for_cover_with_options(annex_b, opts)
        .map_err(|e| StegoError::InvalidVideo(format!("walk: {e}")))?;
    // §6E-A5(d).6 — derive the cascade-safe MvdSuffixLsb mask from
    // the walked meta. Encoder ran the IDENTICAL analysis on its
    // provisional walk (same content modulo shadow-override values
    // at sign-flip-invariant + safe-set-magnitude-flip-invariant
    // positions), so encoder + decoder land on the same priority
    // pool by §6F.2(j) construction.
    let safe_msb = super::cascade_safety::analyze_safe_mvd_subset(
        &walk.mvd_meta, walk.mb_w, walk.mb_h,
    );
    let safe_msl = super::cascade_safety::derive_msl_safe_from_msb(
        &walk.cover.mvd_sign_bypass.positions,
        &safe_msb,
        &walk.cover.mvd_suffix_lsb.positions,
    );
    let payload_data = super::shadow::shadow_extract_all4_safe(
        &walk.cover, passphrase, None, Some(&safe_msl),
    )?;
    Ok(payload_data.text)
}

/// Phase 6E-C1b — shadow-first smart decode. Tries shadow extract
/// first (cheaper — no STC reverse); on shadow miss, falls back to
/// primary decode. Returns the message belonging to whichever
/// passphrase actually authenticates.
pub fn h264_stego_smart_decode_video(
    annex_b: &[u8],
    passphrase: &str,
) -> Result<String, StegoError> {
    h264_stego_smart_decode_video_with_payload(annex_b, passphrase).map(|p| p.text)
}

/// Task #97 — `_with_payload` variant returning the full
/// PayloadData (text + attached files) recovered from either the
/// primary STC plan or a matching shadow layer. Use this when the
/// caller needs file attachments in addition to text.
pub fn h264_stego_smart_decode_video_with_payload(
    annex_b: &[u8],
    passphrase: &str,
) -> Result<PayloadData, StegoError> {
    let opts = WalkOptions { record_mvd: true };
    let walk = walk_annex_b_for_cover_with_options(annex_b, opts)
        .map_err(|e| StegoError::InvalidVideo(format!("walk: {e}")))?;

    // §6E-A5(d).6 — derive cascade-safe MvdSuffixLsb mask. Encoder
    // ran the same analysis on its provisional walk → identical mask
    // by §6F.2(j) construction.
    let safe_msb = super::cascade_safety::analyze_safe_mvd_subset(
        &walk.mvd_meta, walk.mb_w, walk.mb_h,
    );
    let safe_msl = super::cascade_safety::derive_msl_safe_from_msb(
        &walk.cover.mvd_sign_bypass.positions,
        &safe_msb,
        &walk.cover.mvd_suffix_lsb.positions,
    );
    // Shadow attempt first — AES-GCM-SIV authentication ensures we
    // only return Ok if THIS passphrase matches a shadow layer.
    if let Ok(payload_data) = super::shadow::shadow_extract_all4_safe(
        &walk.cover, passphrase, None, Some(&safe_msl),
    ) {
        return Ok(payload_data);
    }

    // Primary fallback.
    decode_from_cover_4domain_with_payload(walk.cover, passphrase)
}

/// Shared body for 4-domain decode: brute-force m_total over the
/// recovered cover, mirroring the encoder's fill-MVD-first split.
fn decode_from_cover_4domain(
    cover: DomainCover,
    passphrase: &str,
) -> Result<String, StegoError> {
    decode_from_cover_4domain_with_payload(cover, passphrase).map(|p| p.text)
}

/// Task #97 — `_with_payload` variant returning the full
/// PayloadData (text + attached files). Existing
/// `decode_from_cover_4domain` is the text-only thin wrapper.
fn decode_from_cover_4domain_with_payload(
    cover: DomainCover,
    passphrase: &str,
) -> Result<PayloadData, StegoError> {
    let keys = CabacStegoMasterKeys::derive(passphrase)?;
    let seeds = [
        (EmbedDomain::CoeffSignBypass,
         keys.per_gop_seeds(EmbedDomain::CoeffSignBypass, 0).hhat_seed),
        (EmbedDomain::CoeffSuffixLsb,
         keys.per_gop_seeds(EmbedDomain::CoeffSuffixLsb, 0).hhat_seed),
        (EmbedDomain::MvdSignBypass,
         keys.per_gop_seeds(EmbedDomain::MvdSignBypass, 0).hhat_seed),
        (EmbedDomain::MvdSuffixLsb,
         keys.per_gop_seeds(EmbedDomain::MvdSuffixLsb, 0).hhat_seed),
    ];

    const STC_H: usize = 4;
    let total_n_bits = cover.coeff_sign_bypass.len()
        + cover.coeff_suffix_lsb.len()
        + cover.mvd_sign_bypass.len()
        + cover.mvd_suffix_lsb.len();
    if total_n_bits == 0 {
        return Err(StegoError::InvalidVideo("empty cover".into()));
    }

    let max_m_total_bits = (frame::MAX_FRAME_BITS).min(total_n_bits);
    let min_m_total_bits = FRAME_OVERHEAD * 8;

    let mut m_total = min_m_total_bits;
    while m_total <= max_m_total_bits {
        if let Some(payload_data) = try_decode_at_4domain(
            &cover, &seeds, STC_H, m_total, passphrase,
        ) {
            return Ok(payload_data);
        }
        m_total += 8;
    }

    Err(StegoError::FrameCorrupted)
}

/// Try one candidate m_total under the §6F.2(k).4 stealth-
/// weighted cross-domain allocation. Both encoder and decoder
/// run `stealth_weighted_allocation` against the SAME cover
/// shape (cover_p1 ≡ walker_p3 — no cascade because MVD-sign
/// override doesn't mutate slot.value), so the per-domain
/// `m_d` split is identical on both sides by construction.
fn try_decode_at_4domain(
    cover: &DomainCover,
    seeds: &[(EmbedDomain, [u8; 32]); 4],
    h: usize,
    m_total_bits: usize,
    passphrase: &str,
) -> Option<PayloadData> {
    use super::hook::GopCapacity;

    // Phase 6F.2(k).4 — mirror encoder's stealth-weighted
    // allocation. mvd_suffix_lsb is forced to zero capacity
    // (cascades through median predictor → can't be inline-mod),
    // so the allocator gets exactly the same view as the
    // encoder's cap_for_alloc.
    let cap_for_alloc = GopCapacity {
        coeff_sign_bypass: cover.coeff_sign_bypass.len(),
        coeff_suffix_lsb: cover.coeff_suffix_lsb.len(),
        mvd_sign_bypass: cover.mvd_sign_bypass.len(),
        mvd_suffix_lsb: 0,
    };
    let allocator = super::orchestrate::StealthAllocator::v1_default();
    let (m_cs, m_cl, m_ms, _m_ml) =
        super::orchestrate::stealth_weighted_allocation(
            m_total_bits, &cap_for_alloc, &allocator,
        )?;
    let m_mvd = m_ms; // mvd_sign only; mvd_suffix is forced 0
    let m_residual = m_cs + m_cl;
    debug_assert_eq!(m_total_bits, m_mvd + m_residual,
        "decoder stealth-weighted alloc must conserve m_total");

    // Stage A split (MVD-sign-only capacity; suffix disabled).
    // Mirrors encoder's `cap_mvd_only`.
    let cap_mvd = GopCapacity {
        coeff_sign_bypass: 0,
        coeff_suffix_lsb: 0,
        mvd_sign_bypass: cover.mvd_sign_bypass.len(),
        mvd_suffix_lsb: 0,
    };
    let stub_mvd = vec![0u8; m_mvd];
    let split_a = split_message_per_domain(&stub_mvd, &cap_mvd)?;

    // Stage B split (coeff-only capacity).
    let cap_coeff = GopCapacity {
        coeff_sign_bypass: cover.coeff_sign_bypass.len(),
        coeff_suffix_lsb: cover.coeff_suffix_lsb.len(),
        mvd_sign_bypass: 0,
        mvd_suffix_lsb: 0,
    };
    let stub_residual = vec![0u8; m_residual];
    let split_b = split_message_per_domain(&stub_residual, &cap_coeff)?;

    // Per-domain STC extract.
    let mvd_sign = extract_one_domain(
        &cover.mvd_sign_bypass.bits,
        split_a.mvd_sign_bypass.len(),
        h, &seeds[2].1,
    )?;
    let mvd_suffix = extract_one_domain(
        &cover.mvd_suffix_lsb.bits,
        split_a.mvd_suffix_lsb.len(),
        h, &seeds[3].1,
    )?;
    let coeff_sign = extract_one_domain(
        &cover.coeff_sign_bypass.bits,
        split_b.coeff_sign_bypass.len(),
        h, &seeds[0].1,
    )?;
    let coeff_suffix = extract_one_domain(
        &cover.coeff_suffix_lsb.bits,
        split_b.coeff_suffix_lsb.len(),
        h, &seeds[1].1,
    )?;

    // Concat in encoder MESSAGE order: MVD bits FIRST (encoder put
    // them at message[..m_mvd]), then coeff bits.
    let mut all_bits = Vec::with_capacity(m_total_bits);
    all_bits.extend_from_slice(&mvd_sign);
    all_bits.extend_from_slice(&mvd_suffix);
    all_bits.extend_from_slice(&coeff_sign);
    all_bits.extend_from_slice(&coeff_suffix);
    if all_bits.len() != m_total_bits {
        return None;
    }

    let frame_bytes = bits_to_bytes_msb_first(&all_bits);
    let parsed = frame::parse_frame(&frame_bytes).ok()?;
    let plaintext = crypto::decrypt(
        &parsed.ciphertext, passphrase, &parsed.salt, &parsed.nonce,
    ).ok()?;
    let payload_data = payload::decode_payload(&plaintext).ok()?;
    Some(payload_data)
}

/// Try one candidate `m_total` (total framed bit count). Returns
/// `Some(plaintext)` on CRC + decrypt + payload-decode success;
/// `None` on any failure (wrong m_total or wrong passphrase).
fn try_decode_at(
    cover: &DomainCover,
    seeds: &[(EmbedDomain, [u8; 32]); 4],
    h: usize,
    m_total_bits: usize,
    passphrase: &str,
) -> Option<String> {
    // Re-derive per-domain m_d via the encode-side splitter.
    // Use a stub bit-stream of length m_total — split_message_per_domain
    // only inspects LENGTH, not values, when computing m_d.
    let stub: Vec<u8> = vec![0u8; m_total_bits];
    let capacities = cover.capacity();
    let split: DomainMessages = split_message_per_domain(&stub, &capacities)?;

    // Per-domain STC extract.
    let coeff_sign = extract_one_domain(
        &cover.coeff_sign_bypass.bits,
        split.coeff_sign_bypass.len(),
        h, &seeds[0].1,
    )?;
    let coeff_suffix = extract_one_domain(
        &cover.coeff_suffix_lsb.bits,
        split.coeff_suffix_lsb.len(),
        h, &seeds[1].1,
    )?;
    let mvd_sign = extract_one_domain(
        &cover.mvd_sign_bypass.bits,
        split.mvd_sign_bypass.len(),
        h, &seeds[2].1,
    )?;
    let mvd_suffix = extract_one_domain(
        &cover.mvd_suffix_lsb.bits,
        split.mvd_suffix_lsb.len(),
        h, &seeds[3].1,
    )?;

    // Concatenate in canonical order (matches encode-side
    // split_message_per_domain emit order).
    let mut all_bits = Vec::with_capacity(m_total_bits);
    all_bits.extend_from_slice(&coeff_sign);
    all_bits.extend_from_slice(&coeff_suffix);
    all_bits.extend_from_slice(&mvd_sign);
    all_bits.extend_from_slice(&mvd_suffix);
    if all_bits.len() != m_total_bits {
        return None;
    }

    let frame_bytes = bits_to_bytes_msb_first(&all_bits);

    // Frame parse is the CRC oracle.
    let parsed = frame::parse_frame(&frame_bytes).ok()?;

    // Decrypt with passphrase.
    let plaintext = crypto::decrypt(
        &parsed.ciphertext, passphrase, &parsed.salt, &parsed.nonce,
    ).ok()?;

    // Decode payload (brotli + inner format).
    let payload_data = payload::decode_payload(&plaintext).ok()?;
    Some(payload_data.text)
}

/// Run STC extract for one domain. Returns `Some(m_d bits)` on
/// success; `None` if the per-domain configuration is degenerate
/// (e.g., m_d > n_d, or w_d = 0).
fn extract_one_domain(
    cover_bits: &[u8],
    m_d: usize,
    h: usize,
    seed: &[u8; 32],
) -> Option<Vec<u8>> {
    if m_d == 0 {
        return Some(Vec::new());
    }
    let n_d = cover_bits.len();
    if m_d > n_d {
        return None;
    }
    let w_d = n_d / m_d;
    if w_d == 0 {
        return None;
    }
    let hhat = generate_hhat(h, w_d, seed);
    // Use exactly m_d * w_d cover bits to match the encode-side
    // STC embed slice (encoder uses w_d = n_d / m_d, embed sees
    // m_d * w_d bits of cover).
    let cover_slice = &cover_bits[..m_d * w_d];
    Some(stc_extract(cover_slice, &hhat, w_d))
}

/// Pack bits (one bit per byte, MSB-first per byte) into bytes.
/// Mirrors the bit-expansion used at encode time:
///   `byte = b7<<7 | b6<<6 | ... | b0`.
fn bits_to_bytes_msb_first(bits: &[u8]) -> Vec<u8> {
    let n_bytes = bits.len() / 8;
    let mut out = Vec::with_capacity(n_bytes);
    for byte_idx in 0..n_bytes {
        let mut byte = 0u8;
        for i in 0..8 {
            byte |= (bits[byte_idx * 8 + i] & 1) << (7 - i);
        }
        out.push(byte);
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use super::super::encode_pixels::h264_stego_encode_yuv_string;

    fn deterministic_yuv(w: u32, h: u32, n_frames: usize) -> Vec<u8> {
        let frame_size = (w * h * 3 / 2) as usize;
        let mut out = Vec::with_capacity(frame_size * n_frames);
        let mut s: u32 = 0xCAFE_F00D;
        for _ in 0..n_frames {
            for _ in 0..frame_size {
                s = s.wrapping_mul(1664525).wrapping_add(1013904223);
                out.push((s >> 16) as u8);
            }
        }
        out
    }

    /// Load-bearing end-to-end roundtrip gate.
    /// Encode "hi" with chunk-5 driver → walk + decode → assert "hi".
    #[test]
    fn roundtrip_short_string_32x32() {
        let yuv = deterministic_yuv(32, 32, 1);
        let pass = "test-pass";
        let msg = "hi";

        let bytes = h264_stego_encode_yuv_string(
            &yuv, 32, 32, 1, msg, pass,
        ).expect("encode");

        let recovered = h264_stego_decode_yuv_string(&bytes, pass)
            .expect("decode");
        assert_eq!(recovered, msg);
    }

    #[test]
    fn roundtrip_wrong_passphrase_fails() {
        let yuv = deterministic_yuv(32, 32, 1);
        let bytes = h264_stego_encode_yuv_string(
            &yuv, 32, 32, 1, "secret", "right",
        ).expect("encode");

        let r = h264_stego_decode_yuv_string(&bytes, "wrong");
        assert!(r.is_err(), "wrong passphrase must fail");
    }

    /// Chunk 8 — multi-row validation. 64x64 has 4x4=16 MBs across
    /// 4 rows; this exercises the new_row neighbor-state propagation
    /// at every row boundary (chunk 6F's regression-fix path).
    #[test]
    fn roundtrip_multi_row_64x64() {
        let yuv = deterministic_yuv(64, 64, 1);
        let pass = "multi-row";
        let msg = "test message across multi-row frame";

        let bytes = h264_stego_encode_yuv_string(
            &yuv, 64, 64, 1, msg, pass,
        ).expect("encode");
        let recovered = h264_stego_decode_yuv_string(&bytes, pass)
            .expect("decode");
        assert_eq!(recovered, msg);
    }

    /// **§30D-C 4-domain end-to-end roundtrip**: encode "msg" with
    /// the 3-pass orchestrator using MVD + residual domains, walk
    /// the resulting Annex-B with `record_mvd=true`, decode via
    /// the fill-MVD-first 4-domain decoder. Validates the entire
    /// 3-pass MVD-stego pipeline.
    #[test]
    fn roundtrip_i_then_p_frames_4domain_32x32() {
        use crate::codec::h264::stego::encode_pixels::
            h264_stego_encode_yuv_string_4domain;

        let yuv = deterministic_yuv(32, 32, 3);
        let pass = "p-4domain";
        let msg = "hello via 4-domain stego";

        let bytes = h264_stego_encode_yuv_string_4domain(
            &yuv, 32, 32, 3, msg, pass,
        ).expect("encode 4-domain");

        let recovered = h264_stego_decode_yuv_string_4domain(&bytes, pass)
            .expect("decode 4-domain");
        assert_eq!(recovered, msg);
    }

    /// **§30C P-slice multi-frame string roundtrip**: encode-walk-
    /// decode on YUV with 1 IDR + N P-frames. Validates STC reverse
    /// on the larger P-residual cover end-to-end.
    #[test]
    fn roundtrip_i_then_p_frames_32x32() {
        use crate::codec::h264::stego::encode_pixels::
            h264_stego_encode_yuv_string_i_then_p;

        // 32×32 × 3 frames (1 IDR + 2 P). High-entropy random YUV
        // ⇒ encoder picks varied P-partition modes; decoder walker
        // (§30A) reproduces byte-identical cover; STC reverse
        // recovers the message.
        let yuv = deterministic_yuv(32, 32, 3);
        let pass = "p-roundtrip";
        let msg = "hello via P-slices";

        let bytes = h264_stego_encode_yuv_string_i_then_p(
            &yuv, 32, 32, 3, msg, pass,
        ).expect("encode I+P");

        let recovered = h264_stego_decode_yuv_string(&bytes, pass)
            .expect("decode");
        assert_eq!(recovered, msg);
    }

    /// Chunk 8 — multi-frame validation. Three IDR frames in a row;
    /// confirms `stego_frame_idx` propagation across NAL-unit
    /// boundaries gives a coherent global cover that decodes back.
    /// Catches the encoder's `frame_num`-resets-per-IDR collision
    /// in `PlanInjector`'s HashMap (fixed alongside this test).
    #[test]
    fn roundtrip_multi_frame_32x32_3_idrs() {
        let yuv = deterministic_yuv(32, 32, 3);
        let pass = "multi-frame";
        let msg = "spread me across 3 IDR frames";

        let bytes = h264_stego_encode_yuv_string(
            &yuv, 32, 32, 3, msg, pass,
        ).expect("encode");
        let recovered = h264_stego_decode_yuv_string(&bytes, pass)
            .expect("decode");
        assert_eq!(recovered, msg);
    }

    #[test]
    fn bits_to_bytes_msb_first_roundtrip() {
        let original = [0xAB, 0xCD, 0x12];
        let bits: Vec<u8> = original.iter()
            .flat_map(|&b| (0..8).rev().map(move |i| (b >> i) & 1))
            .collect();
        let recovered = bits_to_bytes_msb_first(&bits);
        assert_eq!(recovered, original);
    }
}