Skip to main content

tilewright_pack/
chunk.rs

1// Copyright 2026 Seraphina Studios
2// SPDX-License-Identifier: MIT
3
4//! The `twc1+zstd` chunk encoding (`docs/spec/PACK.md` section 2).
5//!
6//! Decoding treats its input as untrusted: sizes are bounded before any
7//! allocation, every index is range-checked, and trailing bytes are errors.
8
9use std::fmt;
10use tilewright_jsonc::{Document, Value};
11
12/// The `chunks.encoding` value of this encoding.
13pub const CHUNK_ENCODING: &str = "twc1+zstd";
14/// zstd compression level used by the compiler.
15pub const ZSTD_LEVEL: i32 = 19;
16/// Largest decompressed record accepted.
17pub const MAXIMUM_RECORD_BYTES: usize = 16 * 1024 * 1024;
18/// Largest stored (compressed) chunk accepted: zstd's compression bound for
19/// a record of [`MAXIMUM_RECORD_BYTES`], so every encoded chunk fits.
20pub const MAXIMUM_CHUNK_BYTES: usize = MAXIMUM_RECORD_BYTES + (MAXIMUM_RECORD_BYTES >> 8);
21/// Deepest inventory nesting accepted.
22pub const MAXIMUM_DEPTH: usize = 16;
23/// The `version` of a `twc1` record of this format.
24pub const RECORD_VERSION: u16 = 2;
25
26const MAGIC: &[u8; 4] = b"TWC1";
27const NO_TILE: u16 = u16::MAX;
28const FLAG_PORTAL: u8 = 1;
29const FLAG_FIELDS: u8 = 2;
30const FLAG_CUSTOM: u8 = 4;
31const FLAG_INVENTORY: u8 = 8;
32const FLAG_UID: u8 = 16;
33
34/// A decoded chunk. Strings are IDs into the pack's `strings` table.
35#[derive(Clone, Debug, PartialEq)]
36pub struct ChunkData {
37    pub chunk_size: u16,
38    /// String IDs referenced by the grids.
39    pub palette: Vec<u32>,
40    /// One grid per ground layer: `chunk_size²` cells, each a palette index
41    /// plus one, or 0 for empty.
42    pub ground: Vec<Vec<u16>>,
43    pub heights: Vec<i32>,
44    /// Palette index plus one, or 0 for the plane's default zone.
45    pub zones: Vec<u16>,
46    pub objects: Vec<ObjectRecord>,
47}
48
49impl ChunkData {
50    /// The object at `index` in the record's depth-first order, where each
51    /// object comes before its inventory entries and the first top-level
52    /// object is 0: the `object` column of `object_uids`.
53    #[must_use]
54    pub fn object(&self, index: usize) -> Option<&ObjectRecord> {
55        let mut remaining = index;
56        let mut levels = vec![self.objects.iter()];
57        while let Some(level) = levels.last_mut() {
58            let Some(object) = level.next() else {
59                levels.pop();
60                continue;
61            };
62            if remaining == 0 {
63                return Some(object);
64            }
65            remaining -= 1;
66            levels.push(object.inventory.iter());
67        }
68        None
69    }
70}
71
72#[derive(Clone, Debug, PartialEq)]
73pub struct ObjectRecord {
74    /// The object's uid, an identifier unique in the pack.
75    pub uid: Option<String>,
76    /// `y * chunk_size + x`; `None` for inventory entries.
77    pub tile: Option<u16>,
78    pub proto: u32,
79    /// Render layer index plus one, or 0 for the prototype's layer.
80    pub layer: u16,
81    pub sub_layer: i16,
82    pub z: i32,
83    pub portal: Option<PortalRecord>,
84    pub fields: Vec<(u32, FieldRecord)>,
85    /// Canonical JSON of the object's `custom` data.
86    pub custom: Option<String>,
87    pub inventory: Vec<ObjectRecord>,
88}
89
90#[derive(Clone, Debug, PartialEq)]
91pub enum PortalRecord {
92    /// `plane` is a string ID, or 0 for the same plane.
93    Location {
94        plane: u32,
95        layer: i32,
96        x: i64,
97        y: i64,
98    },
99    Anchor {
100        plane: u32,
101        anchor: u32,
102    },
103}
104
105#[derive(Clone, Debug, PartialEq)]
106pub enum FieldRecord {
107    Null,
108    Bool(bool),
109    Integer(i64),
110    Number(f64),
111    /// A string ID.
112    String(u32),
113    /// Canonical JSON for arrays and objects.
114    Json(String),
115}
116
117/// Why a chunk record was rejected.
118#[derive(Clone, Debug, Eq, PartialEq)]
119pub struct DecodeError(pub String);
120
121impl fmt::Display for DecodeError {
122    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
123        write!(formatter, "invalid chunk record: {}", self.0)
124    }
125}
126
127impl std::error::Error for DecodeError {}
128
129fn invalid<T>(message: impl Into<String>) -> Result<T, DecodeError> {
130    Err(DecodeError(message.into()))
131}
132
133/// Encodes and compresses a chunk.
134///
135/// # Errors
136/// Returns an error if the chunk violates the encoding's bounds or zstd fails.
137pub fn encode_chunk(chunk: &ChunkData) -> Result<Vec<u8>, DecodeError> {
138    let record = encode_record(chunk)?;
139    zstd::bulk::compress(&record, ZSTD_LEVEL)
140        .or_else(|error| invalid(format!("zstd compression failed: {error}")))
141}
142
143/// Encodes a chunk as an uncompressed `twc1` record.
144///
145/// # Errors
146/// Returns an error if the chunk violates the encoding's bounds.
147pub fn encode_record(chunk: &ChunkData) -> Result<Vec<u8>, DecodeError> {
148    let cells = check_shape(chunk)?;
149    // Sizes are checked before each part is appended, so encoding stops as
150    // soon as the record would pass its limit.
151    let grids = cells * (2 * chunk.ground.len() + 6);
152    // 14 header bytes through `palette_len`, and the 4-byte object count.
153    room(
154        &[],
155        18_usize
156            .saturating_add(chunk.palette.len().saturating_mul(4))
157            .saturating_add(grids),
158    )?;
159    let mut output = Vec::with_capacity(16 + grids);
160    output.extend_from_slice(MAGIC);
161    output.extend_from_slice(&RECORD_VERSION.to_le_bytes());
162    output.extend_from_slice(&chunk.chunk_size.to_le_bytes());
163    output.push(u8::try_from(chunk.ground.len()).or_else(|_| invalid("too many ground layers"))?);
164    output.push(0);
165    push_u32(&mut output, chunk.palette.len())?;
166    for id in &chunk.palette {
167        output.extend_from_slice(&id.to_le_bytes());
168    }
169    for grid in &chunk.ground {
170        for cell in grid {
171            output.extend_from_slice(&cell.to_le_bytes());
172        }
173    }
174    for height in &chunk.heights {
175        output.extend_from_slice(&height.to_le_bytes());
176    }
177    for cell in &chunk.zones {
178        output.extend_from_slice(&cell.to_le_bytes());
179    }
180    push_u32(&mut output, chunk.objects.len())?;
181    for object in &chunk.objects {
182        encode_object(&mut output, object, cells, 0)?;
183    }
184    if output.len() > MAXIMUM_RECORD_BYTES {
185        return invalid("record exceeds 16 MiB");
186    }
187    Ok(output)
188}
189
190fn check_shape(chunk: &ChunkData) -> Result<usize, DecodeError> {
191    if chunk.chunk_size == 0 || chunk.chunk_size > 255 {
192        return invalid("chunk size must be 1..=255");
193    }
194    let cells = usize::from(chunk.chunk_size) * usize::from(chunk.chunk_size);
195    if chunk.ground.len() > 8
196        || chunk.ground.iter().any(|grid| grid.len() != cells)
197        || chunk.heights.len() != cells
198        || chunk.zones.len() != cells
199    {
200        return invalid("grid sizes do not match the chunk size");
201    }
202    if chunk.palette.first() == Some(&0) || chunk.palette.windows(2).any(|pair| pair[0] >= pair[1])
203    {
204        return invalid("palette string IDs must be nonzero and strictly increasing");
205    }
206    let limit = chunk.palette.len();
207    if chunk
208        .ground
209        .iter()
210        .flatten()
211        .chain(&chunk.zones)
212        .any(|cell| usize::from(*cell) > limit)
213    {
214        return invalid("a grid cell is outside the palette");
215    }
216    Ok(cells)
217}
218
219/// Checks that `text` is canonical JSON (as `Document::canonical` writes it)
220/// for an object, or for an array when `array_allowed`.
221fn check_json(text: &str, array_allowed: bool) -> Result<(), DecodeError> {
222    let document = Document::parse(text, &crate::canonical_limits())
223        .or_else(|error| invalid(format!("embedded JSON: {error}")))?;
224    // Custom data's keys are namespaces, as in the source format.
225    let shape_ok = match document.root() {
226        Value::Object { members, .. } => {
227            array_allowed
228                || members
229                    .iter()
230                    .all(|member| crate::is_namespace(&member.key))
231        }
232        Value::Array { .. } => array_allowed,
233        _ => false,
234    };
235    if !shape_ok {
236        return invalid(if array_allowed {
237            "JSON field values must be arrays or objects"
238        } else {
239            "custom data must be an object whose keys are namespaces"
240        });
241    }
242    if document.canonical(document.root()) != text {
243        return invalid("embedded JSON is not canonical");
244    }
245    Ok(())
246}
247
248/// Fails unless `additional` more bytes fit in a record of at most
249/// [`MAXIMUM_RECORD_BYTES`].
250fn room(output: &[u8], additional: usize) -> Result<(), DecodeError> {
251    if output.len().saturating_add(additional) > MAXIMUM_RECORD_BYTES {
252        invalid("record exceeds 16 MiB")
253    } else {
254        Ok(())
255    }
256}
257
258fn push_u32(output: &mut Vec<u8>, value: usize) -> Result<(), DecodeError> {
259    let value = u32::try_from(value).or_else(|_| invalid("count exceeds u32"))?;
260    output.extend_from_slice(&value.to_le_bytes());
261    Ok(())
262}
263
264/// The bytes a field block takes, each field sized by its tag.
265fn field_block_bytes(fields: &[(u32, FieldRecord)]) -> usize {
266    fields.iter().fold(2_usize, |total, (_, value)| {
267        total.saturating_add(match value {
268            FieldRecord::Null | FieldRecord::Bool(_) => 5,
269            FieldRecord::Integer(_) | FieldRecord::Number(_) => 13,
270            FieldRecord::String(_) => 9,
271            FieldRecord::Json(text) => text.len().saturating_add(9),
272        })
273    })
274}
275
276/// The flags byte of an object record: which optional parts follow.
277fn flags(object: &ObjectRecord) -> u8 {
278    [
279        (object.uid.is_some(), FLAG_UID),
280        (object.portal.is_some(), FLAG_PORTAL),
281        (!object.fields.is_empty(), FLAG_FIELDS),
282        (object.custom.is_some(), FLAG_CUSTOM),
283        (!object.inventory.is_empty(), FLAG_INVENTORY),
284    ]
285    .iter()
286    .filter(|(present, _)| *present)
287    .fold(0, |flags, (_, flag)| flags | flag)
288}
289
290fn encode_object(
291    output: &mut Vec<u8>,
292    object: &ObjectRecord,
293    cells: usize,
294    depth: usize,
295) -> Result<(), DecodeError> {
296    if depth > MAXIMUM_DEPTH {
297        return invalid("inventory nesting too deep");
298    }
299    // The fixed part is 15 bytes; a uid adds its length byte and text, and
300    // a portal its kind and fields.
301    let uid = match &object.uid {
302        Some(uid) if crate::is_identifier(uid) => 1 + uid.len(),
303        Some(_) => return invalid("a uid is not an identifier"),
304        None => 0,
305    };
306    let portal = match object.portal {
307        Some(PortalRecord::Location { .. }) => 25,
308        Some(PortalRecord::Anchor { .. }) => 9,
309        None => 0,
310    };
311    room(output, 15 + uid + portal)?;
312    let tile = match (object.tile, depth) {
313        (Some(tile), 0) if usize::from(tile) < cells => tile,
314        (None, depth) if depth > 0 => NO_TILE,
315        _ => return invalid("top-level objects need a tile in the chunk; inventory entries none"),
316    };
317    output.extend_from_slice(&tile.to_le_bytes());
318    output.extend_from_slice(&object.proto.to_le_bytes());
319    output.extend_from_slice(&object.layer.to_le_bytes());
320    output.extend_from_slice(&object.sub_layer.to_le_bytes());
321    output.extend_from_slice(&object.z.to_le_bytes());
322    output.push(flags(object));
323    if let Some(uid) = &object.uid {
324        // An identifier is 1 to 128 bytes, so its length fits the byte.
325        output.push(u8::try_from(uid.len()).or_else(|_| invalid("a uid is too long"))?);
326        output.extend_from_slice(uid.as_bytes());
327    }
328    match &object.portal {
329        Some(PortalRecord::Location { plane, layer, x, y }) => {
330            output.push(0);
331            output.extend_from_slice(&plane.to_le_bytes());
332            output.extend_from_slice(&layer.to_le_bytes());
333            output.extend_from_slice(&x.to_le_bytes());
334            output.extend_from_slice(&y.to_le_bytes());
335        }
336        Some(PortalRecord::Anchor { plane, anchor }) => {
337            output.push(1);
338            output.extend_from_slice(&plane.to_le_bytes());
339            output.extend_from_slice(&anchor.to_le_bytes());
340        }
341        None => {}
342    }
343    if !object.fields.is_empty() {
344        let count = u16::try_from(object.fields.len()).or_else(|_| invalid("too many fields"))?;
345        room(output, field_block_bytes(&object.fields))?;
346        output.extend_from_slice(&count.to_le_bytes());
347        for (name, value) in &object.fields {
348            output.extend_from_slice(&name.to_le_bytes());
349            match value {
350                FieldRecord::Null => output.push(0),
351                FieldRecord::Bool(false) => output.push(1),
352                FieldRecord::Bool(true) => output.push(2),
353                FieldRecord::Integer(number) => {
354                    output.push(3);
355                    output.extend_from_slice(&number.to_le_bytes());
356                }
357                FieldRecord::Number(number) => {
358                    if !number.is_finite() {
359                        return invalid("numbers must be finite");
360                    }
361                    output.push(4);
362                    output.extend_from_slice(&number.to_le_bytes());
363                }
364                FieldRecord::String(id) => {
365                    output.push(5);
366                    output.extend_from_slice(&id.to_le_bytes());
367                }
368                FieldRecord::Json(text) => {
369                    check_json(text, true)?;
370                    output.push(6);
371                    push_u32(output, text.len())?;
372                    output.extend_from_slice(text.as_bytes());
373                }
374            }
375        }
376    }
377    if let Some(custom) = &object.custom {
378        room(output, 4_usize.saturating_add(custom.len()))?;
379        check_json(custom, false)?;
380        push_u32(output, custom.len())?;
381        output.extend_from_slice(custom.as_bytes());
382    }
383    if !object.inventory.is_empty() {
384        room(output, 4)?;
385        push_u32(output, object.inventory.len())?;
386        for item in &object.inventory {
387            encode_object(output, item, cells, depth + 1)?;
388        }
389    }
390    Ok(())
391}
392
393/// Decompresses and decodes a chunk into its full model. Objects take more
394/// memory decoded than encoded, so the model of a 16 MiB record can be
395/// several times its size; verification checks objects as they decode
396/// instead of building it.
397///
398/// # Errors
399/// Returns an error for anything other than a well-formed record within the
400/// encoding's bounds.
401pub fn decode_chunk(data: &[u8]) -> Result<ChunkData, DecodeError> {
402    decode_chunk_with(data, None)
403}
404
405/// Visits each object of a record as it is decoded, at any depth and with
406/// an empty inventory; its inventory entries are visited after it.
407pub(crate) type Visit<'v> = &'v mut dyn FnMut(&ObjectRecord) -> Result<(), DecodeError>;
408
409/// Decompresses and decodes a chunk. With `visit`, objects are streamed to it
410/// instead of kept, so the returned chunk has no objects and memory stays
411/// bounded by the record rather than its decoded objects.
412pub(crate) fn decode_chunk_with(
413    data: &[u8],
414    visit: Option<Visit<'_>>,
415) -> Result<ChunkData, DecodeError> {
416    if data.len() > MAXIMUM_CHUNK_BYTES {
417        return invalid(format!("stored chunk exceeds {MAXIMUM_CHUNK_BYTES} bytes"));
418    }
419    // Exactly one frame, so the size it records is the whole decoded size.
420    if zstd::zstd_safe::find_frame_compressed_size(data).ok() != Some(data.len()) {
421        return invalid("not exactly one zstd frame");
422    }
423    match zstd::zstd_safe::get_frame_content_size(data) {
424        Ok(Some(size)) if size <= MAXIMUM_RECORD_BYTES as u64 => {}
425        Ok(Some(_)) => return invalid("record exceeds 16 MiB"),
426        _ => return invalid("not a zstd frame with a recorded content size"),
427    }
428    let record = zstd::bulk::decompress(data, MAXIMUM_RECORD_BYTES)
429        .or_else(|error| invalid(format!("zstd decompression failed: {error}")))?;
430    decode_record_with(&record, visit)
431}
432
433struct Cursor<'a> {
434    bytes: &'a [u8],
435    position: usize,
436}
437
438impl<'a> Cursor<'a> {
439    fn take(&mut self, count: usize) -> Result<&'a [u8], DecodeError> {
440        let end = self
441            .position
442            .checked_add(count)
443            .filter(|end| *end <= self.bytes.len())
444            .ok_or_else(|| DecodeError("record ends early".to_owned()))?;
445        let slice = &self.bytes[self.position..end];
446        self.position = end;
447        Ok(slice)
448    }
449
450    fn array<const N: usize>(&mut self) -> Result<[u8; N], DecodeError> {
451        let mut output = [0; N];
452        output.copy_from_slice(self.take(N)?);
453        Ok(output)
454    }
455
456    fn u8(&mut self) -> Result<u8, DecodeError> {
457        Ok(self.array::<1>()?[0])
458    }
459
460    fn u16(&mut self) -> Result<u16, DecodeError> {
461        Ok(u16::from_le_bytes(self.array()?))
462    }
463
464    fn i16(&mut self) -> Result<i16, DecodeError> {
465        Ok(i16::from_le_bytes(self.array()?))
466    }
467
468    fn u32(&mut self) -> Result<u32, DecodeError> {
469        Ok(u32::from_le_bytes(self.array()?))
470    }
471
472    fn i32(&mut self) -> Result<i32, DecodeError> {
473        Ok(i32::from_le_bytes(self.array()?))
474    }
475
476    fn i64(&mut self) -> Result<i64, DecodeError> {
477        Ok(i64::from_le_bytes(self.array()?))
478    }
479
480    fn f64(&mut self) -> Result<f64, DecodeError> {
481        Ok(f64::from_le_bytes(self.array()?))
482    }
483
484    fn remaining(&self) -> usize {
485        self.bytes.len() - self.position
486    }
487
488    /// A count whose items each need at least `minimum` bytes.
489    fn count(&mut self, minimum: usize) -> Result<usize, DecodeError> {
490        let count = usize::try_from(self.u32()?).or_else(|_| invalid("count exceeds usize"))?;
491        if count.saturating_mul(minimum) > self.remaining() {
492            return invalid("count exceeds the record");
493        }
494        Ok(count)
495    }
496
497    /// A uid: a length byte, then that many bytes of an identifier.
498    fn uid(&mut self) -> Result<String, DecodeError> {
499        let length = usize::from(self.u8()?);
500        match std::str::from_utf8(self.take(length)?) {
501            Ok(uid) if crate::is_identifier(uid) => Ok(uid.to_owned()),
502            _ => invalid("a uid is not an identifier"),
503        }
504    }
505
506    fn text(&mut self) -> Result<String, DecodeError> {
507        let length = self.count(1)?;
508        String::from_utf8(self.take(length)?.to_vec()).or_else(|_| invalid("text is not UTF-8"))
509    }
510}
511
512/// Decodes an uncompressed `twc1` record.
513///
514/// # Errors
515/// Returns an error for anything other than a well-formed record.
516pub fn decode_record(record: &[u8]) -> Result<ChunkData, DecodeError> {
517    decode_record_with(record, None)
518}
519
520/// [`decode_record`], streaming objects to `visit` when it is given.
521fn decode_record_with(
522    record: &[u8],
523    mut visit: Option<Visit<'_>>,
524) -> Result<ChunkData, DecodeError> {
525    if record.len() > MAXIMUM_RECORD_BYTES {
526        return invalid("record exceeds 16 MiB");
527    }
528    let mut cursor = Cursor {
529        bytes: record,
530        position: 0,
531    };
532    if cursor.take(4)? != MAGIC {
533        return invalid("bad magic");
534    }
535    if cursor.u16()? != RECORD_VERSION {
536        return invalid("unsupported version");
537    }
538    let chunk_size = cursor.u16()?;
539    if chunk_size == 0 || chunk_size > 255 {
540        return invalid("chunk size must be 1..=255");
541    }
542    let ground_count = usize::from(cursor.u8()?);
543    if ground_count > 8 || cursor.u8()? != 0 {
544        return invalid("bad ground count or reserved byte");
545    }
546    let palette_length = cursor.count(4)?;
547    let mut palette = Vec::with_capacity(palette_length);
548    for _ in 0..palette_length {
549        palette.push(cursor.u32()?);
550    }
551    let cells = usize::from(chunk_size) * usize::from(chunk_size);
552    let mut ground = Vec::with_capacity(ground_count);
553    for _ in 0..ground_count {
554        if cursor.remaining() < cells * 2 {
555            return invalid("record ends early");
556        }
557        ground.push(
558            (0..cells)
559                .map(|_| cursor.u16())
560                .collect::<Result<Vec<_>, _>>()?,
561        );
562    }
563    if cursor.remaining() < cells * 6 {
564        return invalid("record ends early");
565    }
566    let heights = (0..cells)
567        .map(|_| cursor.i32())
568        .collect::<Result<Vec<_>, _>>()?;
569    let zones = (0..cells)
570        .map(|_| cursor.u16())
571        .collect::<Result<Vec<_>, _>>()?;
572    let mut chunk = ChunkData {
573        chunk_size,
574        palette,
575        ground,
576        heights,
577        zones,
578        objects: Vec::new(),
579    };
580    check_shape(&chunk)?;
581    // Objects are kept only as they decode, never reserved from the count.
582    let object_count = cursor.count(15)?;
583    for _ in 0..object_count {
584        let object = decode_object(&mut cursor, cells, 0, &mut visit)?;
585        if visit.is_none() {
586            chunk.objects.push(object);
587        }
588    }
589    if cursor.remaining() != 0 {
590        return invalid("trailing bytes");
591    }
592    Ok(chunk)
593}
594
595/// A field block: a nonzero count, then each field's name and value.
596fn decode_fields(cursor: &mut Cursor<'_>) -> Result<Vec<(u32, FieldRecord)>, DecodeError> {
597    let count = usize::from(cursor.u16()?);
598    if count == 0 || count * 5 > cursor.remaining() {
599        return invalid("bad field count");
600    }
601    let mut fields = Vec::with_capacity(count);
602    for _ in 0..count {
603        let name = cursor.u32()?;
604        let value = match cursor.u8()? {
605            0 => FieldRecord::Null,
606            1 => FieldRecord::Bool(false),
607            2 => FieldRecord::Bool(true),
608            3 => FieldRecord::Integer(cursor.i64()?),
609            4 => {
610                let number = cursor.f64()?;
611                if !number.is_finite() {
612                    return invalid("numbers must be finite");
613                }
614                FieldRecord::Number(number)
615            }
616            5 => FieldRecord::String(cursor.u32()?),
617            6 => {
618                let text = cursor.text()?;
619                check_json(&text, true)?;
620                FieldRecord::Json(text)
621            }
622            _ => return invalid("unknown field tag"),
623        };
624        fields.push((name, value));
625    }
626    Ok(fields)
627}
628
629fn decode_object(
630    cursor: &mut Cursor<'_>,
631    cells: usize,
632    depth: usize,
633    visit: &mut Option<Visit<'_>>,
634) -> Result<ObjectRecord, DecodeError> {
635    if depth > MAXIMUM_DEPTH {
636        return invalid("inventory nesting too deep");
637    }
638    let tile = match (cursor.u16()?, depth) {
639        (tile, 0) if usize::from(tile) < cells => Some(tile),
640        (NO_TILE, depth) if depth > 0 => None,
641        _ => return invalid("object tile out of range"),
642    };
643    let proto = cursor.u32()?;
644    let layer = cursor.u16()?;
645    let sub_layer = cursor.i16()?;
646    let z = cursor.i32()?;
647    let flags = cursor.u8()?;
648    if flags & !(FLAG_PORTAL | FLAG_FIELDS | FLAG_CUSTOM | FLAG_INVENTORY | FLAG_UID) != 0 {
649        return invalid("unknown object flags");
650    }
651    let uid = if flags & FLAG_UID == 0 {
652        None
653    } else {
654        Some(cursor.uid()?)
655    };
656    let portal = if flags & FLAG_PORTAL == 0 {
657        None
658    } else {
659        Some(match cursor.u8()? {
660            0 => PortalRecord::Location {
661                plane: cursor.u32()?,
662                layer: cursor.i32()?,
663                x: cursor.i64()?,
664                y: cursor.i64()?,
665            },
666            1 => PortalRecord::Anchor {
667                plane: cursor.u32()?,
668                anchor: cursor.u32()?,
669            },
670            _ => return invalid("unknown portal kind"),
671        })
672    };
673    let fields = if flags & FLAG_FIELDS == 0 {
674        Vec::new()
675    } else {
676        decode_fields(cursor)?
677    };
678    let custom = if flags & FLAG_CUSTOM == 0 {
679        None
680    } else {
681        let text = cursor.text()?;
682        check_json(&text, false)?;
683        Some(text)
684    };
685    let mut object = ObjectRecord {
686        uid,
687        tile,
688        proto,
689        layer,
690        sub_layer,
691        z,
692        portal,
693        fields,
694        custom,
695        inventory: Vec::new(),
696    };
697    if let Some(visit) = visit.as_mut() {
698        visit(&object)?;
699    }
700    if flags & FLAG_INVENTORY != 0 {
701        let count = cursor.count(15)?;
702        if count == 0 {
703            return invalid("empty inventory flagged");
704        }
705        for _ in 0..count {
706            let item = decode_object(cursor, cells, depth + 1, visit)?;
707            if visit.is_none() {
708                object.inventory.push(item);
709            }
710        }
711    }
712    Ok(object)
713}