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
@@ -106,6 +106,7 @@ mod native {
         let mut doc = outcome.document;
         current_style_names(&mut doc);
         table_merges(&mut doc);
+        complete_sort_tables(&mut doc);
         table_style_rows(&mut doc, false);
         let orphans = drop_orphaned_dgn_components(&mut doc);
         if orphans > 0 {
@@ -231,6 +232,53 @@ mod native {
         })
     }
 
+    /// Draw order (SORTENTSTABLE) with every entity of its block listed. acadrust 0.6.5's DXF
+    /// writer replaces a table's sort handles by fresh handles in the same order, all above the
+    /// file's handles, while an entity the table doesn't list is drawn by its own handle: every
+    /// listed entity would move above every unlisted one. Listing the others by their own handles
+    /// keeps the source's order through the renumbering. One table per block is completed (the
+    /// one its extension dictionary names under `ACAD_SORTENTS`, else the lowest handle), so a
+    /// file with many tables for one block costs no more than its entity count.
+    fn complete_sort_tables(doc: &mut acadrust::CadDocument) {
+        use acadrust::objects::ObjectType;
+        let mut chosen: std::collections::HashMap<acadrust::Handle, acadrust::Handle> = std::collections::HashMap::new();
+        for (h, o) in &doc.objects {
+            if let ObjectType::SortEntitiesTable(t) = o {
+                let slot = chosen.entry(t.block_owner_handle).or_insert(*h);
+                if h.value() < slot.value() {
+                    *slot = *h;
+                }
+            }
+        }
+        for b in doc.block_records.iter() {
+            let named = doc.extension_dictionary_handle(b.handle).and_then(|x| match doc.objects.get(&x) {
+                Some(ObjectType::Dictionary(d)) => d.entries.iter().find(|(k, _)| k.eq_ignore_ascii_case("ACAD_SORTENTS")).map(|(_, h)| *h),
+                _ => None,
+            });
+            if let Some(n) = named
+                && matches!(doc.objects.get(&n), Some(ObjectType::SortEntitiesTable(t)) if t.block_owner_handle == b.handle)
+            {
+                chosen.insert(b.handle, n);
+            }
+        }
+        let mut missing: Vec<(acadrust::Handle, Vec<acadrust::Handle>)> = Vec::new();
+        for b in doc.block_records.iter() {
+            let Some(h) = chosen.get(&b.handle) else { continue };
+            let Some(ObjectType::SortEntitiesTable(t)) = doc.objects.get(h) else { continue };
+            let add: Vec<_> = b.entity_handles.iter().copied().filter(|e| !t.contains(*e)).collect();
+            if !add.is_empty() {
+                missing.push((*h, add));
+            }
+        }
+        for (h, add) in missing {
+            if let Some(ObjectType::SortEntitiesTable(t)) = doc.objects.get_mut(&h) {
+                for e in add {
+                    t.add_entry(e, e);
+                }
+            }
+        }
+    }
+
     /// Merged table cells onto the cells. acadrust 0.6.3 reads a 2010+ (AC1024+) DWG table's
     /// merged cells into the table's `merged_ranges` only, while its DXF writer writes each cell's
     /// own merge groups (173, 175, 176), so without this the merges would be lost. Tables whose
@@ -639,6 +687,53 @@ mod native {
             }
         }
     }
+
+    #[cfg(test)]
+    mod sort_table_tests {
+        use acadrust::entities::Line;
+        use acadrust::objects::{ObjectType, SortEntitiesTable};
+        use acadrust::{CadDocument, DxfVersion, EntityType, Handle};
+
+        /// A sort table for model space listing `listed`, owned by `owner`.
+        fn table(doc: &mut CadDocument, ms: Handle, owner: Handle, listed: Handle) -> Handle {
+            let h = doc.allocate_handle();
+            let mut t = SortEntitiesTable::for_block(ms);
+            t.handle = h;
+            t.owner_handle = owner;
+            t.add_entry(listed, listed);
+            doc.objects.insert(h, ObjectType::SortEntitiesTable(t));
+            h
+        }
+
+        /// Entries of the sort table `h`.
+        fn entries(doc: &CadDocument, h: Handle) -> usize {
+            match doc.objects.get(&h) {
+                Some(ObjectType::SortEntitiesTable(t)) => t.entries().count(),
+                _ => 0,
+            }
+        }
+
+        /// Two tables for model space: only the one its extension dictionary names gets the others.
+        #[test]
+        fn only_the_table_the_block_names_is_completed() -> Result<(), Box<dyn std::error::Error>> {
+            let mut doc = CadDocument::with_version(DxfVersion::AC1032);
+            let mut lines = Vec::new();
+            for i in 0..3 {
+                let y = f64::from(i);
+                lines.push(doc.add_entity(EntityType::Line(Line::from_coords(0., y, 0., 10., y, 0.)))?);
+            }
+            let ms = doc.block_records.get("*Model_Space").ok_or("model space")?.handle;
+            let xd = doc.ensure_extension_dictionary(ms);
+            // A stray table first (lower handle), then the one the block record names.
+            let stray = table(&mut doc, ms, xd, lines[2]);
+            let named = table(&mut doc, ms, xd, lines[0]);
+            let Some(ObjectType::Dictionary(d)) = doc.objects.get_mut(&xd) else { return Err("extension dictionary".into()) };
+            d.entries.push(("ACAD_SORTENTS".into(), named));
+            super::complete_sort_tables(&mut doc);
+            assert_eq!((entries(&doc, named), entries(&doc, stray)), (3, 1));
+            Ok(())
+        }
+    }
 }
 
 #[cfg(not(target_arch = "wasm32"))]
