aep export: review fixes for #3278

- tree::Fixed replaces tree::fixed: a fixed layout writes every field that
  fits (as the old code did) and keeps the first error; finish() logs it
  once with the layout's description and counts it per thread, so the
  every-layout test cannot race with other tests.
- base::bytes logs one line naming its length and fields.
- masks::build_atom errors when the prototype has no Mask Shape instead of
  dropping the path of every mask quietly.
- log sorted into the dependency list.
This commit is contained in:
vangel76 committed 2026-11-08 03:56:50 +01:00
1 parent a4f9a97525
commit 9e82a5e704
6 files changed
+191 -105

No files matched your search

+1 -1
View File
@@ -15,9 +15,9 @@ effectcraft-color = { workspace = true }
effectcraft-keyframe = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-effects = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+12 -14
View File
@@ -7,24 +7,20 @@
use crate::Chunk;
use crate::export::synth::{GROUP_END, tdmn, tdsn};
use crate::export::tree::{R, fixed, put};
use crate::export::tree::Fixed;
/// A zero buffer of `len` bytes with `fields` (offset, bytes) written into it. The layouts are
/// fixed in code ([`fixed`]).
/// fixed in code ([`Fixed`]).
pub(crate) fn bytes(len: usize, fields: &[(usize, &[u8])]) -> Vec<u8> {
fixed("base::bytes", try_bytes(len, fields), || {
// Which layout: its length and fields (the builders are named in the comments above).
log::error!("aep export: base::bytes layout of {len} bytes, fields at {:?}", fields.iter().map(|(at, v)| (*at, v.len())).collect::<Vec<_>>());
vec![0u8; len]
})
layout(len, fields).finish(|| format!("base::bytes ({len} bytes, fields at {:?})", fields.iter().map(|(at, v)| (*at, v.len())).collect::<Vec<_>>()))
}
fn try_bytes(len: usize, fields: &[(usize, &[u8])]) -> R<Vec<u8>> {
let mut b = vec![0u8; len];
fn layout(len: usize, fields: &[(usize, &[u8])]) -> Fixed {
let mut b = Fixed::zeroed(len);
for (at, v) in fields {
put(&mut b, *at, v)?;
b.put(*at, v);
}
Ok(b)
b
}
pub(crate) fn utf8(text: &str) -> Chunk<'static> {
@@ -286,9 +282,11 @@ mod tests {
#[test]
fn a_field_out_of_range_is_an_error() {
assert_eq!(try_bytes(4, &[(0, &[1]), (3, &[2])]).ok(), Some(vec![1, 0, 0, 2]));
assert!(try_bytes(4, &[(3, &[1, 2])]).is_err());
assert!(try_bytes(4, &[(usize::MAX, &[1])]).is_err());
let ok = layout(4, &[(0, &[1]), (3, &[2])]);
assert!(ok.error().is_none());
assert_eq!(ok.finish(String::new), [1, 0, 0, 2]);
assert!(layout(4, &[(3, &[1, 2])]).error().is_some());
assert!(layout(4, &[(usize::MAX, &[1])]).error().is_some());
}
/// The chunks After Effects refuses a file without, or crashes on (aep-export.md, "What After
+37 -35
View File
@@ -12,7 +12,7 @@ use crate::Chunk;
use crate::effect_map::{Conv, by_match_name};
use crate::export::fx_defs::{BUILT_IN, FxDef, PDef};
use crate::export::synth::{GROUP_END, KeyTemplate, NO_NAME, RecSpec, class, tdmn, tdsn, utf8_in};
use crate::export::tree::{R, fixed as fixed_layout, put};
use crate::export::tree::Fixed;
use crate::{CURVE_IDENTITY, CurvesData, MeshData};
/// The layer a prototype's layer parameters name when their default is the owning layer; the
@@ -48,91 +48,91 @@ fn fixed(v: f64) -> [u8; 4] {
/// display 124; fixed and integer: 124 / 128 / 132 / 136, default 140, display 144). Every other
/// byte is zero (the switches at 48 are not needed: s12-e2-nofl).
pub(crate) fn pard(p: &PDef, popup_items: Option<u16>) -> Vec<u8> {
fixed_layout("pard", try_pard(p, popup_items), || vec![0u8; 148])
pard_layout(p, popup_items).finish(|| format!("pard (type {})", p.code))
}
fn try_pard(p: &PDef, popup_items: Option<u16>) -> R<Vec<u8>> {
let mut b = vec![0u8; 148];
put(&mut b, 12, &u32::from(p.code).to_be_bytes())?;
fn pard_layout(p: &PDef, popup_items: Option<u16>) -> Fixed {
let mut b = Fixed::zeroed(148);
b.put(12, &u32::from(p.code).to_be_bytes());
let d = |i: usize| p.default.get(i).copied().unwrap_or(0.0);
// After Effects takes the default of a parameter without a value record from here (s8-e1:
// the copies below are the ones it reads).
match p.code {
10 => {
put(&mut b, 56, &d(0).to_be_bytes())?;
b.put(56, &d(0).to_be_bytes());
// The default again as `f32` at 120, the display word (decimals) at 124.
put(&mut b, 120, &(d(0) as f32).to_be_bytes())?;
put(&mut b, 124, &p.display.to_be_bytes())?;
b.put(120, &(d(0) as f32).to_be_bytes());
b.put(124, &p.display.to_be_bytes());
}
2 => put(&mut b, 56, &fixed(d(0)))?,
2 => b.put(56, &fixed(d(0))),
// An angle: the default again at 60.
3 => {
put(&mut b, 56, &fixed(d(0)))?;
put(&mut b, 60, &fixed(d(0)))?;
b.put(56, &fixed(d(0)));
b.put(60, &fixed(d(0)));
}
1 => put(&mut b, 56, &(d(0).round() as i32).to_be_bytes())?,
1 => b.put(56, &(d(0).round() as i32).to_be_bytes()),
// A checkbox: the default again as a byte at 60.
4 => {
put(&mut b, 56, &(d(0).round().max(0.0) as u32).to_be_bytes())?;
put(&mut b, 60, &[u8::from(d(0) != 0.0)])?;
b.put(56, &(d(0).round().max(0.0) as u32).to_be_bytes());
b.put(60, &[u8::from(d(0) != 0.0)]);
}
// A popup: the default again as `u16` at 62 (the item count is at 60).
7 => {
let sel = d(0).round().clamp(0.0, f64::from(u16::MAX));
put(&mut b, 56, &(sel as u32).to_be_bytes())?;
put(&mut b, 62, &(sel as u16).to_be_bytes())?;
b.put(56, &(sel as u32).to_be_bytes());
b.put(62, &(sel as u16).to_be_bytes());
}
6 => {
// Fractions of the layer at 56 / 60 and the same in percent at 68 / 72.
put(&mut b, 56, &fixed(d(0)))?;
put(&mut b, 60, &fixed(d(1)))?;
put(&mut b, 68, &fixed(d(0) * 100.0))?;
put(&mut b, 72, &fixed(d(1) * 100.0))?;
b.put(56, &fixed(d(0)));
b.put(60, &fixed(d(1)));
b.put(68, &fixed(d(0) * 100.0));
b.put(72, &fixed(d(1) * 100.0));
}
5 => {
// ARGB at 56 and the same at 60.
let c: Vec<u8> = (0..4).map(|i| d(i).round().clamp(0.0, 255.0) as u8).collect();
put(&mut b, 56, &c)?;
put(&mut b, 60, &c)?;
b.put(56, &c);
b.put(60, &c);
}
_ => {}
}
if p.code == 7 {
let n = popup_items.unwrap_or_else(|| u16::try_from(p.display).unwrap_or(0));
put(&mut b, 60, &n.to_be_bytes())?;
b.put(60, &n.to_be_bytes());
}
match p.code {
10 => {
if let Some(m) = p.valid_min {
put(&mut b, 104, &(m as f32).to_be_bytes())?;
b.put(104, &(m as f32).to_be_bytes());
}
if let Some(m) = p.valid_max {
put(&mut b, 108, &(m as f32).to_be_bytes())?;
b.put(108, &(m as f32).to_be_bytes());
}
if let Some((lo, hi)) = p.range {
put(&mut b, 112, &(lo as f32).to_be_bytes())?;
put(&mut b, 116, &(hi as f32).to_be_bytes())?;
b.put(112, &(lo as f32).to_be_bytes());
b.put(116, &(hi as f32).to_be_bytes());
}
}
1 | 2 => {
let enc = |v: f64| if p.code == 2 { fixed(v) } else { (v.round() as i32).to_be_bytes() };
if let Some(m) = p.valid_min {
put(&mut b, 124, &enc(m))?;
b.put(124, &enc(m));
}
if let Some(m) = p.valid_max {
put(&mut b, 128, &enc(m))?;
b.put(128, &enc(m));
}
if let Some((lo, hi)) = p.range {
put(&mut b, 132, &enc(lo))?;
put(&mut b, 136, &enc(hi))?;
b.put(132, &enc(lo));
b.put(136, &enc(hi));
}
// The default again at 140, the display word (decimals, percent) at 144.
put(&mut b, 140, &enc(d(0)))?;
put(&mut b, 144, &p.display.to_be_bytes())?;
b.put(140, &enc(d(0)));
b.put(144, &p.display.to_be_bytes());
}
_ => {}
}
Ok(b)
b
}
/// The default arbitrary data of an effect (its type-11 parameter), from the decoded layouts
@@ -278,7 +278,9 @@ mod tests {
fn every_parameter_definition_fits() {
for fx in FX_DEFS {
for (i, p) in fx.params.iter().enumerate() {
assert_eq!(try_pard(p, popup_items(fx, i)).map(|b| b.len()).ok(), Some(148), "{} {}", fx.match_name, i);
let b = pard_layout(p, popup_items(fx, i));
assert!(b.error().is_none(), "{} {}", fx.match_name, i);
assert_eq!(b.finish(String::new).len(), 148);
}
}
}
+52 -15
View File
@@ -21,7 +21,7 @@ use crate::export::keys::{set_key_list, timed_keys};
use crate::export::plan::{CompPlan, EaseSlot, KeyPlan, LIMIT, cut_name};
use crate::export::report::{Collector, LossCategory};
use crate::export::synth::GROUP_END;
use crate::export::tree::{R, child_data, child_mut, fixed, kids_mut, list_mut, put, put_f32, put_f64, put_i32, put_u8, put_u16, put_u32, split_group, tpl};
use crate::export::tree::{Fixed, R, child_data, child_mut, kids_mut, list_mut, put_f32, put_f64, put_i32, put_u8, put_u16, put_u32, split_group, tpl};
/// The most vertices one mask path may have (as the importer).
pub(crate) const MAX_VERTICES: usize = 65_536;
@@ -365,26 +365,25 @@ const MASK_LIMIT: f64 = 32_000.0;
/// s13-m1), byte 3 `0x01` (`0x09` once the path is open), the box at 4..19 (written per shape),
/// byte 20 1.
pub(crate) fn shph() -> Vec<u8> {
fixed("shph", try_shph(), || vec![0u8; 24])
shph_layout().finish(|| "shph".to_string())
}
fn try_shph() -> R<Vec<u8>> {
let mut b = vec![0u8; 24];
put(&mut b, 0, &[0xb3, 0xde, 0x02, 0x01])?;
put_u8(&mut b, 20, 1)?;
Ok(b)
fn shph_layout() -> Fixed {
let mut b = Fixed::zeroed(24);
b.put(0, &[0xb3, 0xde, 0x02, 0x01]);
b.put(20, &[1]);
b
}
/// The two prototype records of a keyed path (093): 64 bytes each, flags `0x0001`, the `f64` at
/// 16 1 in the first (the rest is zero: its last bytes were uninitialised).
fn path_key_records() -> R<Vec<u8>> {
let mut first = vec![0u8; 64];
let mut later = vec![0u8; 64];
for r in [&mut first, &mut later] {
put_u16(r, 6, 0x0001)?;
fn path_key_records() -> Fixed {
let mut b = Fixed::zeroed(128);
for at in [6, 64 + 6] {
b.put(at, &0x0001u16.to_be_bytes());
}
put_f64(&mut first, 16, 1.0)?;
Ok([first, later].concat())
b.put(16, &1.0f64.to_be_bytes());
b
}
impl MaskProtos {
@@ -400,7 +399,7 @@ impl MaskProtos {
let path_rec = |keyed: bool| {
let mut kids = vec![state(), tdsn(NO_NAME), Chunk::data(*b"tdb4", tdb4(&path, keyed, 25_600))];
if keyed {
kids.push(key_list(64, 2, fixed("keyed path", path_key_records(), || vec![0u8; 128])));
kids.push(key_list(64, 2, path_key_records().finish(|| "the keyed path records".to_string())));
} else {
kids.push(Chunk::data(*b"cdat", vec![0u8; 4]));
}
@@ -465,6 +464,7 @@ fn build_atom(t: &MaskProtos, m: &MaskPlan, size: (u32, u32)) -> R<Chunk<'static
crate::export::tree::set_utf8(tdsn, &m.name)?;
}
let (mut out, entries) = split_group(kids_mut(&mut atom)?)?;
let mut has_shape = false;
for (name, entry) in entries {
// The closing Group End (no node) is kept as it is.
let [tdmn, mut node]: [Chunk<'static>; 2] = match entry.try_into() {
@@ -477,6 +477,7 @@ fn build_atom(t: &MaskProtos, m: &MaskPlan, size: (u32, u32)) -> R<Chunk<'static
let keep = match name.as_str() {
"ADBE Mask Shape" => {
build_shape(t, &mut node, m, size)?;
has_shape = true;
true
}
"ADBE Mask Feather" => {
@@ -511,6 +512,10 @@ fn build_atom(t: &MaskProtos, m: &MaskPlan, size: (u32, u32)) -> R<Chunk<'static
out.push(node);
}
}
// A prototype without the path would drop every mask's shape.
if !has_shape {
return Err(tpl("mask atom without its path"));
}
*kids_mut(&mut atom)? = out;
Ok(atom)
}
@@ -623,3 +628,35 @@ pub(crate) fn build_shap(proto: &Chunk<'static>, s: &ShapePlan) -> R<Chunk<'stat
}
Ok(shap)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::export::synth::is_tdmn;
fn mask() -> MaskPlan {
let shape = ShapePlan { bbox: [0.0, 0.0, 1.0, 1.0], open: false, points: vec![[0.0, 0.0]; 3] };
MaskPlan {
name: "M".into(),
mode: 1,
inverted: false,
shape,
path_keys: Vec::new(),
feather: None,
feather_keys: Vec::new(),
opacity: None,
expansion: None,
}
}
#[test]
fn an_atom_without_its_path_is_an_error() {
let mut t = MaskProtos::build();
assert!(build_atom(&t, &mask(), (100, 100)).is_ok());
// Without the Mask Shape pair the path of every mask would be dropped quietly.
let kids = kids_mut(&mut t.atom).unwrap();
let at = kids.iter().position(|c| is_tdmn(c, "ADBE Mask Shape")).unwrap();
kids.drain(at..at + 2);
assert!(build_atom(&t, &mask(), (100, 100)).is_err());
}
}
+25 -23
View File
@@ -4,7 +4,7 @@
//! evidence, or zero where After Effects was shown to accept zero (the read-backs listed in
//! `docs/aep-export.md`, "Built in code").
use crate::export::tree::{R, fixed, put, put_u32};
use crate::export::tree::{Fixed, R, put_u32};
use crate::{Body, Chunk};
/// `tdmn`: a match name padded to 40 bytes (at most 39 bytes of the name, so that a NUL remains).
@@ -123,12 +123,12 @@ impl RecSpec {
/// word and the constant doubles at 16..56 are written; After Effects reads the other bytes as
/// zero (read-back e1-ztdb4d).
pub(crate) fn tdb4(spec: &RecSpec, keyed: bool, time_scale: u32) -> Vec<u8> {
fixed("tdb4", try_tdb4(spec, keyed, time_scale), || vec![0u8; 124])
tdb4_layout(spec, keyed, time_scale).finish(|| "tdb4".to_string())
}
fn try_tdb4(spec: &RecSpec, keyed: bool, time_scale: u32) -> R<Vec<u8>> {
let mut b = vec![0u8; 124];
put(&mut b, 0, &[0xdb, 0x99, 0x00, spec.dims])?;
fn tdb4_layout(spec: &RecSpec, keyed: bool, time_scale: u32) -> Fixed {
let mut b = Fixed::zeroed(124);
b.put(0, &[0xdb, 0x99, 0x00, spec.dims]);
let spatial = match spec.template {
KeyTemplate::Plain => 0,
KeyTemplate::Spatial(t) => t,
@@ -137,36 +137,36 @@ fn try_tdb4(spec: &RecSpec, keyed: bool, time_scale: u32) -> R<Vec<u8>> {
Some(b) => (b & !0x01) | u8::from(!keyed),
None => (if spatial == 0 { 0 } else { spatial_byte5(spatial) }) | u8::from(!keyed),
};
put(&mut b, 5, &[byte5])?;
put(&mut b, 7, &[spatial])?;
put(&mut b, 8, &spec.class.to_be_bytes())?;
put(&mut b, 12, &time_scale.to_be_bytes())?;
b.put(5, &[byte5]);
b.put(7, &[spatial]);
b.put(8, &spec.class.to_be_bytes());
b.put(12, &time_scale.to_be_bytes());
// Every record After Effects writes carries 0.0001 and four 1.0 here; without them a roving
// key reads back at time zero instead of its retimed place (read-back s14-re-keyframes).
put(&mut b, 16, &1e-4f64.to_be_bytes())?;
b.put(16, &1e-4f64.to_be_bytes());
for at in [24, 32, 40, 48] {
put(&mut b, at, &1.0f64.to_be_bytes())?;
b.put(at, &1.0f64.to_be_bytes());
}
put(&mut b, 68, &[u8::from(keyed)])?;
b.put(68, &[u8::from(keyed)]);
// Set on a static spatial property, cleared once it is keyed (aep-format.md 3.4, byte 79).
put(&mut b, 79, &[u8::from(spatial != 0 && !keyed)])?;
Ok(b)
b.put(79, &[u8::from(spatial != 0 && !keyed)]);
b
}
/// A key list (`LIST list`: `lhd3` + `ldat`) of `count` records of `size` bytes (aep-format.md
/// 3.5): word 0 `0x00d00bee`, word 2 the count, word 3 the blocks of four (at least one), word 4
/// the size, words 5 and 7 the capacity (the count rounded up to four, at least two), word 6 one.
pub(crate) fn key_list(size: u32, count: u32, records: Vec<u8>) -> Chunk<'static> {
let lhd3 = fixed("lhd3", key_list_header(size, count), || vec![0u8; 52]);
let lhd3 = key_list_header(size, count).finish(|| "lhd3".to_string());
Chunk::list(*b"list", vec![Chunk::data(*b"lhd3", lhd3), Chunk::data(*b"ldat", records)])
}
fn key_list_header(size: u32, count: u32) -> R<Vec<u8>> {
let mut lhd3 = vec![0u8; 52];
put_u32(&mut lhd3, 0, 0x00d0_0bee)?;
put_u32(&mut lhd3, 24, 1)?;
put_key_counts(&mut lhd3, count, size)?;
Ok(lhd3)
fn key_list_header(size: u32, count: u32) -> Fixed {
let mut lhd3 = Fixed::zeroed(52);
lhd3.put(0, &0x00d0_0bee_u32.to_be_bytes());
lhd3.put(24, &1u32.to_be_bytes());
lhd3.apply(|d| put_key_counts(d, count, size));
lhd3
}
/// Sets the count words of a key list header for `count` keys of `size` bytes.
@@ -235,12 +235,14 @@ mod tests {
for template in [KeyTemplate::Plain, KeyTemplate::Spatial(1), KeyTemplate::Spatial(3), KeyTemplate::Spatial(u8::MAX)] {
for byte5 in [None, Some(0xff)] {
let spec = RecSpec { dims, template, class: u32::MAX, limits: None, byte5 };
assert!(try_tdb4(&spec, false, u32::MAX).is_ok() && try_tdb4(&spec, true, 0).is_ok());
assert!(tdb4_layout(&spec, false, u32::MAX).error().is_none() && tdb4_layout(&spec, true, 0).error().is_none());
}
}
}
for (size, count) in [(0, 0), (u32::MAX, u32::MAX), (48, 5)] {
assert_eq!(key_list_header(size, count).map(|h| h.len()).ok(), Some(52));
let h = key_list_header(size, count);
assert!(h.error().is_none());
assert_eq!(h.finish(String::new).len(), 52);
}
}
+64 -17
View File
@@ -97,27 +97,61 @@ pub(crate) fn put_cstr(d: &mut [u8], at: usize, len: usize, text: &str) -> R<()>
Ok(())
}
/// How many fixed layouts did not fit their buffer (see [`fixed`]); the tests keep it at zero.
static FIXED_LAYOUT_ERRORS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
/// The result of a builder whose layout is fixed in code (constant sizes and offsets, no input):
/// its writes go through [`put`], which errors when one does not fit. Such an error is a bug in
/// the layout, never something a project can cause, and the tests build every one of these
/// layouts and require no error. The builders keep their infallible signatures because files
/// outside this module call them without a `Result`; if a layout ever broke, `fallback` is used
/// and the error goes to the log.
pub(crate) fn fixed<T>(builder: &'static str, built: R<T>, fallback: impl FnOnce() -> T) -> T {
built.unwrap_or_else(|e| {
FIXED_LAYOUT_ERRORS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
log::error!("aep export: the fixed layout of {builder} does not fit: {e}");
fallback()
})
thread_local! {
/// How many fixed layouts on this thread did not fit their buffer (see [`Fixed`]); the tests
/// keep it at zero.
static FIXED_LAYOUT_ERRORS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// How many fixed layouts have failed in this process (for the tests).
/// A buffer whose layout is fixed in code (constant size and offsets, no input from a project).
/// Every write goes through [`put`]; a write that does not fit is skipped and its error kept, the
/// others are written. Such an error is a bug in the layout, never something a project can cause:
/// the tests build every one of these layouts and require no error. The builders keep their
/// infallible signatures because files outside this module call them without a `Result`; if a
/// layout ever broke, [`Fixed::finish`] logs it once and returns what fit.
pub(crate) struct Fixed {
bytes: Vec<u8>,
error: Option<AepExportError>,
}
impl Fixed {
/// `len` zero bytes.
pub(crate) fn zeroed(len: usize) -> Fixed {
Fixed { bytes: vec![0u8; len], error: None }
}
/// Writes `bytes` at `at`, or keeps the error (the first one) when they do not fit.
pub(crate) fn put(&mut self, at: usize, bytes: &[u8]) {
self.apply(|d| put(d, at, bytes));
}
/// Runs a writer on the buffer, keeping its error (the first one).
pub(crate) fn apply(&mut self, write: impl FnOnce(&mut [u8]) -> R<()>) {
if let Err(e) = write(&mut self.bytes) {
self.error.get_or_insert(e);
}
}
/// The error of the first write that did not fit (for the tests).
#[cfg(test)]
pub(crate) fn error(&self) -> Option<&AepExportError> {
self.error.as_ref()
}
/// The bytes; a write that did not fit is logged once, with what `layout` says.
pub(crate) fn finish(self, layout: impl FnOnce() -> String) -> Vec<u8> {
if let Some(e) = self.error {
FIXED_LAYOUT_ERRORS.with(|n| n.set(n.get().saturating_add(1)));
log::error!("aep export: the fixed layout of {} does not fit: {e}", layout());
}
self.bytes
}
}
/// How many fixed layouts have failed on this thread (for the tests).
#[cfg(test)]
pub(crate) fn fixed_layout_errors() -> usize {
FIXED_LAYOUT_ERRORS.load(std::sync::atomic::Ordering::Relaxed)
FIXED_LAYOUT_ERRORS.with(std::cell::Cell::get)
}
/// The entries of a property group by match name (see [`split_group`]).
@@ -179,10 +213,23 @@ mod tests {
/// text, cameras, lights, shapes, styles) are built and no write went out of range.
#[test]
fn every_fixed_layout_fits() {
assert_eq!(fixed_layout_errors(), 0);
assert!(crate::export::template::Template::load().is_ok());
assert_eq!(fixed_layout_errors(), 0);
}
#[test]
fn a_broken_fixed_layout_writes_what_fits_and_is_counted() {
let before = fixed_layout_errors();
let mut b = Fixed::zeroed(4);
b.put(0, &[1]);
b.put(3, &[2, 2]);
b.put(2, &[3]);
assert!(b.error().is_some());
assert_eq!(b.finish(|| "a test layout".to_string()), [1, 0, 3, 0]);
assert_eq!(fixed_layout_errors(), before + 1);
}
#[test]
fn a_group_splits_into_header_and_entries() {
use crate::export::synth::{tdmn, tdsn};