diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs
--- a/crates/dwg/src/lib.rs
+++ b/crates/dwg/src/lib.rs
@@ -124,7 +124,7 @@ mod native {
             ));
         }
         written.map_err(|e| format!("DWG→DXF: {e}"))?;
-        Ok((add_header_vars(out.buf, &doc.header), notes))
+        Ok((add_purge_preventers(add_header_vars(out.buf, &doc.header), &doc, limits.max_dxf_bytes), notes))
     }
 
     /// Most record types named in the skipped-records note.
@@ -432,6 +432,139 @@ mod native {
         dxf
     }
 
+    /// Dynamic block purge preventers (`ACDB_DYNAMICBLOCKPURGEPREVENTER_VERSION`, in a dynamic
+    /// block record's extension dictionary) into the DXF. acadrust 0.6.5's DXF writer leaves them
+    /// out, entry and object, though its DXF reader and DWG writer handle them; without them a DWG
+    /// saved by CADCraft lets AutoCAD's PURGE remove a dynamic block's representations after a
+    /// round trip through an older version. Each goes back (groups after the DXF Reference's
+    /// common object groups: 70 version, 340 block record) with its entry in its dictionary, which
+    /// must be in the text without xdata; one whose handle is already there is left alone. A name that
+    /// isn't a plain DXF name, or a key with a line break, is left out, and so is what would take the
+    /// text past `limit` bytes. The OBJECTS section is indexed once.
+    fn add_purge_preventers(mut dxf: Vec<u8>, doc: &acadrust::CadDocument, limit: usize) -> Vec<u8> {
+        use acadrust::objects::{DynamicBlockData, ObjectType};
+        let preventer = |name: &str| {
+            let plain = !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
+            let n: String = name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_uppercase()).collect();
+            plain && n.starts_with("ACDBDYNAMICBLOCKPURGEPREVENTER")
+        };
+        let mut found: Vec<(acadrust::Handle, &str, &acadrust::objects::DynamicBlockObject)> = Vec::new();
+        for (h, o) in &doc.objects {
+            let ObjectType::Dictionary(d) = o else { continue };
+            for (key, e) in &d.entries {
+                if let Some(ObjectType::DynamicBlock(p)) = doc.objects.get(e)
+                    && preventer(&p.dxf_name)
+                    && !key.contains(['\r', '\n'])
+                    && p.owner == *h
+                {
+                    found.push((*h, key.as_str(), p));
+                }
+            }
+        }
+        if found.is_empty() {
+            return dxf;
+        }
+        let find = |dxf: &[u8], pat: &[u8], from: usize| dxf.get(from..).and_then(|t| t.windows(pat.len()).position(|w| w == pat)).map(|p| p + from);
+        // The section's own header: a layer or block named OBJECTS has a group 2 of that value too.
+        let Some(objects) = find(&dxf, b"  0\r\nSECTION\r\n  2\r\nOBJECTS\r\n", 0) else { return dxf };
+        let Some(objects_end) = find(&dxf, b"  0\r\nENDSEC\r\n", objects) else { return dxf };
+        let index = ObjectIndex::of(&dxf, objects, objects_end);
+        // (where, groups): dictionary entries, then the objects at the section's end.
+        let mut inserts: Vec<(usize, String)> = Vec::new();
+        let mut records = String::new();
+        let mut added = 0usize;
+        let hex = |h: acadrust::Handle| format!("{:X}", h.value());
+        for (dict, key, p) in found {
+            let DynamicBlockData::Representation(r) = &p.data else { continue };
+            if index.handles.contains(&hex(p.handle)) {
+                continue;
+            }
+            let Some(&(end, false)) = index.dictionaries.get(&hex(dict)) else { continue };
+            let entry = format!("  3\r\n{key}\r\n360\r\n{}\r\n", hex(p.handle));
+            let reactors: String = p.reactors.iter().map(|h| format!("330\r\n{}\r\n", hex(*h))).collect();
+            let reactors = if reactors.is_empty() { String::new() } else { format!("102\r\n{{ACAD_REACTORS\r\n{reactors}102\r\n}}\r\n") };
+            let record = format!(
+                "  0\r\n{name}\r\n  5\r\n{h}\r\n{reactors}330\r\n{owner}\r\n100\r\nAcDbDynamicBlockPurgePreventer\r\n 70\r\n{flags}\r\n340\r\n{block}\r\n",
+                name = p.dxf_name,
+                h = hex(p.handle),
+                owner = hex(p.owner),
+                flags = r.flags,
+                block = hex(r.block),
+            );
+            let grown = added.saturating_add(entry.len()).saturating_add(record.len());
+            if dxf.len().saturating_add(grown) > limit {
+                break;
+            }
+            added = grown;
+            inserts.push((end, entry));
+            records.push_str(&record);
+        }
+        // First among insertions at the same place (stable sort; one spliced later goes before it):
+        // an entry for the section's last dictionary stays in it, ahead of the new objects.
+        inserts.insert(0, (objects_end, records));
+        inserts.sort_by_key(|(at, _)| std::cmp::Reverse(*at));
+        for (at, text) in inserts {
+            dxf.splice(at..at, text.into_bytes());
+        }
+        dxf
+    }
+
+    /// The records of an ASCII DXF's OBJECTS section, read once: every record's own handle, and
+    /// for each DICTIONARY where its record ends and whether it carries xdata (upper-case hex).
+    struct ObjectIndex {
+        handles: std::collections::HashSet<String>,
+        dictionaries: std::collections::HashMap<String, (usize, bool)>,
+    }
+
+    impl ObjectIndex {
+        /// Index the records between `from` and `to` (the section's `ENDSEC` record).
+        fn of(dxf: &[u8], from: usize, to: usize) -> ObjectIndex {
+            let mut index = ObjectIndex { handles: Default::default(), dictionaries: Default::default() };
+            // The record being read: (kind, own handle, has xdata).
+            let mut open: Option<(String, Option<String>, bool)> = None;
+            let close = |open: Option<(String, Option<String>, bool)>, end: usize, index: &mut ObjectIndex| {
+                if let Some((kind, Some(h), xdata)) = open {
+                    if kind == "DICTIONARY" {
+                        index.dictionaries.entry(h.clone()).or_insert((end, xdata));
+                    }
+                    index.handles.insert(h);
+                }
+            };
+            let body = dxf.get(from..to).unwrap_or(&[]);
+            let mut at = 0;
+            let line = |at: usize| -> Option<(&[u8], usize)> {
+                let rest = body.get(at..)?;
+                let n = rest.windows(2).position(|w| w == b"\r\n")?;
+                Some((rest.get(..n)?, at + n + 2))
+            };
+            while let Some((code, next)) = line(at)
+                && let Some((value, after)) = line(next)
+            {
+                let value = String::from_utf8_lossy(value).trim().to_ascii_uppercase();
+                match String::from_utf8_lossy(code).trim() {
+                    "0" => {
+                        close(open.take(), from + at, &mut index);
+                        open = Some((value, None, false));
+                    }
+                    "5" => {
+                        if let Some((_, h @ None, _)) = &mut open {
+                            *h = Some(value);
+                        }
+                    }
+                    "1001" => {
+                        if let Some((_, _, x)) = &mut open {
+                            *x = true;
+                        }
+                    }
+                    _ => {}
+                }
+                at = after;
+            }
+            close(open, to, &mut index);
+            index
+        }
+    }
+
     /// Collects the DXF text and fails the write once it would grow past `limit`.
     struct CappedWriter {
         buf: Vec<u8>,
@@ -639,6 +772,78 @@ mod native {
             }
         }
     }
