Skip to main content

dvb_subtitle/segments/
region_composition.rs

1//! Region Composition Segment — ETSI EN 300 743 §7.2.3, Table 11 (segment_type 0x11).
2
3use crate::error::{Error, Result};
4use broadcast_common::{Parse, Serialize};
5
6/// The region_composition_segment segment_type.
7pub const SEGMENT_TYPE: u8 = 0x11;
8/// Header: sync_byte(1) + segment_type(1) + page_id(2) + segment_length(2) = 6 bytes.
9pub const HEADER_LEN: usize = 6;
10/// Fixed body: 10 bytes.
11pub const FIXED_LEN: usize = 10;
12/// Each object entry base: object_id(2) + type(2b)+provider(2b)+hpos(12b)+reserved(4b)+vpos(12b) = 6 bytes.
13pub const OBJECT_ENTRY_BASE_LEN: usize = 6;
14/// Extra bytes for character/composite objects: foreground(1) + background(1) = 2 bytes.
15pub const OBJECT_EXTRA_LEN: usize = 2;
16/// Top 4 bits of the 12-bit object horizontal/vertical position, held in the
17/// low nibble of the third/fifth entry byte (EN 300 743 Table 11).
18const POS_HIGH_NIBBLE_MASK: u8 = 0x0F;
19
20/// Region level of compatibility as defined in Table 12.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize))]
23#[repr(u8)]
24#[non_exhaustive]
25pub enum RegionLevelOfCompatibility {
26    /// Reserved.
27    Reserved0 = 0x00,
28    /// 2-bit/entry CLUT required.
29    Clut2Bit = 0x01,
30    /// 4-bit/entry CLUT required.
31    Clut4Bit = 0x02,
32    /// 8-bit/entry CLUT required.
33    Clut8Bit = 0x03,
34    /// Reserved range.
35    Reserved(u8),
36}
37
38impl RegionLevelOfCompatibility {
39    /// Human-readable name for this compatibility level.
40    #[must_use]
41    pub fn name(&self) -> &'static str {
42        match self {
43            Self::Reserved0 => "reserved",
44            Self::Clut2Bit => "2-bit CLUT",
45            Self::Clut4Bit => "4-bit CLUT",
46            Self::Clut8Bit => "8-bit CLUT",
47            Self::Reserved(_) => "reserved",
48        }
49    }
50
51    fn to_bits(self) -> u8 {
52        match self {
53            Self::Reserved0 => 0x00,
54            Self::Clut2Bit => 0x01,
55            Self::Clut4Bit => 0x02,
56            Self::Clut8Bit => 0x03,
57            Self::Reserved(v) => v,
58        }
59    }
60}
61
62broadcast_common::impl_spec_display!(RegionLevelOfCompatibility, Reserved);
63
64/// Intended region pixel depth as defined in Table 13.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66#[cfg_attr(feature = "serde", derive(serde::Serialize))]
67#[repr(u8)]
68#[non_exhaustive]
69pub enum RegionDepth {
70    /// Reserved.
71    Reserved0 = 0x00,
72    /// 2-bit.
73    Depth2Bit = 0x01,
74    /// 4-bit.
75    Depth4Bit = 0x02,
76    /// 8-bit.
77    Depth8Bit = 0x03,
78    /// Reserved range.
79    Reserved(u8),
80}
81
82impl RegionDepth {
83    /// Human-readable name for this pixel depth.
84    #[must_use]
85    pub fn name(&self) -> &'static str {
86        match self {
87            Self::Reserved0 => "reserved",
88            Self::Depth2Bit => "2-bit",
89            Self::Depth4Bit => "4-bit",
90            Self::Depth8Bit => "8-bit",
91            Self::Reserved(_) => "reserved",
92        }
93    }
94
95    fn to_bits(self) -> u8 {
96        match self {
97            Self::Reserved0 => 0x00,
98            Self::Depth2Bit => 0x01,
99            Self::Depth4Bit => 0x02,
100            Self::Depth8Bit => 0x03,
101            Self::Reserved(v) => v,
102        }
103    }
104}
105
106broadcast_common::impl_spec_display!(RegionDepth, Reserved);
107
108/// Object type as defined in Table 14.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110#[cfg_attr(feature = "serde", derive(serde::Serialize))]
111#[repr(u8)]
112#[non_exhaustive]
113pub enum ObjectType {
114    /// Basic object, bitmap.
115    BasicBitmap = 0x00,
116    /// Basic object, character.
117    BasicCharacter = 0x01,
118    /// Composite object, string of characters.
119    CompositeString = 0x02,
120    /// Reserved.
121    Reserved(u8),
122}
123
124impl ObjectType {
125    /// Human-readable name for this object type.
126    #[must_use]
127    pub fn name(&self) -> &'static str {
128        match self {
129            Self::BasicBitmap => "basic_bitmap",
130            Self::BasicCharacter => "basic_character",
131            Self::CompositeString => "composite_string",
132            Self::Reserved(_) => "reserved",
133        }
134    }
135
136    fn to_bits(self) -> u8 {
137        match self {
138            Self::BasicBitmap => 0x00,
139            Self::BasicCharacter => 0x01,
140            Self::CompositeString => 0x02,
141            Self::Reserved(v) => v,
142        }
143    }
144}
145
146broadcast_common::impl_spec_display!(ObjectType, Reserved);
147
148/// Object provider flag as defined in Table 15.
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150#[cfg_attr(feature = "serde", derive(serde::Serialize))]
151#[repr(u8)]
152#[non_exhaustive]
153pub enum ObjectProviderFlag {
154    /// Provided in the subtitling stream.
155    InStream = 0x00,
156    /// Provided by a ROM in the IRD.
157    InRom = 0x01,
158    /// Reserved.
159    Reserved(u8),
160}
161
162impl ObjectProviderFlag {
163    /// Human-readable name for this provision method.
164    #[must_use]
165    pub fn name(&self) -> &'static str {
166        match self {
167            Self::InStream => "in_stream",
168            Self::InRom => "in_rom",
169            Self::Reserved(_) => "reserved",
170        }
171    }
172
173    fn to_bits(self) -> u8 {
174        match self {
175            Self::InStream => 0x00,
176            Self::InRom => 0x01,
177            Self::Reserved(v) => v,
178        }
179    }
180}
181
182broadcast_common::impl_spec_display!(ObjectProviderFlag, Reserved);
183
184/// An object entry within a region composition segment.
185#[derive(Debug, Clone, PartialEq, Eq)]
186#[cfg_attr(feature = "serde", derive(serde::Serialize))]
187pub struct RegionObjectEntry {
188    /// Object identifier.
189    pub object_id: u16,
190    /// Object type.
191    pub object_type: ObjectType,
192    /// Object provider flag.
193    pub object_provider_flag: ObjectProviderFlag,
194    /// Horizontal position relative to region.
195    pub object_horizontal_position: u16,
196    /// Vertical position relative to region.
197    pub object_vertical_position: u16,
198    /// Foreground pixel code (only for character/composite objects).
199    pub foreground_pixel_code: Option<u8>,
200    /// Background pixel code (only for character/composite objects).
201    pub background_pixel_code: Option<u8>,
202}
203
204impl RegionObjectEntry {
205    fn serialized_len(&self) -> usize {
206        if self.foreground_pixel_code.is_some() {
207            OBJECT_ENTRY_BASE_LEN + OBJECT_EXTRA_LEN
208        } else {
209            OBJECT_ENTRY_BASE_LEN
210        }
211    }
212
213    fn serialize_into(&self, buf: &mut [u8]) -> Result<()> {
214        buf[0..2].copy_from_slice(&self.object_id.to_be_bytes());
215        let hpos = self.object_horizontal_position;
216        let vpos = self.object_vertical_position;
217        // EN 300 743 Table 11 (docs/tables/en_300_743/11-region-composition-segment.md:29-33):
218        // both object_horizontal_position and object_vertical_position are
219        // 12-bit uimsbf fields; range-check before narrowing so a value that
220        // does not fit is rejected instead of silently masked (#1129/#1044).
221        broadcast_common::len::fit_bits(u64::from(hpos), 12, "object_horizontal_position")?;
222        broadcast_common::len::fit_bits(u64::from(vpos), 12, "object_vertical_position")?;
223        buf[2] = (self.object_type.to_bits() << 6)
224            | (self.object_provider_flag.to_bits() << 4)
225            | ((hpos >> 8) as u8 & POS_HIGH_NIBBLE_MASK);
226        buf[3] = hpos as u8;
227        // reserved(4) then the top 4 bits of vpos, i.e. reserved is the HIGH
228        // nibble and vpos's top bits are the LOW nibble — the mirror of
229        // `parse`'s `(bytes[4] & 0x0F) << 8`.
230        buf[4] = (vpos >> 8) as u8 & POS_HIGH_NIBBLE_MASK;
231        buf[5] = vpos as u8;
232        if let (Some(fg), Some(bg)) = (self.foreground_pixel_code, self.background_pixel_code) {
233            buf[6] = fg;
234            buf[7] = bg;
235        }
236        Ok(())
237    }
238}
239
240/// Region Composition Segment.
241#[derive(Debug, Clone, PartialEq, Eq)]
242#[cfg_attr(feature = "serde", derive(serde::Serialize))]
243pub struct RegionCompositionSegment {
244    /// The page_id (from generic segment header).
245    pub page_id: u16,
246    /// Region identifier.
247    pub region_id: u8,
248    /// Region version number (modulo 16).
249    pub region_version_number: u8,
250    /// Fill flag.
251    pub region_fill_flag: bool,
252    /// Reserved bits in body byte 1 (bits `[2:0]`).
253    pub reserved_byte1: u8,
254    /// Region width in pixels.
255    pub region_width: u16,
256    /// Region height in pixels.
257    pub region_height: u16,
258    /// Minimum CLUT type required.
259    pub region_level_of_compatibility: RegionLevelOfCompatibility,
260    /// Intended pixel depth.
261    pub region_depth: RegionDepth,
262    /// Reserved bits in body byte 6 (bits `[1:0]`).
263    pub reserved_byte6: u8,
264    /// CLUT family identifier.
265    pub clut_id: u8,
266    /// Background colour for 8-bit CLUT.
267    pub region_8bit_pixel_code: u8,
268    /// Background colour for 4-bit CLUT.
269    pub region_4bit_pixel_code: u8,
270    /// Background colour for 2-bit CLUT.
271    pub region_2bit_pixel_code: u8,
272    /// Reserved bits in body byte 9 (bits `[1:0]`).
273    pub reserved_byte9: u8,
274    /// Object entries.
275    pub objects: alloc::vec::Vec<RegionObjectEntry>,
276}
277
278fn parse_object_entry(bytes: &[u8]) -> Result<(RegionObjectEntry, usize)> {
279    if bytes.len() < OBJECT_ENTRY_BASE_LEN {
280        return Err(Error::BufferTooShort {
281            need: OBJECT_ENTRY_BASE_LEN,
282            have: bytes.len(),
283            what: "region_object_entry",
284        });
285    }
286    let object_id = u16::from_be_bytes([bytes[0], bytes[1]]);
287    let obj_type_val = (bytes[2] >> 6) & 0x03;
288    let obj_provider_val = (bytes[2] >> 4) & 0x03;
289    let obj_hpos =
290        ((u16::from(bytes[2]) & u16::from(POS_HIGH_NIBBLE_MASK)) << 8) | u16::from(bytes[3]);
291    let reserved = (bytes[4] >> 4) & POS_HIGH_NIBBLE_MASK;
292    // reserved bits tolerated per §7.2.0.2 forward compatibility
293    let _ = reserved;
294    let obj_vpos =
295        ((u16::from(bytes[4]) & u16::from(POS_HIGH_NIBBLE_MASK)) << 8) | u16::from(bytes[5]);
296
297    let obj_type = match obj_type_val {
298        0x00 => ObjectType::BasicBitmap,
299        0x01 => ObjectType::BasicCharacter,
300        0x02 => ObjectType::CompositeString,
301        v => ObjectType::Reserved(v),
302    };
303    let obj_provider = match obj_provider_val {
304        0x00 => ObjectProviderFlag::InStream,
305        0x01 => ObjectProviderFlag::InRom,
306        v => ObjectProviderFlag::Reserved(v),
307    };
308
309    let has_extra = obj_type_val == 0x01 || obj_type_val == 0x02;
310    if has_extra {
311        if bytes.len() < OBJECT_ENTRY_BASE_LEN + OBJECT_EXTRA_LEN {
312            return Err(Error::BufferTooShort {
313                need: OBJECT_ENTRY_BASE_LEN + OBJECT_EXTRA_LEN,
314                have: bytes.len(),
315                what: "region_object_entry extra",
316            });
317        }
318        Ok((
319            RegionObjectEntry {
320                object_id,
321                object_type: obj_type,
322                object_provider_flag: obj_provider,
323                object_horizontal_position: obj_hpos,
324                object_vertical_position: obj_vpos,
325                foreground_pixel_code: Some(bytes[6]),
326                background_pixel_code: Some(bytes[7]),
327            },
328            OBJECT_ENTRY_BASE_LEN + OBJECT_EXTRA_LEN,
329        ))
330    } else {
331        Ok((
332            RegionObjectEntry {
333                object_id,
334                object_type: obj_type,
335                object_provider_flag: obj_provider,
336                object_horizontal_position: obj_hpos,
337                object_vertical_position: obj_vpos,
338                foreground_pixel_code: None,
339                background_pixel_code: None,
340            },
341            OBJECT_ENTRY_BASE_LEN,
342        ))
343    }
344}
345
346impl<'a> Parse<'a> for RegionCompositionSegment {
347    type Error = Error;
348
349    fn parse(bytes: &'a [u8]) -> Result<Self> {
350        if bytes.len() < HEADER_LEN + FIXED_LEN {
351            return Err(Error::BufferTooShort {
352                need: HEADER_LEN + FIXED_LEN,
353                have: bytes.len(),
354                what: "region_composition_segment",
355            });
356        }
357        if bytes[0] != crate::pes_data_field::SYNC_BYTE {
358            return Err(Error::BadSyncByte(bytes[0]));
359        }
360        if bytes[1] != SEGMENT_TYPE {
361            return Err(Error::UnknownSegmentType(bytes[1]));
362        }
363        let page_id = u16::from_be_bytes([bytes[2], bytes[3]]);
364        let segment_length = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;
365        let total = HEADER_LEN + segment_length;
366        if bytes.len() < total {
367            return Err(Error::BufferTooShort {
368                need: total,
369                have: bytes.len(),
370                what: "region_composition_segment data",
371            });
372        }
373        let body = &bytes[HEADER_LEN..HEADER_LEN + segment_length];
374        if body.len() < FIXED_LEN {
375            return Err(Error::BufferTooShort {
376                need: FIXED_LEN,
377                have: body.len(),
378                what: "region_composition_segment body",
379            });
380        }
381        let region_id = body[0];
382        let region_version_number = body[1] >> 4;
383        let region_fill_flag = (body[1] & 0x08) != 0;
384        let reserved_byte1 = body[1] & 0x07;
385        let region_width = u16::from_be_bytes([body[2], body[3]]);
386        let region_height = u16::from_be_bytes([body[4], body[5]]);
387        let rloc_val = (body[6] >> 5) & 0x07;
388        let region_level_of_compatibility = match rloc_val {
389            0x00 => RegionLevelOfCompatibility::Reserved0,
390            0x01 => RegionLevelOfCompatibility::Clut2Bit,
391            0x02 => RegionLevelOfCompatibility::Clut4Bit,
392            0x03 => RegionLevelOfCompatibility::Clut8Bit,
393            v => RegionLevelOfCompatibility::Reserved(v),
394        };
395        let rd_val = (body[6] >> 2) & 0x07;
396        let region_depth = match rd_val {
397            0x00 => RegionDepth::Reserved0,
398            0x01 => RegionDepth::Depth2Bit,
399            0x02 => RegionDepth::Depth4Bit,
400            0x03 => RegionDepth::Depth8Bit,
401            v => RegionDepth::Reserved(v),
402        };
403        let reserved_byte6 = body[6] & 0x03;
404        let clut_id = body[7];
405        let region_8bit_pixel_code = body[8];
406        let region_4bit_pixel_code = body[9] >> 4;
407        let region_2bit_pixel_code = (body[9] >> 2) & 0x03;
408        let reserved_byte9 = body[9] & 0x03;
409
410        let obj_data = &body[FIXED_LEN..];
411        let mut objects = alloc::vec::Vec::new();
412        let mut pos: usize = 0;
413        while pos < obj_data.len() {
414            let (entry, entry_len) = parse_object_entry(&obj_data[pos..])?;
415            objects.push(entry);
416            pos += entry_len;
417        }
418
419        Ok(RegionCompositionSegment {
420            page_id,
421            region_id,
422            region_version_number,
423            region_fill_flag,
424            reserved_byte1,
425            region_width,
426            region_height,
427            region_level_of_compatibility,
428            region_depth,
429            reserved_byte6,
430            clut_id,
431            region_8bit_pixel_code,
432            region_4bit_pixel_code,
433            region_2bit_pixel_code,
434            reserved_byte9,
435            objects,
436        })
437    }
438}
439
440impl Serialize for RegionCompositionSegment {
441    type Error = Error;
442
443    fn serialized_len(&self) -> usize {
444        HEADER_LEN
445            + FIXED_LEN
446            + self
447                .objects
448                .iter()
449                .map(|o| o.serialized_len())
450                .sum::<usize>()
451    }
452
453    fn serialize_into(&self, buf: &mut [u8]) -> core::result::Result<usize, Self::Error> {
454        let len = self.serialized_len();
455        if buf.len() < len {
456            return Err(Error::BufferTooShort {
457                need: len,
458                have: buf.len(),
459                what: "region_composition_segment serialize",
460            });
461        }
462        buf[0] = 0x0F;
463        buf[1] = SEGMENT_TYPE;
464        buf[2..4].copy_from_slice(&self.page_id.to_be_bytes());
465        let seg_len = crate::segments::check_segment_length(len - HEADER_LEN)?;
466        buf[4..6].copy_from_slice(&seg_len.to_be_bytes());
467
468        buf[6] = self.region_id;
469        buf[7] = (broadcast_common::len::fit_mask(
470            self.region_version_number,
471            0x0F,
472            "region_version_number",
473        )? << 4)
474            | (u8::from(self.region_fill_flag) << 3)
475            | broadcast_common::len::fit_mask(self.reserved_byte1, 0x07, "reserved_byte1")?;
476        buf[8..10].copy_from_slice(&self.region_width.to_be_bytes());
477        buf[10..12].copy_from_slice(&self.region_height.to_be_bytes());
478        buf[12] = (broadcast_common::len::fit_mask(
479            self.region_level_of_compatibility.to_bits(),
480            0x07,
481            "region_level_of_compatibility",
482        )? << 5)
483            | (broadcast_common::len::fit_mask(self.region_depth.to_bits(), 0x07, "region_depth")?
484                << 2)
485            | broadcast_common::len::fit_mask(self.reserved_byte6, 0x03, "reserved_byte6")?;
486        buf[13] = self.clut_id;
487        buf[14] = self.region_8bit_pixel_code;
488        buf[15] = (broadcast_common::len::fit_mask(
489            self.region_4bit_pixel_code,
490            0x0F,
491            "region_4bit_pixel_code",
492        )? << 4)
493            | (broadcast_common::len::fit_mask(
494                self.region_2bit_pixel_code,
495                0x03,
496                "region_2bit_pixel_code",
497            )? << 2)
498            | broadcast_common::len::fit_mask(self.reserved_byte9, 0x03, "reserved_byte9")?;
499
500        let mut off = HEADER_LEN + FIXED_LEN;
501        for obj in &self.objects {
502            obj.serialize_into(&mut buf[off..])?;
503            off += obj.serialized_len();
504        }
505        Ok(len)
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use broadcast_common::{Parse, Serialize};
513
514    #[test]
515    fn round_trip_bitmap_objects() {
516        let bytes = [
517            0x0F, 0x11, 0x00, 0x01, 0x00, 0x16, 0x01, 0x88, 0x02, 0xCF, 0x00, 0x8F, 0x18, 0x03,
518            0x00, 0x00, 0x00, 0x01, 0x00, 0x0A, 0x00, 0x0A, 0x00, 0x02, 0x10, 0x14, 0x00,
519            0x14, // obj 2: hpos=20, vpos=20, vpos upper nibble=0
520        ];
521        let seg = RegionCompositionSegment::parse(&bytes).unwrap();
522        assert_eq!(seg.region_id, 1);
523        assert!(seg.region_fill_flag);
524        assert_eq!(seg.region_width, 719);
525        assert_eq!(seg.objects.len(), 2);
526        assert_eq!(seg.objects[0].object_id, 1);
527        assert_eq!(seg.objects[0].object_type, ObjectType::BasicBitmap);
528        assert_eq!(seg.objects[0].object_horizontal_position, 10);
529        assert_eq!(seg.objects[1].object_id, 2);
530        assert_eq!(
531            seg.objects[1].object_provider_flag,
532            ObjectProviderFlag::InRom
533        );
534        let out = seg.try_to_bytes().unwrap();
535        assert_eq!(out, bytes);
536
537        // Biting test: mutate a field and re-parse
538        let mut seg2 = seg.clone();
539        seg2.region_width = 1280;
540        let out2 = seg2.try_to_bytes().unwrap();
541        assert_ne!(out2, bytes);
542        let reparse = RegionCompositionSegment::parse(&out2).unwrap();
543        assert_eq!(reparse.region_width, 1280);
544    }
545
546    /// DS-W2 (#1108): sync_byte wasn't checked by any typed segment parser.
547    #[test]
548    fn rejects_bad_sync_byte() {
549        let bytes = [
550            0x00, 0x11, 0x00, 0x01, 0x00, 0x16, 0x01, 0x88, 0x02, 0xCF, 0x00, 0x8F, 0x18, 0x03,
551            0x00, 0x00, 0x00, 0x01, 0x00, 0x0A, 0x00, 0x0A, 0x00, 0x02, 0x10, 0x14, 0x00, 0x14,
552        ];
553        assert!(matches!(
554            RegionCompositionSegment::parse(&bytes),
555            Err(Error::BadSyncByte(0x00))
556        ));
557    }
558
559    #[test]
560    fn round_trip_character_object() {
561        let bytes = [
562            0x0F, 0x11, 0x00, 0x01, 0x00, 0x12, 0x01, 0x80, 0x02, 0xCF, 0x00, 0x8F, 0x18, 0x03,
563            0x00, 0x00, 0x00, 0x01, 0x40, 0x0A, 0x00, 0x1E, 0xAA, 0xBB,
564        ];
565        let seg = RegionCompositionSegment::parse(&bytes).unwrap();
566        assert_eq!(seg.objects.len(), 1);
567        assert_eq!(seg.objects[0].object_type, ObjectType::BasicCharacter);
568        assert_eq!(seg.objects[0].foreground_pixel_code, Some(0xAA));
569        assert_eq!(seg.objects[0].background_pixel_code, Some(0xBB));
570        let out = seg.try_to_bytes().unwrap();
571        assert_eq!(out, bytes);
572
573        // Biting test
574        let mut seg2 = seg.clone();
575        seg2.objects[0].foreground_pixel_code = Some(0xCC);
576        let out2 = seg2.try_to_bytes().unwrap();
577        assert_ne!(out2, bytes);
578        let reparse = RegionCompositionSegment::parse(&out2).unwrap();
579        assert_eq!(reparse.objects[0].foreground_pixel_code, Some(0xCC));
580    }
581
582    /// Issue #1044: `object_vertical_position >= 0x100` (>= 256) must
583    /// round-trip. EN 300 743 Table 11 (`docs/tables/en_300_743/11-region-composition-segment.md:32-33`):
584    /// byte 4 of the object entry is `reserved(4)` then the *top 4 bits* of
585    /// the 12-bit `object_vertical_position` — the same split `parse` already
586    /// uses (`(bytes[4] & 0x0F) << 8 | bytes[5]`). Every existing round-trip
587    /// fixture keeps `vpos < 256` (a single byte), so this is the first test
588    /// to exercise the top nibble.
589    #[test]
590    fn round_trip_object_vertical_position_above_one_byte() {
591        let mut seg = RegionCompositionSegment::parse(&[
592            0x0F, 0x11, 0x00, 0x01, 0x00, 0x16, 0x01, 0x88, 0x02, 0xCF, 0x00, 0x8F, 0x18, 0x03,
593            0x00, 0x00, 0x00, 0x01, 0x00, 0x0A, 0x00, 0x0A, 0x00, 0x02, 0x10, 0x14, 0x00, 0x14,
594        ])
595        .unwrap();
596        // 900 (0x384): the bottom-of-a-1080-line-frame subtitle position from
597        // the audit's failure scenario.
598        seg.objects[1].object_vertical_position = 0x384;
599
600        let out = seg.try_to_bytes().unwrap();
601        // object index 1 is the last (and, per the fixture, fg/bg-less 6-byte)
602        // entry in the loop, so its `object_vertical_position` bytes are the
603        // final 2 bytes of the whole serialized segment. They must be
604        // `reserved(0) | vpos[11:8]` = 0x03 (not 0x30 — that would put the
605        // position's top nibble into the reserved high nibble, the exact
606        // bug) then `vpos[7:0]` = 0x84.
607        let n = out.len();
608        assert_eq!(
609            out[n - 2],
610            0x03,
611            "vpos top nibble must land in the low nibble of byte 4, not the reserved high nibble"
612        );
613        assert_eq!(out[n - 1], 0x84);
614
615        let reparsed = RegionCompositionSegment::parse(&out).unwrap();
616        assert_eq!(
617            reparsed.objects[1].object_vertical_position, 0x384,
618            "byte-exact round-trip of a vertical position >= 256"
619        );
620    }
621
622    /// Issue #1044/#1129: `object_vertical_position`/`object_horizontal_position`
623    /// are 12-bit fields (EN 300 743 Table 11); a value over `0x0FFF` must be
624    /// rejected, not silently masked to a wrong (wrapped) position.
625    #[test]
626    fn oversized_object_position_is_rejected_not_masked() {
627        let mut seg = RegionCompositionSegment::parse(&[
628            0x0F, 0x11, 0x00, 0x01, 0x00, 0x16, 0x01, 0x88, 0x02, 0xCF, 0x00, 0x8F, 0x18, 0x03,
629            0x00, 0x00, 0x00, 0x01, 0x00, 0x0A, 0x00, 0x0A, 0x00, 0x02, 0x10, 0x14, 0x00, 0x14,
630        ])
631        .unwrap();
632        let mut buf = alloc::vec![0u8; 64];
633
634        seg.objects[1].object_vertical_position = 0x1000; // 13 bits, one over max
635        assert!(matches!(
636            seg.serialize_into(&mut buf),
637            Err(Error::FieldOverflow(_))
638        ));
639
640        seg.objects[1].object_vertical_position = 0x0FFF; // the 12-bit maximum
641        seg.objects[1].object_horizontal_position = 0x1000;
642        assert!(matches!(
643            seg.serialize_into(&mut buf),
644            Err(Error::FieldOverflow(_))
645        ));
646
647        seg.objects[1].object_horizontal_position = 0x0FFF; // both at max: OK
648        assert!(seg.serialize_into(&mut buf).is_ok());
649    }
650}