Skip to main content

powerio_tx/format/powerworld/
pwd.rs

1//! Decode PowerWorld `.pwd` drawing coordinates.
2//!
3//! Supported records provide substation positions, bus positions, and branch
4//! paths. Identity tables associate drawing objects with equipment. Repeated
5//! coordinates, header stamps, and equipment references validate each record.
6//! Ambiguous identity tables and empty decoded drawings return errors.
7//!
8//! Coordinates retain the drawing's units and orientation. They are not
9//! latitude and longitude. Geographic placement requires a separate source
10//! such as a case file or an AUX file with geographic fields.
11
12use std::collections::{BTreeMap, HashSet};
13use std::path::Path;
14
15use crate::{Error, Result};
16
17const FMT: &str = "PowerWorld .pwd";
18
19/// The identity table tag behind the `ff ff ff ff` sentinel.
20const IDENTITY_TAG: [u8; 6] = [0xff, 0xff, 0xff, 0xff, 0x3d, 0x0f];
21
22/// The word every probed save writes one u32 past the header stamp. It pins
23/// which of the two candidate positions holds the stamp when a canvas title
24/// shifts it (see [`parse_pwd_header`]).
25const HEADER_TRAILER: u32 = 10105;
26
27/// Where the stamp sits when the header carries no canvas title.
28const UNTITLED_STAMP_AT: usize = 22;
29
30/// Longest canvas title the header shift accepts. Every probed save is well
31/// under it; a length past it is a corrupt or unrecognized header, not a
32/// title, so the reader falls back to the untitled position.
33const MAX_TITLE_LEN: usize = 256;
34
35/// Cap on identity record steps across every anchor in one parse. A step is one
36/// record examined; each consumes at least 13 bytes, so the largest real table
37/// is far below this. Bounds the anchors × records blowup a crafted file could
38/// otherwise force. Matches the probe-budget idiom of the `.pwb` reader.
39const IDENTITY_WALK_BUDGET: u64 = 128_000_000;
40
41/// Cap on identity rows retained across every candidate walk in one parse.
42/// The step budget above bounds the reader's *work*; this bounds what a
43/// densely packed file can make it *hold*: each retained row owns a name
44/// String, so without a retention cap a GB-scale file of valid-looking
45/// records could drive several GB of held rows before the walk finishes
46/// (#274). The vendored ACTIVSg200 display retains 200 rows; a display for
47/// the largest interconnection-scale case stays in the tens of thousands, so
48/// one million rows and 64 MiB of name bytes are far above any real layout
49/// while capping amplification near the input's own size. Exceeding either
50/// is a coded refusal, never a silent omission.
51const IDENTITY_ROW_BUDGET: usize = 1_000_000;
52const IDENTITY_NAME_BYTE_BUDGET: usize = 64 << 20;
53
54/// One substation symbol from a display file: the identity row joined with
55/// its drawing record, in identity table (display) order. `x` and `y` are
56/// diagram coordinates as stored, y north positive (see the module docs).
57#[derive(Debug, Clone, PartialEq)]
58pub struct PwdSubstation {
59    pub number: u32,
60    pub name: String,
61    pub x: f64,
62    pub y: f64,
63}
64
65/// Decoded PowerWorld display file content.
66///
67/// A `.pwd` is not a case file and does not carry a [`BalancedNetwork`](crate::BalancedNetwork).
68/// This structure exposes the display metadata the reader validates plus the
69/// supported drawing object subset.
70#[derive(Debug, Clone, PartialEq)]
71pub struct PwdDisplay {
72    pub canvas_width: u16,
73    pub canvas_height: u16,
74    pub stamp: u32,
75    pub substations: Vec<PwdSubstation>,
76}
77
78/// Read and parse a `.pwd` display file.
79///
80/// # Errors
81/// [`Error::Io`] when the file cannot be read, or [`Error::FormatRead`] when
82/// the display bytes are not a supported PowerWorld `.pwd` shape.
83pub fn parse_pwd_file(path: impl AsRef<Path>) -> Result<PwdDisplay> {
84    let bytes = std::fs::read(path)?;
85    parse_pwd_display(&bytes)
86}
87
88/// Parse a `.pwd` display file, returning metadata and decoded substations.
89///
90/// # Errors
91/// [`Error::FormatRead`] when the header is not the known display shape,
92/// or no unique drawing record group links to the identity rows.
93pub fn parse_pwd_display(bytes: &[u8]) -> Result<PwdDisplay> {
94    parse_pwd_inner(bytes)
95}
96
97/// Parse the substation coordinates out of `.pwd` bytes.
98///
99/// # Errors
100/// [`Error::FormatRead`] when the header is not the known display shape,
101/// or no unique drawing record group links to the identity rows.
102pub fn parse_pwd(bytes: &[u8]) -> Result<Vec<PwdSubstation>> {
103    parse_pwd_display(bytes).map(|display| display.substations)
104}
105
106/// Decode supported substation, bus, and branch drawing objects into a shared layer.
107/// Bus and line object records require matching case identities and repeated positions.
108pub fn parse_pwd_layer(bytes: &[u8]) -> Result<crate::geo::GeoParsed> {
109    use crate::geo::{ElementKey, GeoFeature, GeoGeometry, GeoTarget};
110    let display = parse_pwd_display(bytes)?;
111    let mut layer = crate::geo::to_geo_layer_from_pwd(&display);
112    let mut diagnostics = Vec::new();
113    let identities = bus_identities(bytes)?;
114    if !identities.is_empty() {
115        let mut buses = BTreeMap::new();
116        let mut owners = Vec::new();
117        for at in 0..bytes.len().saturating_sub(38) {
118            let Some((x, y)) = drawing_position(bytes, at, display.stamp) else {
119                continue;
120            };
121            match u16_at(bytes, at) {
122                Some(0x277e) => {
123                    let Some(end) = style_label_end(bytes, at, &[67]) else {
124                        continue;
125                    };
126                    let marker = end + 17;
127                    if bytes.get(marker) != Some(&3) {
128                        continue;
129                    }
130                    let Some(number) =
131                        u32_at(bytes, marker + 1).filter(|n| identities.contains_key(n))
132                    else {
133                        continue;
134                    };
135                    if buses.insert(number, [x, y]).is_some() {
136                        return Err(pwd_err("duplicate bus drawing identity"));
137                    }
138                }
139                Some(0x27b3 | 0x27b7) => owners.push(at),
140                _ => {}
141            }
142        }
143        for (&number, &point) in &buses {
144            layer.features.push(GeoFeature {
145                target: GeoTarget::Bus,
146                key: ElementKey {
147                    id: Some(number.to_string()),
148                    name: Some(identities[&number].clone()),
149                    ..ElementKey::default()
150                },
151                geometry: GeoGeometry::Point(point),
152                from: None,
153                to: None,
154                kind: None,
155            });
156        }
157        let mut unsupported = identities.len().saturating_sub(buses.len());
158        for at in owners {
159            let decoded = decode_branch_drawing(bytes, at, display.stamp, &buses);
160            if let Some(feature) = decoded {
161                layer.features.push(feature);
162            } else {
163                unsupported += 1;
164            }
165        }
166        if unsupported > 0 {
167            diagnostics.push(crate::diagnostics::Diagnostic::of(&crate::diagnostics::codes::READ_GEO_SOURCE_MALFORMED,
168                format!("{unsupported} bus or branch drawing objects have unsupported layouts or unmatched identities")));
169        }
170    }
171    if layer.features.is_empty() {
172        return Err(pwd_err(
173            "no supported bus, branch, or substation positions were decoded from the drawing",
174        ));
175    }
176    Ok(crate::geo::GeoParsed { layer, diagnostics })
177}
178
179fn decode_branch_drawing(
180    bytes: &[u8],
181    at: usize,
182    stamp: u32,
183    buses: &BTreeMap<u32, [f64; 2]>,
184) -> Option<crate::geo::GeoFeature> {
185    use crate::geo::{ElementKey, GeoFeature, GeoGeometry, GeoTarget};
186    let end = style_label_end(bytes, at, &[75, 79])?;
187    let from = u32_at(bytes, end + 63)?;
188    let to = u32_at(bytes, end + 67)?;
189    if !buses.contains_key(&from) || !buses.contains_key(&to) {
190        return None;
191    }
192    let child = end + 72;
193    if u16_at(bytes, child) != Some(0x3131) || drawing_position(bytes, child, stamp).is_none() {
194        return None;
195    }
196    let child_end = style_label_end(bytes, child, &[75, 79])?;
197    let count_at = child_end + 58;
198    let count = u32_at(bytes, count_at)? as usize;
199    if !(2..=100_000).contains(&count) {
200        return None;
201    }
202    let raw = bytes.get(count_at + 4..count_at.checked_add(4 + count * 16)?)?;
203    let mut points = Vec::with_capacity(count);
204    for i in (0..raw.len()).step_by(16) {
205        let x = f64_at(raw, i)?;
206        let y = f64_at(raw, i + 8)?;
207        if !x.is_finite() || !y.is_finite() || x.abs().max(y.abs()) > 1e7 {
208            return None;
209        }
210        points.push([x, y]);
211    }
212    Some(GeoFeature {
213        target: GeoTarget::Branch,
214        key: ElementKey::default(),
215        geometry: GeoGeometry::LineString(points),
216        from: Some(from.to_string()),
217        to: Some(to.to_string()),
218        kind: None,
219    })
220}
221
222fn drawing_position(bytes: &[u8], at: usize, stamp: u32) -> Option<(f64, f64)> {
223    if u32_at(bytes, at + 18) != Some(stamp) {
224        return None;
225    }
226    let x = f64_at(bytes, at + 22)?;
227    let y = f64_at(bytes, at + 30)?;
228    if !x.is_finite() || !y.is_finite() || x.abs().max(y.abs()) > 1e7 {
229        return None;
230    }
231    #[allow(clippy::cast_possible_truncation)]
232    let (rx, ry) = (x as f32, y as f32);
233    if f32_at(bytes, at + 2)?.to_bits() != rx.to_bits()
234        || f32_at(bytes, at + 6)?.to_bits() != ry.to_bits()
235    {
236        return None;
237    }
238    Some((x, y))
239}
240
241fn style_label_end(bytes: &[u8], at: usize, offsets: &[usize]) -> Option<usize> {
242    let mut found = None;
243    for offset in offsets {
244        let length = u32_at(bytes, at + offset)? as usize;
245        if length == 0 || length > 64 {
246            continue;
247        }
248        let end = (at + offset + 4).checked_add(length)?;
249        let text = bytes.get(at + offset + 4..end)?;
250        if !text.iter().all(|c| (0x20..0x7f).contains(c)) || u32_at(bytes, end) != Some(u32::MAX) {
251            continue;
252        }
253        if found.replace(end).is_some() {
254            return None;
255        }
256    }
257    found
258}
259
260fn bus_identities(bytes: &[u8]) -> Result<BTreeMap<u32, String>> {
261    let mut found = None;
262    let mut work = 0usize;
263    for anchor in memmem(bytes, &[0x3c, 0x0f]) {
264        let mut at = anchor + 2;
265        let mut rows = BTreeMap::new();
266        while work < IDENTITY_ROW_BUDGET {
267            work += 1;
268            if bytes.get(at..at + 6) == Some(&IDENTITY_TAG) {
269                if !rows.is_empty() && found.replace(rows).is_some() {
270                    return Err(pwd_err("ambiguous bus identity tables"));
271                }
272                break;
273            }
274            let row = (|| -> Option<(u32, String, usize)> {
275                let number = u32_at(bytes, at)?;
276                let length = u32_at(bytes, at + 4)? as usize;
277                if number == 0 || number > 99_999_999 || length == 0 || length > 64 {
278                    return None;
279                }
280                let end = at + 8 + length;
281                let name = bytes.get(at + 8..end)?;
282                if !name.iter().all(|c| (0x20..0x7f).contains(c))
283                    || u32_at(bytes, end) != Some(number)
284                    || bytes.get(end + 4) != Some(&0)
285                {
286                    return None;
287                }
288                let label_length = u32_at(bytes, end + 5)? as usize;
289                if label_length > 64 {
290                    return None;
291                }
292                let kv = f32_at(bytes, end + 9 + label_length)?;
293                if !kv.is_finite() || !(0.0..=10_000.0).contains(&kv) {
294                    return None;
295                }
296                Some((
297                    number,
298                    String::from_utf8_lossy(name).into_owned(),
299                    end + 13 + label_length,
300                ))
301            })();
302            let Some((number, name, next)) = row else {
303                break;
304            };
305            if rows.insert(number, name).is_some() {
306                break;
307            }
308            at = next;
309        }
310        if work >= IDENTITY_ROW_BUDGET {
311            return Err(pwd_err("bus identity search exceeded its work limit"));
312        }
313    }
314    Ok(found.unwrap_or_default())
315}
316
317fn pwd_err(message: impl Into<String>) -> Error {
318    Error::FormatRead {
319        format: FMT,
320        message: message.into(),
321    }
322}
323
324fn parse_pwd_header(bytes: &[u8]) -> Result<(u16, u16, u32)> {
325    let (Some(header), Some(canvas_width), Some(canvas_height)) =
326        (u32_at(bytes, 0), u16_at(bytes, 4), u16_at(bytes, 6))
327    else {
328        let header = u32_at(bytes, 0).unwrap_or(0);
329        return Err(pwd_err(format!(
330            "not a recognized PowerWorld display file (header word {header}; the probed saves all \
331             carry 50)",
332        )));
333    };
334    if bytes.len() < 0x40 || header != 50 {
335        return Err(pwd_err(format!(
336            "not a recognized PowerWorld display file (header word {header}; the probed saves all \
337             carry 50)",
338        )));
339    }
340    if canvas_width == 0 || canvas_height == 0 {
341        return Err(pwd_err("display header canvas dimensions are zero"));
342    }
343    let stamp = header_stamp(bytes).unwrap_or(0);
344    if stamp == 0 {
345        return Err(pwd_err(
346            "display header stamp is zero; every validated save carries a nonzero stamp the \
347             drawing records repeat",
348        ));
349    }
350    Ok((canvas_width, canvas_height, stamp))
351}
352
353/// The per file stamp every drawing object record repeats at +18.
354///
355/// A save with no canvas title puts it at offset 22. A save with one writes a
356/// u16 length at offset 10, a zero u16, the title text, and eight zero bytes,
357/// which shifts the stamp by the title length, so offset 22 holds title text
358/// and reads as a zero stamp. The shifted position is taken only when the
359/// whole title structure validates and the word past the stamp is the trailer
360/// every probed save writes, so a header that is not this shape reads the
361/// untitled position.
362fn header_stamp(bytes: &[u8]) -> Option<u32> {
363    let title_len = usize::from(u16_at(bytes, 10)?);
364    let titled_at = UNTITLED_STAMP_AT.checked_add(title_len)?;
365    let title_is_shaped = title_len <= MAX_TITLE_LEN
366        && u16_at(bytes, 12) == Some(0)
367        && bytes
368            .get(14..14 + title_len)
369            .is_some_and(|title| title.iter().all(|&c| (0x20..0x7f).contains(&c)))
370        && bytes
371            .get(14 + title_len..UNTITLED_STAMP_AT + title_len)
372            .is_some_and(|gap| gap.iter().all(|&c| c == 0));
373    if title_is_shaped
374        && u32_at(bytes, titled_at).is_some_and(|stamp| stamp != 0)
375        && u32_at(bytes, titled_at + 4) == Some(HEADER_TRAILER)
376    {
377        return u32_at(bytes, titled_at);
378    }
379    u32_at(bytes, UNTITLED_STAMP_AT)
380}
381
382fn parse_pwd_inner(bytes: &[u8]) -> Result<PwdDisplay> {
383    let (canvas_width, canvas_height, stamp) = parse_pwd_header(bytes)?;
384
385    let identity = find_identity_table(bytes)?;
386    if identity.is_empty() {
387        return Ok(PwdDisplay {
388            canvas_width,
389            canvas_height,
390            stamp,
391            substations: Vec::new(),
392        });
393    }
394
395    // Every drawing object record repeats the header stamp at +18 and dual
396    // encodes its position (f64 at +22/+30, f32 echo at +2/+6); the scan
397    // collects every offset with that shape and groups by the u16 type tag.
398    // Keyed by type tag so grouping is O(log tags) per record: a crafted file
399    // can spread gate-passing records across up to 65536 distinct tags, and a
400    // linear scan per record would be quadratic in the file size.
401    let mut groups: BTreeMap<u16, Vec<DrawRecord>> = BTreeMap::new();
402    for i in 0..bytes.len().saturating_sub(38) {
403        if u32_at(bytes, i + 18) != Some(stamp) {
404            continue;
405        }
406        let (Some(x), Some(y)) = (f64_at(bytes, i + 22), f64_at(bytes, i + 30)) else {
407            continue;
408        };
409        if !x.is_finite() || !y.is_finite() {
410            continue;
411        }
412        #[allow(clippy::cast_possible_truncation)] // the echo is the f32 rounding by design
413        let (rx, ry) = (x as f32, y as f32);
414        // Bit equality: the magnitude gate below excludes zero, so the only
415        // value the echo can hold is the rounded f64 itself.
416        if f32_at(bytes, i + 2).map(f32::to_bits) != Some(rx.to_bits())
417            || f32_at(bytes, i + 6).map(f32::to_bits) != Some(ry.to_bits())
418        {
419            continue;
420        }
421        let magnitude = x.abs().max(y.abs());
422        if !(1.0..1.0e7).contains(&magnitude) {
423            continue;
424        }
425        let Some(tag) = u16_at(bytes, i) else {
426            continue;
427        };
428        let rec = DrawRecord { at: i, x, y };
429        groups.entry(tag).or_default().push(rec);
430    }
431
432    // The substation group is the one whose records, in stream order, link
433    // every identity row in table order: a marker byte (0x03 or 0x07 by
434    // era) followed by the row's u32 number, somewhere in the style tail.
435    // Field label decoys carry other markers (0x05 observed) or another
436    // order and fail; ambiguity is a loud error, never a pick.
437    let matches: Vec<(&u16, &Vec<DrawRecord>)> = groups
438        .iter()
439        .filter(|(_, records)| {
440            records.len() == identity.len()
441                && records
442                    .iter()
443                    .zip(&identity)
444                    .all(|(rec, (number, _))| links_number(bytes, rec.at, *number))
445        })
446        .collect();
447    let (_, records) = match matches.as_slice() {
448        [one] => *one,
449        [] => {
450            return Err(pwd_err(format!(
451                "no drawing record group links the {} substation identity rows; the \
452                 DisplaySubstation layout of this save is not the validated one",
453                identity.len()
454            )));
455        }
456        several => {
457            return Err(pwd_err(format!(
458                "{} drawing record groups link the substation identity rows; refusing to guess \
459                 between them",
460                several.len()
461            )));
462        }
463    };
464
465    let substations = records
466        .iter()
467        .zip(identity)
468        .map(|(rec, (number, name))| PwdSubstation {
469            number,
470            name,
471            x: rec.x,
472            y: rec.y,
473        })
474        .collect();
475    Ok(PwdDisplay {
476        canvas_width,
477        canvas_height,
478        stamp,
479        substations,
480    })
481}
482
483/// A drawing record that passed the shape gate: its stream offset (for the
484/// identity link check) and the decoded coordinates, kept so the final mapping
485/// never re-reads the bytes.
486struct DrawRecord {
487    at: usize,
488    x: f64,
489    y: f64,
490}
491
492/// The substation identity table: exactly one valid walk behind a
493/// `ff ff ff ff 3d 0f` anchor. A missing table means there are no decoded
494/// substation symbols. Several tables are a loud error.
495fn find_identity_table(b: &[u8]) -> Result<Vec<(u32, String)>> {
496    // A crafted file can plant many IDENTITY_TAG anchors, each starting a walk
497    // that runs to a sentinel, so the total work is anchors × records. One
498    // shared budget over every record step across every anchor keeps that
499    // bounded; the largest real identity table is orders of magnitude below it.
500    let mut budget = 0u64;
501    let mut retained = Retention::default();
502    let mut tables = Vec::new();
503    for at in memmem(b, &IDENTITY_TAG) {
504        if let Some(rows) = identity_walk(b, at + IDENTITY_TAG.len(), &mut budget, &mut retained) {
505            tables.push(rows);
506        }
507        if budget > IDENTITY_WALK_BUDGET {
508            return Err(Error::FormatRead {
509                format: FMT,
510                message: "substation identity search exceeded its probe budget; the file is \
511                          not a decodable DisplaySubstation layout"
512                    .into(),
513            });
514        }
515        if retained.exceeded {
516            return Err(Error::FormatRead {
517                format: FMT,
518                message: "substation identity search exceeded its retention budget; the file \
519                          packs more identity rows than any decodable DisplaySubstation \
520                          layout states"
521                    .into(),
522            });
523        }
524    }
525    match tables.len() {
526        1 => Ok(tables.pop().unwrap()),
527        0 => Ok(Vec::new()),
528        n => Err(Error::FormatRead {
529            format: FMT,
530            message: format!(
531                "{n} byte ranges walk as a substation identity table; refusing to guess \
532                 between them"
533            ),
534        }),
535    }
536}
537
538/// Walk identity records (`u32 number, u32 duplicate, u32 length, name,
539/// 0x02`) from `at` until the next `ff ff ff ff` sentinel, which must
540/// arrive exactly at a record boundary. At least one record, numbers
541/// unique and plausible, names printable.
542/// Rows and name bytes retained across every walk of one parse, with the
543/// flag that turns exhaustion into the coded refusal rather than a silently
544/// shorter table (#274).
545#[derive(Default)]
546struct Retention {
547    rows: usize,
548    name_bytes: usize,
549    exceeded: bool,
550}
551
552fn identity_walk(
553    b: &[u8],
554    mut at: usize,
555    budget: &mut u64,
556    retained: &mut Retention,
557) -> Option<Vec<(u32, String)>> {
558    let mut rows = Vec::new();
559    let mut seen = HashSet::new();
560    loop {
561        // One record step; abandon the walk once the shared budget is spent so
562        // a file packed with anchors cannot force quadratic work.
563        *budget = budget.saturating_add(1);
564        if *budget > IDENTITY_WALK_BUDGET {
565            return None;
566        }
567        if b.get(at..).and_then(|s| s.get(..4)) == Some([0xff; 4].as_slice()) {
568            return (!rows.is_empty()).then_some(rows);
569        }
570        let number = u32_at(b, at)?;
571        let duplicate_at = at.checked_add(4)?;
572        if number == 0 || number > 99_999_999 || u32_at(b, duplicate_at) != Some(number) {
573            return None;
574        }
575        let len_at = at.checked_add(8)?;
576        let len = u32_at(b, len_at)? as usize;
577        if len == 0 || len >= 64 {
578            return None;
579        }
580        let name_start = at.checked_add(12)?;
581        let name_end = name_start.checked_add(len)?;
582        let name = b.get(name_start..name_end)?;
583        if !name.iter().all(|&c| (0x20..0x7f).contains(&c)) || b.get(name_end) != Some(&0x02) {
584            return None;
585        }
586        if !seen.insert(number) {
587            return None;
588        }
589        retained.rows += 1;
590        retained.name_bytes += name.len();
591        if retained.rows > IDENTITY_ROW_BUDGET || retained.name_bytes > IDENTITY_NAME_BYTE_BUDGET {
592            retained.exceeded = true;
593            return None;
594        }
595        rows.push((number, String::from_utf8_lossy(name).into_owned()));
596        at = name_end.checked_add(1)?;
597    }
598}
599
600/// Whether the drawing record at `i` links `number`: a marker byte 0x03 or
601/// 0x07 (the substation symbol markers of the two observed eras) directly
602/// followed by the number, inside the style tail window. The window is
603/// variable because a digit string of 1 to 4 characters precedes the link
604/// in some saves.
605fn links_number(b: &[u8], i: usize, number: u32) -> bool {
606    (40..140).any(|d| {
607        let Some(marker_at) = i.checked_add(d) else {
608            return false;
609        };
610        let Some(number_at) = marker_at.checked_add(1) else {
611            return false;
612        };
613        matches!(b.get(marker_at), Some(0x03 | 0x07)) && u32_at(b, number_at) == Some(number)
614    })
615}
616
617/// Every start of `needle` in `haystack`.
618fn memmem<'a>(haystack: &'a [u8], needle: &'a [u8]) -> impl Iterator<Item = usize> + 'a {
619    haystack
620        .windows(needle.len())
621        .enumerate()
622        .filter_map(move |(i, w)| (w == needle).then_some(i))
623}
624
625// Total little endian reads: `None` past the end of the buffer, no index
626// arithmetic that can panic or wrap. Every offset in this reader derives
627// from untrusted file bytes, so the accessors carry the bounds check.
628
629fn u16_at(b: &[u8], i: usize) -> Option<u16> {
630    Some(u16::from_le_bytes(*b.get(i..)?.first_chunk()?))
631}
632
633fn u32_at(b: &[u8], i: usize) -> Option<u32> {
634    Some(u32::from_le_bytes(*b.get(i..)?.first_chunk()?))
635}
636
637fn f32_at(b: &[u8], i: usize) -> Option<f32> {
638    Some(f32::from_le_bytes(*b.get(i..)?.first_chunk()?))
639}
640
641fn f64_at(b: &[u8], i: usize) -> Option<f64> {
642    Some(f64::from_le_bytes(*b.get(i..)?.first_chunk()?))
643}
644
645#[cfg(test)]
646mod retention_tests {
647    use super::*;
648
649    /// #274: the retention budget refuses a file densely packed with valid
650    /// identity rows, with a coded error rather than a silently shorter
651    /// table or GB-scale held rows.
652    #[test]
653    fn packed_identity_rows_hit_the_retention_budget() {
654        // Header word 50, nonzero canvas, then one anchor followed by more
655        // valid-looking rows than any decodable layout states.
656        let mut b = vec![0u8; 0x40];
657        b[0] = 50;
658        b[4] = 1; // canvas width
659        b[6] = 1; // canvas height
660        b[22] = 7; // nonzero stamp
661        b.extend_from_slice(&IDENTITY_TAG);
662        let name = b"S";
663        for number in 1..=(IDENTITY_ROW_BUDGET as u32 + 2) {
664            b.extend_from_slice(&number.to_le_bytes());
665            b.extend_from_slice(&number.to_le_bytes());
666            b.extend_from_slice(&(name.len() as u32).to_le_bytes());
667            b.extend_from_slice(name);
668            b.push(0x02);
669        }
670        b.extend_from_slice(&[0xff; 4]);
671        let error = parse_pwd(&b).unwrap_err().to_string();
672        assert!(error.contains("retention budget"), "{error}");
673    }
674}
675
676#[cfg(test)]
677mod drawing_tests {
678    use super::*;
679
680    fn symbol(tag: u16, stamp: u32, x: f64, y: f64, size: usize) -> Vec<u8> {
681        let mut b = vec![0; size];
682        b[..2].copy_from_slice(&tag.to_le_bytes());
683        b[2..6].copy_from_slice(&(x as f32).to_le_bytes());
684        b[6..10].copy_from_slice(&(y as f32).to_le_bytes());
685        b[18..22].copy_from_slice(&stamp.to_le_bytes());
686        b[22..30].copy_from_slice(&x.to_le_bytes());
687        b[30..38].copy_from_slice(&y.to_le_bytes());
688        b
689    }
690
691    fn drawing() -> Vec<u8> {
692        let mut b = vec![0; 64];
693        b[..4].copy_from_slice(&50u32.to_le_bytes());
694        b[4..6].copy_from_slice(&200u16.to_le_bytes());
695        b[6..8].copy_from_slice(&200u16.to_le_bytes());
696        b[22..26].copy_from_slice(&7u32.to_le_bytes());
697        b[26..30].copy_from_slice(&10105u32.to_le_bytes());
698        b.extend_from_slice(&[0x3c, 0x0f]);
699        for number in [1u32, 2] {
700            b.extend_from_slice(&number.to_le_bytes());
701            b.extend_from_slice(&1u32.to_le_bytes());
702            b.push(b'A');
703            b.extend_from_slice(&number.to_le_bytes());
704            b.push(0);
705            b.extend_from_slice(&0u32.to_le_bytes());
706            b.extend_from_slice(&345f32.to_le_bytes());
707        }
708        b.extend_from_slice(&IDENTITY_TAG);
709        b.extend_from_slice(&[0xff, 0xff, 0xff, 0xff, 0x3e, 0x0f]);
710        for (number, x, y) in [(1u32, 28500., 18900.), (2u32, 29500., 17900.)] {
711            let mut row = symbol(0x277e, 7, x, y, 96);
712            row[67..71].copy_from_slice(&1u32.to_le_bytes());
713            row[71] = b'A';
714            row[72..76].fill(0xff);
715            row[89] = 3;
716            row[90..94].copy_from_slice(&number.to_le_bytes());
717            b.extend(row);
718        }
719        let mut line = symbol(0x27b3, 7, 29000., 18400., 156);
720        line[79..83].copy_from_slice(&1u32.to_le_bytes());
721        line[83] = b'A';
722        line[84..88].fill(0xff);
723        line[147..151].copy_from_slice(&1u32.to_le_bytes());
724        line[151..155].copy_from_slice(&2u32.to_le_bytes());
725        b.extend(line);
726        let mut route = symbol(0x3131, 7, 29000., 18400., 178);
727        route[79..83].copy_from_slice(&1u32.to_le_bytes());
728        route[83] = b'A';
729        route[84..88].fill(0xff);
730        route[142..146].copy_from_slice(&2u32.to_le_bytes());
731        for (i, value) in [28500f64, 18900., 29500., 17900.].iter().enumerate() {
732            route[146 + i * 8..154 + i * 8].copy_from_slice(&value.to_le_bytes());
733        }
734        b.extend(route);
735        b
736    }
737
738    #[test]
739    fn bus_and_route_positions_keep_diagram_units() {
740        let bytes = drawing();
741        let parsed = parse_pwd_layer(&bytes).unwrap();
742        assert!(parsed.diagnostics.is_empty());
743        assert_eq!(parsed.layer.features.len(), 3);
744        assert!(matches!(
745            parsed.layer.space,
746            crate::geo::CoordinateSpace::Diagram { .. }
747        ));
748        assert_eq!(
749            parsed.layer.features[0].geometry,
750            crate::geo::GeoGeometry::Point([28500., 18900.])
751        );
752        let route = &parsed.layer.features[2];
753        assert_eq!(route.from.as_deref(), Some("1"));
754        assert_eq!(route.to.as_deref(), Some("2"));
755        let encoded = parsed.layer.to_geojson_checked().unwrap();
756        assert_eq!(
757            crate::geo::GeoLayer::parse(&encoded, None).unwrap().layer,
758            parsed.layer
759        );
760        let truncated = parse_pwd_layer(&bytes[..bytes.len() - 8]).unwrap();
761        assert!(!truncated.diagnostics.is_empty());
762        assert_eq!(truncated.layer.features.len(), 2);
763    }
764
765    #[test]
766    fn empty_and_mismatched_drawing_records_fail_explicitly() {
767        let mut b = drawing();
768        assert!(parse_pwd_layer(&b[..64]).is_err());
769        let starts: Vec<_> = memmem(&b, &[0x7e, 0x27]).collect();
770        for at in starts {
771            b[at + 2..at + 6].fill(0);
772        }
773        assert!(parse_pwd_layer(&b).is_err());
774    }
775}