+    #[cfg(test)]
+    mod purge_preventer_tests {
+        use acadrust::objects::{BlockRepresentationData, DynamicBlockData, DynamicBlockObject, ObjectType};
+        use acadrust::{CadDocument, DxfVersion};
+
+        const NAME: &str = "ACDB_DYNAMICBLOCKPURGEPREVENTER_VERSION";
+
+        /// A document whose model space extension dictionary holds a purge preventer named `name`,
+        /// and acadrust's DXF of it (which leaves the preventer out).
+        fn source(name: &str) -> Result<(CadDocument, Vec<u8>), Box<dyn std::error::Error>> {
+            let mut doc = CadDocument::with_version(DxfVersion::AC1032);
+            let ms = doc.block_records.get("*Model_Space").ok_or("model space")?.handle;
+            let xd = doc.ensure_extension_dictionary(ms);
+            let h = doc.allocate_handle();
+            let mut p = DynamicBlockObject::new(name, "AcDbDynamicBlockPurgePreventer");
+            p.handle = h;
+            p.owner = xd;
+            p.reactors = vec![xd];
+            p.data = DynamicBlockData::Representation(BlockRepresentationData { flags: 1, block: ms });
+            doc.objects.insert(h, ObjectType::DynamicBlock(p));
+            let Some(ObjectType::Dictionary(d)) = doc.objects.get_mut(&xd) else { return Err("extension dictionary".into()) };
+            d.entries.push(("AcDbDynamicBlockRoundTripPurgePreventer".into(), h));
+            let mut dxf = Vec::new();
+            acadrust::DxfWriter::new(&doc).write_to_writer(&mut dxf)?;
+            Ok((doc, dxf))
+        }
+
+        /// How many times `pat` occurs in `dxf`.
+        fn count(dxf: &[u8], pat: &str) -> usize {
+            dxf.windows(pat.len()).filter(|w| *w == pat.as_bytes()).count()
+        }
+
+        /// A preventer goes back into the DXF once; a second pass leaves the text alone.
+        #[test]
+        fn a_preventer_goes_back_once() -> Result<(), Box<dyn std::error::Error>> {
+            let (doc, dxf) = source(NAME)?;
+            assert_eq!(count(&dxf, &format!("  0\r\n{NAME}\r\n")), 0, "acadrust's DXF leaves it out");
+            let once = super::add_purge_preventers(dxf, &doc, usize::MAX);
+            assert_eq!(count(&once, &format!("  0\r\n{NAME}\r\n")), 1);
+            assert_eq!(count(&once, "AcDbDynamicBlockRoundTripPurgePreventer"), 1);
+            // Already there: left alone.
+            let twice = super::add_purge_preventers(once.clone(), &doc, usize::MAX);
+            assert_eq!(twice, once);
+            Ok(())
+        }
+
+        /// A layer named OBJECTS (its group 2 comes before the OBJECTS section's) doesn't take the
+        /// restored records: they go into the OBJECTS section.
+        #[test]
+        fn a_layer_named_objects_is_not_the_objects_section() -> Result<(), Box<dyn std::error::Error>> {
+            let (mut doc, _) = source(NAME)?;
+            doc.layers.add(acadrust::tables::Layer::new("OBJECTS"))?;
+            let mut dxf = Vec::new();
+            acadrust::DxfWriter::new(&doc).write_to_writer(&mut dxf)?;
+            let out = super::add_purge_preventers(dxf, &doc, usize::MAX);
+            let text = String::from_utf8_lossy(&out);
+            let section = text.find("  0\r\nSECTION\r\n  2\r\nOBJECTS\r\n").ok_or("OBJECTS section")?;
+            let record = text.find(&format!("  0\r\n{NAME}\r\n")).ok_or("restored record")?;
+            assert!(record > section, "the record is in the OBJECTS section");
+            Ok(())
+        }
+
+        /// A name that would break the DXF structure, or a record past the size limit, is left out.
+        #[test]
+        fn a_name_with_a_line_break_or_past_the_limit_is_left_out() -> Result<(), Box<dyn std::error::Error>> {
+            let (doc, dxf) = source(&format!("{NAME}\r\n  0\r\nLINE"))?;
+            assert_eq!(super::add_purge_preventers(dxf.clone(), &doc, usize::MAX), dxf);
+            let (doc, dxf) = source(NAME)?;
+            assert_eq!(super::add_purge_preventers(dxf.clone(), &doc, dxf.len()), dxf);
+            Ok(())
+        }
+    }
 }
 
 #[cfg(not(target_arch = "wasm32"))]