diff --git a/crates/engine/src/cmd/draworder.rs b/crates/engine/src/cmd/draworder.rs
--- a/crates/engine/src/cmd/draworder.rs
+++ b/crates/engine/src/cmd/draworder.rs
@@ -1,6 +1,7 @@
 //! Draw order: DRAWORDER (Above objects / Under objects / Front / Back), TEXTTOFRONT and
 //! HATCHTOBACK. The order is the order of a space's entity store, which DXF files keep as the
-//! order of their entity records (`cadcraft-io` also applies a SORTENTSTABLE when it reads one).
+//! order of their entity records (`cadcraft-io` applies a SORTENTSTABLE when it reads one, and
+//! writes one for every space drawn out of handle order).
 
 use std::collections::{HashMap, HashSet};
 
diff --git a/crates/io/src/draw_order.rs b/crates/io/src/draw_order.rs
new file mode 100644
--- /dev/null
+++ b/crates/io/src/draw_order.rs
@@ -0,0 +1,217 @@
+//! Draw order in the written file (DXF Reference, SORTENTSTABLE). CADCraft keeps a space's draw
+//! order as the order of its entities (DRAWORDER moves them; reading applies the file's table).
+//! The writer puts the entity records in that order; [`finish`] adds, for each space whose order
+//! isn't ascending handle order, a SORTENTSTABLE in its block record's extension dictionary, so
+//! programs that draw by the table (AutoCAD among them) draw the same order. The table lists each
+//! entity whose place doesn't match its own handle, with the handle at that place in ascending
+//! order as its sort handle: the same handle swap DRAWORDER makes in AutoCAD's own files.
+
+use std::collections::{HashMap, HashSet};
+
+use cadcraft_doc::Handle;
+use cadcraft_dxf::Tag;
+
+use crate::dxf_raw::{head, max_handle, seed, seed_index, split, val};
+
+/// Records in the ENTITIES and BLOCKS sections that aren't entities of a space: structure, and
+/// sub-entities (drawn with the entity that owns them).
+const NOT_DRAWN: &[&str] = &["SECTION", "ENDSEC", "BLOCK", "ENDBLK", "SEQEND", "VERTEX", "ATTRIB", "EOF"];
+
+/// The extension dictionary entry AutoCAD looks a block record's table up by.
+const KEY: &str = "ACAD_SORTENTS";
+
+/// A space's table: the entities whose sort handle isn't their own, as (entity, sort handle).
+fn table(order: &[Handle]) -> Option<Vec<(Handle, Handle)>> {
+    let mut sorted = order.to_vec();
+    sorted.sort_unstable();
+    // A handle twice (or none) can't be ordered by handle: no table rather than a wrong one.
+    if sorted.windows(2).any(|w| matches!(w, [a, b] if a == b)) || sorted.first().is_some_and(|h| h.0 == 0) {
+        return None;
+    }
+    let moved: Vec<_> = order.iter().zip(&sorted).filter(|(e, k)| e != k).map(|(e, k)| (*e, *k)).collect();
+    (!moved.is_empty()).then_some(moved)
+}
+
+/// Where a record's owner (330) is, outside its `102` groups (the record's end without one).
+fn owner_at(body: &[Tag]) -> usize {
+    let mut in_group = false;
+    for (i, t) in body.iter().enumerate() {
+        match t.code {
+            102 => in_group = t.str().trim().starts_with('{'),
+            330 if !in_group => return i,
+            _ => {}
+        }
+    }
+    body.len()
+}
+
+/// Add the SORTENTSTABLE of every space drawn out of handle order to the finished tags (after
+/// `dxf_raw::finish`, which places the entities of other programs' types and their extension
+/// dictionaries). A file without an OBJECTS section has nowhere to keep one.
+pub(crate) fn finish(tags: &mut Vec<Tag>) {
+    let mut sec = String::new();
+    // Block record handle → (index of its `0` group, its extension dictionary, groups after it).
+    let mut records: HashMap<String, (usize, Option<String>, usize)> = HashMap::new();
+    // Owner handle → its entities' handles in the order written; owners in first-seen order.
+    let mut spaces: HashMap<String, Vec<Handle>> = HashMap::new();
+    let mut owners: Vec<String> = Vec::new();
+    // DICTIONARY handle → (index of its `0` group, groups after it).
+    let mut dictionaries: HashMap<String, (usize, usize)> = HashMap::new();
+    let (mut objects_end, mut classes_end, mut has_class) = (None, None, false);
+    for (kind, start, body) in split(tags) {
+        match (kind.as_str(), sec.as_str()) {
+            ("SECTION", _) => sec = val(body, 2).unwrap_or_default(),
+            ("ENDSEC", s) => {
+                match s {
+                    "OBJECTS" => objects_end = Some(start),
+                    "CLASSES" => classes_end = Some(start),
+                    _ => {}
+                }
+                sec.clear();
+            }
+            ("CLASS", "CLASSES") => has_class |= val(body, 1).is_some_and(|n| n.trim().eq_ignore_ascii_case("SORTENTSTABLE")),
+            ("BLOCK_RECORD", "TABLES") => {
+                let h = head(body);
+                if let Some(handle) = h.handle {
+                    records.insert(handle, (start, h.xdict, body.len()));
+                }
+            }
+            ("DICTIONARY", "OBJECTS") => {
+                if let Some(handle) = head(body).handle {
+                    dictionaries.entry(handle).or_insert((start, body.len()));
+                }
+            }
+            (k, "ENTITIES" | "BLOCKS") if !NOT_DRAWN.contains(&k) => {
+                let h = head(body);
+                if let (Some(e), Some(o)) = (h.handle.as_deref().and_then(Handle::parse_hex), h.owner) {
+                    if !spaces.contains_key(&o) {
+                        owners.push(o.clone());
+                    }
+                    spaces.entry(o).or_default().push(e);
+                }
+            }
+            _ => {}
+        }
+    }
+    let Some(objects_end) = objects_end else { return };
+    let mut next = seed(tags).max(max_handle(tags).saturating_add(1));
+    let mut alloc = || {
+        let h = Handle(next);
+        next = next.saturating_add(1);
+        h.hex()
+    };
+    // Insertions (index, groups), applied from the last index back; new objects go at the end.
+    let mut inserts: Vec<(usize, Vec<Tag>)> = Vec::new();
+    let mut objects: Vec<Tag> = Vec::new();
+    let mut extended: HashSet<String> = HashSet::new();
+    for owner in &owners {
+        let Some(&(start, ref xdict, len)) = records.get(owner) else { continue };
+        let Some(moved) = spaces.get(owner).and_then(|o| table(o)) else { continue };
+        let table_h = alloc();
+        let xd = match xdict {
+            // The record's own extension dictionary gets the entry (once: a record has one table).
+            Some(x) => {
+                let Some(&(dstart, dlen)) = dictionaries.get(x) else { continue };
+                if !extended.insert(x.clone()) {
+                    continue;
+                }
+                let body = tags.get(dstart + 1..dstart + 1 + dlen).unwrap_or(&[]);
+                let at = dstart + 1 + body.iter().position(|t| t.code >= 1000).unwrap_or(body.len());
+                inserts.push((at, vec![Tag::s(3, KEY), Tag::s(360, table_h.clone())]));
+                x.clone()
+            }
+            None => {
+                let x = alloc();
+                // The group goes before the record's owner (DXF Reference, common group codes).
+                let body = tags.get(start + 1..start + 1 + len).unwrap_or(&[]);
+                let at = start + 1 + owner_at(body);
+                inserts.push((at, vec![Tag::s(102, "{ACAD_XDICTIONARY"), Tag::s(360, x.clone()), Tag::s(102, "}")]));
+                objects.extend([
+                    Tag::s(0, "DICTIONARY"),
+                    Tag::s(5, x.clone()),
+                    Tag::s(102, "{ACAD_REACTORS"),
+                    Tag::s(330, owner.clone()),
+                    Tag::s(102, "}"),
+                    Tag::s(330, owner.clone()),
+                    Tag::s(100, "AcDbDictionary"),
+                    Tag::i(280, 1),
+                    Tag::i(281, 1),
+                    Tag::s(3, KEY),
+                    Tag::s(360, table_h.clone()),
+                ]);
+                x
+            }
+        };
+        objects.extend([
+            Tag::s(0, "SORTENTSTABLE"),
+            Tag::s(5, table_h),
+            Tag::s(102, "{ACAD_REACTORS"),
+            Tag::s(330, xd.clone()),
+            Tag::s(102, "}"),
+            Tag::s(330, xd),
+            Tag::s(100, "AcDbSortentsTable"),
+            Tag::s(330, owner.clone()),
+        ]);
+        for (e, k) in moved {
+            objects.extend([Tag::s(331, e.hex()), Tag::s(5, k.hex())]);
+        }
+    }
+    if objects.is_empty() {
+        return;
+    }
+    // First among insertions at the same place: one spliced later goes before it, so a dictionary
+    // entry for the last record of the section stays in that record, ahead of the new objects.
+    inserts.insert(0, (objects_end, objects));
+    if let (false, Some(at)) = (has_class, classes_end) {
+        inserts.push((
+            at,
+            vec![
+                Tag::s(0, "CLASS"),
+                Tag::s(1, "SORTENTSTABLE"),
+                Tag::s(2, "AcDbSortentsTable"),
+                Tag::s(3, "ObjectDBX Classes"),
+                Tag::i(90, 0),
+                Tag::i(280, 0),
+                Tag::i(281, 0),
+            ],
+        ));
+    }
+    inserts.sort_by_key(|(at, _)| std::cmp::Reverse(*at)); // stable: keeps that order at a place
+    for (at, groups) in inserts {
+        let at = at.min(tags.len());
+        tags.splice(at..at, groups);
+    }
+    if let Some(i) = seed_index(tags)
+        && let Some(t) = tags.get_mut(i)
+    {
+        *t = Tag::s(5, Handle(next).hex());
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Handles from their values.
+    fn hs(v: &[u64]) -> Vec<Handle> {
+        v.iter().map(|h| Handle(*h)).collect()
+    }
+
+    /// Entities already in handle order (or none) need no table.
+    #[test]
+    fn a_space_in_handle_order_needs_no_table() {
+        assert_eq!(table(&hs(&[0x10, 0x20, 0x30])), None);
+        assert_eq!(table(&[]), None);
+    }
+
+    /// Moved entities swap handles; repeated or null handles give no table.
+    #[test]
+    fn the_table_swaps_handles_so_ascending_sort_handles_give_the_order() {
+        // 30 drawn first, 10 last: 30 takes 10's place, 10 takes 30's; 20 stays.
+        let t = table(&hs(&[0x30, 0x20, 0x10])).unwrap_or_default();
+        assert_eq!(t, vec![(Handle(0x30), Handle(0x10)), (Handle(0x10), Handle(0x30))]);
+        // A repeated or null handle can't be ordered by handle.
+        assert_eq!(table(&hs(&[0x20, 0x10, 0x20])), None);
+        assert_eq!(table(&hs(&[0x20, 0])), None);
+    }
+}
diff --git a/crates/io/src/dxf_raw.rs b/crates/io/src/dxf_raw.rs
--- a/crates/io/src/dxf_raw.rs
+++ b/crates/io/src/dxf_raw.rs
@@ -37,8 +37,9 @@ const MODELLED_KEYS: &[&str] = &[
 ];
 
 /// Extension dictionary entries not kept raw: dimension associativity (CADCraft reads and
-/// rewrites it), draw order and annotation scale contexts (written from their own models by the
-/// changes that model them; stale copies would contradict the drawing).
+/// rewrites it), draw order (applied on reading, written by `draw_order`) and annotation scale
+/// contexts (written from their own models by the changes that model them; stale copies would
+/// contradict the drawing).
 const MODELLED_XDICT_KEYS: &[&str] = &["ACAD_DIMASSOC", "ACAD_SORTENTS", "ACDBCONTEXTDATAMANAGER"];
 
 /// Applications whose xdata CADCraft reads and writes itself. `ACAD` holds dimension overrides
@@ -95,7 +96,7 @@ fn is_pointer(code: i32) -> bool {
 }
 
 /// The records of a run of tags: (type, index of the record's `0` group, groups after it).
-fn split(tags: &[Tag]) -> Vec<(String, usize, &[Tag])> {
+pub(crate) fn split(tags: &[Tag]) -> Vec<(String, usize, &[Tag])> {
     let mut out = Vec::new();
     let mut open: Option<(String, usize)> = None;
     for (i, t) in tags.iter().enumerate() {
@@ -116,15 +117,16 @@ type Recs<'a> = Vec<(String, usize, &'a [Tag])>;
 
 /// A record's handle, owner, reactors and extension dictionary (groups before its xdata).
 #[derive(Default)]
-struct Head {
-    handle: Option<String>,
-    owner: Option<String>,
+pub(crate) struct Head {
+    pub(crate) handle: Option<String>,
+    pub(crate) owner: Option<String>,
     reactors: Vec<String>,
-    xdict: Option<String>,
+    pub(crate) xdict: Option<String>,
     has_reactors: bool,
 }
 
-fn head(tags: &[Tag]) -> Head {
+/// A record's [`Head`], from its groups before any xdata.
+pub(crate) fn head(tags: &[Tag]) -> Head {
     let mut h = Head::default();
     let mut group: Option<String> = None;
     for t in tags.iter().take_while(|t| t.code < 1000) {
@@ -149,7 +151,8 @@ fn head(tags: &[Tag]) -> Head {
     h
 }
 
-fn val(tags: &[Tag], code: i32) -> Option<String> {
+/// The first value of group `code` before any xdata.
+pub(crate) fn val(tags: &[Tag], code: i32) -> Option<String> {
     tags.iter().take_while(|t| t.code < 1000).find(|t| t.code == code).map(Tag::str)
 }
 
@@ -1191,16 +1194,16 @@ fn add_appids(out: &mut Vec<Tag>) {
 }
 
 /// The index of the `$HANDSEED` value in the header.
-fn seed_index(tags: &[Tag]) -> Option<usize> {
+pub(crate) fn seed_index(tags: &[Tag]) -> Option<usize> {
     tags.windows(2).position(|w| matches!(w, [a, b] if a.code == 9 && a.str() == "$HANDSEED" && b.code == 5)).map(|i| i + 1)
 }
 
 /// The header's next free handle.
-fn seed(tags: &[Tag]) -> u64 {
+pub(crate) fn seed(tags: &[Tag]) -> u64 {
     seed_index(tags).and_then(|i| tags.get(i)).and_then(|t| Handle::parse_hex(&t.str())).map_or(0, |h| h.0)
 }
 
 /// The largest handle of a record (groups 5 and 105).
-fn max_handle(tags: &[Tag]) -> u64 {
+pub(crate) fn max_handle(tags: &[Tag]) -> u64 {
     tags.iter().filter(|t| t.code == 5 || t.code == 105).filter_map(|t| Handle::parse_hex(&t.str())).map(|h| h.0).max().unwrap_or(0)
 }
diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs
--- a/crates/io/src/dxf_write.rs
+++ b/crates/io/src/dxf_write.rs
@@ -2687,6 +2687,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
         *t = Tag::s(5, seed);
     }
     crate::dxf_raw::finish(&mut w.t, d);
+    crate::draw_order::finish(&mut w.t);
     crate::dxf_raw::data_store(&mut w.t, d, target >= Version::R2013);
     add_class_instance_counts(&mut w.t);
     mark_xrefs(&mut w.t, d);
diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs
--- a/crates/io/src/lib.rs
+++ b/crates/io/src/lib.rs
@@ -2,6 +2,8 @@
 #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
 #![forbid(unsafe_code)]
 
+/// Draw order written as SORTENTSTABLE records.
+mod draw_order;
 /// What a DXF file holds that CADCraft doesn't model, kept and written back (#528).
 mod dxf_raw;
 
diff --git a/crates/io/tests/it/sortents.rs b/crates/io/tests/it/sortents.rs
--- a/crates/io/tests/it/sortents.rs
+++ b/crates/io/tests/it/sortents.rs
@@ -1,12 +1,16 @@
 //! Draw order from DXF files (#439): a SORTENTSTABLE reorders its space by sort handle, and the
-//! order CADCraft writes (the order of the entity records) reads back unchanged.
+//! order CADCraft writes (the order of the entity records, and a SORTENTSTABLE when it isn't
+//! handle order) reads back unchanged. DWG: the table goes through the DWG bridge both ways
+//! (the DWG oracle shares acadrust 0.6.5; no AutoCAD validation is claimed).
 
 use cadcraft_doc::*;
 
+/// A LINE record in model space with handle `h` at height `y`.
 fn line(h: &str, y: f64) -> String {
     format!("0\nLINE\n5\n{h}\n330\n1F\n8\n0\n10\n0\n20\n{y}\n30\n0\n11\n10\n21\n{y}\n31\n0\n")
 }
 
+/// A DXF with model space, three lines (A1, A2, A3) and `objects` in OBJECTS.
 fn dxf(objects: &str) -> String {
     let mut s = String::from("0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nBLOCK_RECORD\n0\nBLOCK_RECORD\n5\n1F\n2\n*Model_Space\n0\nENDTAB\n0\nENDSEC\n");
     s.push_str("0\nSECTION\n2\nENTITIES\n");
@@ -19,21 +23,91 @@ fn dxf(objects: &str) -> String {
     s
 }
 
+/// Model space's entity handles in draw order.
 fn order(d: &Drawing) -> Vec<String> {
     d.model.iter().map(|e| e.handle.hex()).collect()
 }
 
+/// A table reorders model space; written back with a table, the order survives.
 #[test]
 fn sortentstable_sets_the_draw_order() {
     // A1 is drawn by sort handle A4 (over A2 and A3); A3 by 10 (under everything).
     let table = "0\nSORTENTSTABLE\n5\nB0\n330\nB1\n100\nAcDbSortentsTable\n330\n1F\n331\nA1\n5\nA4\n331\nA3\n5\n10\n";
     let d = cadcraft_io::read_dxf(dxf(table).as_bytes()).unwrap();
     assert_eq!(order(&d), ["A3", "A2", "A1"]);
-    // Written back in draw order, without a table, the order survives.
-    let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap();
+    // Written back in draw order, with a table that swaps A1 and A3 (A2 is in its handle's
+    // place), the order survives.
+    let text = cadcraft_io::write_dxf(&d);
+    let tags = cadcraft_dxf::parse(text.as_bytes()).expect("parse DXF");
+    let (table, xdict) = written_table(&tags).expect("a SORTENTSTABLE");
+    assert_eq!(table, [("A3".to_string(), "A1".to_string()), ("A1".into(), "A3".into())]);
+    assert!(xdict, "model space's extension dictionary has ACAD_SORTENTS");
+    let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();
     assert_eq!(order(&back), ["A3", "A2", "A1"]);
 }
 
+/// The written SORTENTSTABLE's (entity, sort handle) pairs, and whether the model space record's
+/// extension dictionary names it ACAD_SORTENTS.
+fn written_table(tags: &[cadcraft_dxf::Tag]) -> Option<(Vec<(String, String)>, bool)> {
+    let recs: Vec<&[cadcraft_dxf::Tag]> = {
+        let starts: Vec<usize> = tags.iter().enumerate().filter(|(_, t)| t.code == 0).map(|(i, _)| i).collect();
+        starts.iter().enumerate().map(|(k, &s)| &tags[s..starts.get(k + 1).copied().unwrap_or(tags.len())]).collect()
+    };
+    let kind = |r: &[cadcraft_dxf::Tag]| r.first().map(|t| t.str()).unwrap_or_default();
+    let get = |r: &[cadcraft_dxf::Tag], c: i32| r.iter().find(|t| t.code == c).map(|t| t.str());
+    let table = recs.iter().find(|r| kind(r) == "SORTENTSTABLE")?;
+    let handle = get(table, 5)?;
+    let mut pairs = Vec::new();
+    let mut ent = None;
+    for t in table.iter().skip_while(|t| t.code != 100).skip(1) {
+        match t.code {
+            331 => ent = Some(t.str()),
+            5 => pairs.extend(ent.take().map(|e| (e, t.str()))),
+            _ => {}
+        }
+    }
+    let ms = recs.iter().find(|r| kind(r) == "BLOCK_RECORD" && get(r, 2).as_deref() == Some("*Model_Space"))?;
+    let xd = ms.iter().skip_while(|t| t.str() != "{ACAD_XDICTIONARY").nth(1).map(|t| t.str())?;
+    let dict = recs.iter().find(|r| kind(r) == "DICTIONARY" && get(r, 5).as_deref() == Some(xd.as_str()))?;
+    let named = dict.windows(2).any(|w| w[0].code == 3 && w[0].str() == "ACAD_SORTENTS" && w[1].str() == handle);
+    Some((pairs, named))
+}
+
+/// A block record whose extension dictionary is the last record of OBJECTS gets the entry inside it.
+#[test]
+fn the_entry_goes_into_a_kept_extension_dictionary_written_last() {
+    // Model space's extension dictionary also holds another program's record, and comes last in
+    // the file's objects (so last of the kept objects CADCraft writes back).
+    let mut s = String::from("0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nBLOCK_RECORD\n");
+    s.push_str("0\nBLOCK_RECORD\n5\n1F\n102\n{ACAD_XDICTIONARY\n360\nD1\n102\n}\n2\n*Model_Space\n0\nENDTAB\n0\nENDSEC\n");
+    s.push_str("0\nSECTION\n2\nENTITIES\n");
+    for (h, y) in [("A1", 0.0), ("A2", 1.0), ("A3", 2.0)] {
+        s.push_str(&line(h, y));
+    }
+    s.push_str("0\nENDSEC\n0\nSECTION\n2\nOBJECTS\n0\nDICTIONARY\n5\nC\n330\n0\n100\nAcDbDictionary\n");
+    s.push_str("0\nXRECORD\n5\nE1\n330\nD1\n100\nAcDbXrecord\n1\nkeep me\n");
+    s.push_str("0\nSORTENTSTABLE\n5\nB0\n330\nD1\n100\nAcDbSortentsTable\n330\n1F\n331\nA1\n5\nA4\n331\nA3\n5\n10\n");
+    s.push_str("0\nDICTIONARY\n5\nD1\n330\n1F\n100\nAcDbDictionary\n3\nOTHER_APP\n360\nE1\n3\nACAD_SORTENTS\n360\nB0\n0\nENDSEC\n0\nEOF\n");
+    let d = cadcraft_io::read_dxf(s.as_bytes()).unwrap();
+    assert_eq!(order(&d), ["A3", "A2", "A1"]);
+    let text = cadcraft_io::write_dxf(&d);
+    let tags = cadcraft_dxf::parse(text.as_bytes()).expect("parse DXF");
+    let (table, xdict) = written_table(&tags).expect("a SORTENTSTABLE");
+    assert_eq!(table.len(), 2);
+    assert!(xdict, "the kept dictionary names the table");
+    assert!(text.contains("OTHER_APP"), "and still holds the other program's entry");
+    assert_eq!(order(&cadcraft_io::read_dxf(text.as_bytes()).unwrap()), ["A3", "A2", "A1"]);
+}
+
+/// Entities in handle order are written without a table.
+#[test]
+fn a_space_in_handle_order_gets_no_table() {
+    let d = cadcraft_io::read_dxf(dxf("").as_bytes()).unwrap();
+    let text = cadcraft_io::write_dxf(&d);
+    assert!(!text.contains("SORTENTSTABLE"), "no table, and no class for one");
+}
+
+/// Tables that don't parse or name no space change nothing.
 #[test]
 fn broken_sortentstables_are_ignored() {
     for table in [
@@ -46,3 +120,92 @@ fn broken_sortentstables_are_ignored() {
         assert_eq!(order(&d), ["A1", "A2", "A3"], "{table:?}");
     }
 }
+
+#[cfg(not(target_arch = "wasm32"))]
+mod dwg {
+    use acadrust::entities::Line;
+    use acadrust::objects::{ObjectType, SortEntitiesTable};
+    use acadrust::{CadDocument, DwgWriter, DxfVersion, EntityType, Handle};
+
+    type TestResult<T = ()> = Result<T, Box<dyn std::error::Error>>;
+
+    /// An AC1032 DWG with three lines in model space and a table that swaps the first and the
+    /// last; the middle one isn't listed (it is drawn by its own handle). Drawn: 3, 2, 1.
+    fn source() -> TestResult<(Vec<u8>, [Handle; 3])> {
+        let mut doc = CadDocument::with_version(DxfVersion::AC1032);
+        let mut h = [Handle::NULL; 3];
+        for (i, slot) in h.iter_mut().enumerate() {
+            let y = i as f64;
+            *slot = doc.add_entity(EntityType::Line(Line::from_coords(0., y, 0., 10., y, 0.)))?;
+        }
+        let ms = doc.block_records.get("*Model_Space").ok_or("model space")?.handle;
+        let xd = doc.ensure_extension_dictionary(ms);
+        let th = doc.allocate_handle();
+        let mut t = SortEntitiesTable::for_block(ms);
+        t.handle = th;
+        t.owner_handle = xd;
+        t.add_entry(h[0], h[2]);
+        t.add_entry(h[2], h[0]);
+        doc.objects.insert(th, ObjectType::SortEntitiesTable(t));
+        let Some(ObjectType::Dictionary(d)) = doc.objects.get_mut(&xd) else { return Err("extension dictionary".into()) };
+        d.entries.push(("ACAD_SORTENTS".into(), th));
+        Ok((DwgWriter::write_to_vec(&doc)?, h))
+    }
+
+    /// Model space's entity handles in draw order.
+    fn order(d: &cadcraft_doc::Drawing) -> Vec<u64> {
+        d.model.iter().map(|e| e.handle.0).collect()
+    }
+
+    /// The draw order a DWG's model space has for a reader that honours the table.
+    fn native_order(bytes: &[u8]) -> TestResult<Vec<u64>> {
+        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")?;
+        let mut tables = doc.objects.values().filter_map(|o| match o {
+            ObjectType::SortEntitiesTable(t) if t.block_owner_handle == ms.handle => Some(t),
+            _ => None,
+        });
+        let table = tables.next().ok_or("no table")?;
+        assert!(tables.next().is_none(), "one table");
+        // Found the way AutoCAD finds it: the record's extension dictionary, entry ACAD_SORTENTS.
+        let xd = doc.extension_dictionary_handle(ms.handle).ok_or("no extension dictionary")?;
+        let Some(ObjectType::Dictionary(d)) = doc.objects.get(&xd) else { return Err("extension dictionary".into()) };
+        assert!(d.entries.iter().any(|(k, h)| k == "ACAD_SORTENTS" && *h == table.handle));
+        let mut v: Vec<u64> = ms.entity_handles.iter().map(|h| h.value()).collect();
+        v.sort_by_key(|h| table.effective_sort_handle(Handle::new(*h)).value());
+        Ok(v)
+    }
+
+    /// A table listing only some entities reads in its own order.
+    #[test]
+    fn a_dwg_table_that_leaves_entities_out_reads_in_its_order() -> TestResult {
+        let (bytes, h) = source()?;
+        let want = vec![h[2].value(), h[1].value(), h[0].value()];
+        assert_eq!(native_order(&bytes)?, want);
+        // The DWG bridge's DXF used to renumber the listed entities above the unlisted one: 3, 1, 2.
+        let d = cadcraft_io::read(&bytes, "order.dwg")?;
+        assert_eq!(order(&d), want);
+        Ok(())
+    }
+
+    /// The order goes back into the DWG, survives a second save, and a bring-to-front reaches the file.
+    #[test]
+    fn draw_order_goes_back_into_the_dwg_and_survives_a_second_save() -> TestResult {
+        let (bytes, h) = source()?;
+        let want = vec![h[2].value(), h[1].value(), h[0].value()];
+        let mut d = cadcraft_io::read(&bytes, "order.dwg")?;
+        for _ in 0..2 {
+            let out = cadcraft_io::write_with(&d, "out.dwg", &cadcraft_io::SaveOptions::default())?;
+            assert_eq!(native_order(&out)?, want);
+            d = cadcraft_io::read(&out, "out.dwg")?;
+            assert_eq!(order(&d), want);
+        }
+        // A DRAWORDER-style move (the first drawn to the top) is what the next file's table says.
+        assert!(d.model.bring_to_front(cadcraft_doc::Handle(h[2].value())));
+        let out = cadcraft_io::write_with(&d, "out.dwg", &cadcraft_io::SaveOptions::default())?;
+        assert_eq!(native_order(&out)?, vec![h[1].value(), h[0].value(), h[2].value()]);
+        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 (draw order: SORTENTSTABLE read from DWG in its order and written to DXF and 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
@@ -35,7 +35,9 @@ with the `acadrust` 0.6.3 crate and reading that DXF; it saves by writing its ow
 converting it to a DWG of the chosen version (#421): SAVEAS `version`, else the drawing's own
 version (the file's `$ACADVER`, 2018 for a new drawing), at least 2004 unless 2000 is asked for.
 The bridge repairs two acadrust 0.6.3 gaps in 2010+ DWG: merged table cells and table-style
-title/header suppression.
+title/header suppression. It also lists every entity of a draw order table's space in the table
+before the conversion: acadrust 0.6.5's DXF writer renumbers a SORTENTSTABLE's sort handles above
+all the file's handles, which would move every listed entity above every unlisted one.
 
 | DWG version | Code | AutoCAD 2027 | CADCraft read | CADCraft write | Tested how |
 |---|---|---|---|---|---|
@@ -99,6 +101,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG
 | Objects: VISUALSTYLE, PLOTSETTINGS, materials, field objects and other unmodelled objects | kept raw (#528) | written back as read; a proxy object (ACAD_PROXY_OBJECT) keeps its class: its group 91 is renumbered to the written CLASSES, which keep the class it names, and one whose number names no class is dropped (#1812) | not editable |
 | Foreign xdata, extension dictionaries, reactors, unmodelled objects, colour books | kept raw (#528); an entity's xdata is part of the entity, so COPY, MIRROR, ARRAY and paste keep it on the copies (EXPLODE pieces don't) | written back, handles remapped, references to deleted objects dropped | through DWG: xdata on table records and blocks, entity colour books and reactors, custom-class objects lost (acadrust) |
 | Objects: dynamic block data (ACAD_EVALUATION_GRAPH, BLOCK…PARAMETER, BLOCK…ACTION, grips) | read into a model (`dynblock`, `blocks.dynamic`): parameters, visibility states, value sets, stretch and lookup actions; references: their dynamic block, values and custom properties (`blocks.reference`, Properties ▸ Custom, read-only); kept raw (#528) | written back as read; a changed visibility state is written as AutoCAD writes it (a new anonymous block with its `AcDbBlockRepBTag`, the objects' `AcDbBlockRepETag`, the reference's representation data) | visibility states (and lookups that set only them) can be changed (`blocks.reference.set`, Properties ▸ Custom); distances, positions and the actions they drive not yet; DWG open and save keep it too (checked with an AutoCAD dynamic block) |
+| Draw order (SORTENTSTABLE) | yes: a space's table sets its entity order (#439) | yes: entities in draw order, and a SORTENTSTABLE in the block record's extension dictionary (`ACAD_SORTENTS`) for every space drawn out of handle order, listing each entity whose place doesn't match its handle; DRAWORDER results reach the file (DXF and DWG) | `sortents` tests (synthetic; the DWG oracle is acadrust) |
 | Handles and owners | entity handles kept; others regenerated | consistent | stable across CADCraft re-saves (#495); other programs' table and object handles renumbered once |
 
 **DXF ≈ 50% read, ≈ 30% write** (estimated; weights: common 2D entities 50%, annotation objects
@@ -140,6 +143,7 @@ our output in AutoCAD black-box.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-24 | minor | Draw order: a DWG table that leaves entities out reads in its order (acadrust's renumbering worked around); DXF and DWG saves write a SORTENTSTABLE for each space drawn out of handle order, so DRAWORDER reaches the file |
 | 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 |