diff --git a/crates/io/tests/dwg_purge_preventer.rs b/crates/io/tests/dwg_purge_preventer.rs
new file mode 100644
--- /dev/null
+++ b/crates/io/tests/dwg_purge_preventer.rs
@@ -0,0 +1,68 @@
+//! Dynamic block purge preventers (FF1): acadrust 0.6.5's DXF writer leaves them out of the DXF
+//! CADCraft reads, so the DWG import puts them back; a DWG save then keeps them, in their block
+//! record's extension dictionary. Original synthetic source; the DWG oracle shares acadrust 0.6.5,
+//! no AutoCAD validation is claimed.
+#![cfg(not(target_arch = "wasm32"))]
+
+use acadrust::classes::DxfClass;
+use acadrust::entities::Line;
+use acadrust::objects::{BlockRepresentationData, DynamicBlockData, DynamicBlockObject, ObjectType};
+use acadrust::{CadDocument, DwgWriter, DxfVersion, EntityType};
+
+type TestResult<T = ()> = Result<T, Box<dyn std::error::Error>>;
+
+const NAME: &str = "ACDB_DYNAMICBLOCKPURGEPREVENTER_VERSION";
+const KEY: &str = "AcDbDynamicBlockRoundTripPurgePreventer";
+
+/// AC1032 with a line and model space's extension dictionary holding a purge preventer.
+fn source() -> TestResult<Vec<u8>> {
+    let mut doc = CadDocument::with_version(DxfVersion::AC1032);
+    doc.add_entity(EntityType::Line(Line::from_coords(0., 0., 0., 10., 0., 0.)))?;
+    let mut class = DxfClass::new(NAME, "AcDbDynamicBlockPurgePreventer");
+    class.application_name = "ObjectDBX Classes".into();
+    doc.classes.add_or_update(class);
+    let ms = doc.block_records.get("*Model_Space").ok_or("model space")?.handle;
+    let xd = doc.ensure_extension_dictionary(ms);
+    let h = doc.allocate_handle();
+    let mut p = DynamicBlockObject::new(NAME, "AcDbDynamicBlockPurgePreventer");
+    p.handle = h;
+    p.owner = xd;
+    p.reactors = vec![xd];
+    p.data = DynamicBlockData::Representation(BlockRepresentationData { flags: 1, block: ms });
+    doc.objects.insert(h, ObjectType::DynamicBlock(p));
+    let Some(ObjectType::Dictionary(d)) = doc.objects.get_mut(&xd) else { return Err("extension dictionary".into()) };
+    d.entries.push((KEY.into(), h));
+    Ok(DwgWriter::write_to_vec(&doc)?)
+}
+
+/// The purge preventer in model space's extension dictionary: (flags, whether it names model space).
+fn preventer(bytes: &[u8]) -> TestResult<Option<(i16, bool)>> {
+    let out = acadrust::DwgReader::from_stream(std::io::Cursor::new(bytes)).read_with_stats()?;
+    assert_eq!((out.stats.skipped_source_records, out.stats.recovered_errors), (0, 0));
+    let doc = out.document;
+    let ms = doc.block_records.get("*Model_Space").ok_or("model space")?.handle;
+    let Some(xd) = doc.extension_dictionary_handle(ms) else { return Ok(None) };
+    let Some(ObjectType::Dictionary(d)) = doc.objects.get(&xd) else { return Ok(None) };
+    let Some((_, h)) = d.entries.iter().find(|(k, _)| k == KEY) else { return Ok(None) };
+    Ok(match doc.objects.get(h) {
+        Some(ObjectType::DynamicBlock(DynamicBlockObject { dxf_name, owner, data: DynamicBlockData::Representation(r), .. }))
+            if dxf_name == NAME && *owner == xd =>
+        {
+            Some((r.flags, r.block == ms))
+        }
+        _ => None,
+    })
+}
+
+/// The preventer (flags and block) survives two DWG saves through CADCraft.
+#[test]
+fn a_purge_preventer_survives_two_dwg_saves() -> TestResult {
+    let mut bytes = source()?;
+    assert_eq!(preventer(&bytes)?, Some((1, true)));
+    for _ in 0..2 {
+        let d = cadcraft_io::read(&bytes, "in.dwg")?;
+        bytes = cadcraft_io::write_with(&d, "out.dwg", &cadcraft_io::SaveOptions::default())?;
+        assert_eq!(preventer(&bytes)?, Some((1, true)));
+    }
+    Ok(())
+}
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
 # File-format parity
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (DIMASSOC objects for angular, diametric and radial dimensions) · **Target:** Autodesk AutoCAD 2027
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (dynamic block purge preventers kept through DWG) · **Target:** Autodesk AutoCAD 2027
 
 Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work
 items are in [gaps.md](gaps.md#file-formats); the weighted score feeds
@@ -55,7 +55,11 @@ Everything lost in the DXF read/write paths below is also lost through DWG, plus
 report #57: a 10 MB DWG failed after about two minutes; limits now stop runaway conversions
 early (#175), but the throughput problem remains. DGN line style components that no line style
 definition uses (leftovers of DGN imports, hundreds of thousands in some drawings) are left out
-on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG ≈ 30% read,
+on read with a note, so such files no longer exceed the DXF limit (#1482). Dynamic block purge
+preventers (`ACDB_DYNAMICBLOCKPURGEPREVENTER_VERSION`, in a dynamic block record's extension
+dictionary), which acadrust 0.6.5's DXF output leaves out, are put back into the DXF CADCraft
+reads, so a DWG save keeps them (`dwg_purge_preventer`); without them AutoCAD's PURGE may remove
+a dynamic block's representations. **DWG ≈ 30% read,
 ≈ 15% write.**
 
 ## DXF, version by version
@@ -140,6 +144,7 @@ our output in AutoCAD black-box.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-24 | minor | DWG: dynamic block purge preventers, left out of acadrust's DXF output, are put back on read, so DWG saves keep them |
 | 2026-10-22 | minor | DXF/DWG save: angular, diametric and radial dimensions get standard DIMASSOC objects (one centre reference to the circle for radial/diametric, the definition points for angular), so AutoCAD follows their geometry; the standard objects of those kinds are read back too, without CADCraft's xdata (#1651) |
 | 2026-10-19 | minor | DXF/DWG save: angular dimension block text honors DIMDSEP, including per-dimension overrides (#1646) |
 | 2026-10-19 | minor | A file's own arrowhead blocks with standard names (AutoCAD's `_Dot`, `_DotSmall`), named only by a dimension style, are kept and saved as they were instead of being replaced by CADCraft's solids; PURGE keeps arrowhead blocks dimension styles name |
