formats: add safe dat codecs
Browse files- Cargo.lock +7 -0
- Cargo.toml +13 -0
- README.md +6 -0
- crates/mb3-dat/Cargo.toml +10 -0
- crates/mb3-dat/src/codec.rs +767 -0
- crates/mb3-dat/src/lib.rs +93 -0
- crates/mb3-dat/src/tests.rs +691 -0
- crates/mb3-dat/tests/converter.rs +207 -0
- docs/FINDINGS.md +4 -0
- docs/MB3_DAT.md +1 -1
- docs/README.md +3 -1
- docs/RUST_DAT.md +84 -0
- docs/TECHNICAL_DIRECTION.md +3 -3
- docs/TODO.md +7 -5
- docs/experiments/rust-dat-2026-10-21.md +37 -0
- docs/parity.md +5 -1
- docs/risks.md +2 -1
- docs/validation.md +6 -0
- flake.nix +22 -0
Cargo.lock
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is automatically @generated by Cargo.
|
| 2 |
+
# It is not intended for manual editing.
|
| 3 |
+
version = 4
|
| 4 |
+
|
| 5 |
+
[[package]]
|
| 6 |
+
name = "mb3-dat"
|
| 7 |
+
version = "0.1.0"
|
Cargo.toml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[workspace]
|
| 2 |
+
members = ["crates/mb3-dat"]
|
| 3 |
+
resolver = "3"
|
| 4 |
+
|
| 5 |
+
[workspace.package]
|
| 6 |
+
edition = "2024"
|
| 7 |
+
rust-version = "1.85"
|
| 8 |
+
|
| 9 |
+
[workspace.lints.rust]
|
| 10 |
+
unsafe_code = "forbid"
|
| 11 |
+
|
| 12 |
+
[workspace.lints.clippy]
|
| 13 |
+
all = "warn"
|
README.md
CHANGED
|
@@ -64,3 +64,9 @@ See [setup validation](docs/SETUP.md) for the implementation, checks, and remain
|
|
| 64 |
`map-references` verifies the recovered inputs and regenerates the Xet-backed static source map plus the Core/tool Markdown indexes. `check-reference-map` checks that they are reproducible and current. No original compiler or executable runs. Start with [formats](docs/FORMATS.md), [host APIs](docs/HOST_APIS.md), [plugins](docs/PLUGINS.md), [dependencies](docs/DEPENDENCIES.md), and the [coverage ledger](docs/parity.md). The separate [converter investigation](docs/CONVERTER.md) now runs unchanged converters under Mono; native editor behavior remains unverified.
|
| 65 |
|
| 66 |
`analyze-converter decompile|probes|check` reproduces the managed converter evidence and 101 controlled probes. `inspect-mb3dat <file>` safely inspects either recovered DAT version. These research tools, including pinned Mono/ILSpy, are in the Nix shell; captures/decompilations remain Xet assets. See [E02](docs/experiments/converter-2026-10-21.md) and the [binary contract](docs/MB3_DAT.md).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
`map-references` verifies the recovered inputs and regenerates the Xet-backed static source map plus the Core/tool Markdown indexes. `check-reference-map` checks that they are reproducible and current. No original compiler or executable runs. Start with [formats](docs/FORMATS.md), [host APIs](docs/HOST_APIS.md), [plugins](docs/PLUGINS.md), [dependencies](docs/DEPENDENCIES.md), and the [coverage ledger](docs/parity.md). The separate [converter investigation](docs/CONVERTER.md) now runs unchanged converters under Mono; native editor behavior remains unverified.
|
| 65 |
|
| 66 |
`analyze-converter decompile|probes|check` reproduces the managed converter evidence and 101 controlled probes. `inspect-mb3dat <file>` safely inspects either recovered DAT version. These research tools, including pinned Mono/ILSpy, are in the Nix shell; captures/decompilations remain Xet assets. See [E02](docs/experiments/converter-2026-10-21.md) and the [binary contract](docs/MB3_DAT.md).
|
| 67 |
+
|
| 68 |
+
## Rust DAT codec
|
| 69 |
+
|
| 70 |
+
`crates/mb3-dat` provides dependency-free bounded readers/writers for both recovered DAT versions. Run `cargo test --workspace --locked --offline` inside `nix develop` after hydrating and verifying the converter fixtures (`analyze-converter check`). All 58 captured DAT outputs are tested for exact byte round trips. See [API, compatibility and limits](docs/RUST_DAT.md).
|
| 71 |
+
|
| 72 |
+
The asset-free Nix check `nix build .#checks.aarch64-darwin.rust --no-link` runs formatting, Clippy, unit tests and doctests; full converter integration tests require Xet assets.
|
crates/mb3-dat/Cargo.toml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[package]
|
| 2 |
+
name = "mb3-dat"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Bounded readers and writers for the two recovered Microbrush 3 DAT formats"
|
| 5 |
+
edition.workspace = true
|
| 6 |
+
rust-version.workspace = true
|
| 7 |
+
publish = false
|
| 8 |
+
|
| 9 |
+
[lints]
|
| 10 |
+
workspace = true
|
crates/mb3-dat/src/codec.rs
ADDED
|
@@ -0,0 +1,767 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use crate::*;
|
| 2 |
+
use std::fmt;
|
| 3 |
+
use std::io::{self, Read, Write};
|
| 4 |
+
use std::mem::size_of;
|
| 5 |
+
|
| 6 |
+
/// Application policy limits, not maxima inferred from the legacy format.
|
| 7 |
+
#[derive(Clone, Copy, Debug)]
|
| 8 |
+
pub struct Limits {
|
| 9 |
+
/// Maximum complete input or encoded output, including the header.
|
| 10 |
+
pub max_bytes: usize,
|
| 11 |
+
/// Maximum bytes in any individual ASCII name or value, excluding NUL.
|
| 12 |
+
pub max_string_bytes: usize,
|
| 13 |
+
/// Aggregate names, brushes, planes, properties, patches and vec3 elements.
|
| 14 |
+
pub max_items: usize,
|
| 15 |
+
/// Decoder's aggregate requested owned storage, excluding allocator overhead
|
| 16 |
+
/// and the input buffer. Includes record slots and string payloads.
|
| 17 |
+
pub max_owned_bytes: usize,
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
impl Default for Limits {
|
| 21 |
+
fn default() -> Self {
|
| 22 |
+
Self {
|
| 23 |
+
max_bytes: 64 * 1024 * 1024,
|
| 24 |
+
max_string_bytes: 1024 * 1024,
|
| 25 |
+
max_items: 1_000_000,
|
| 26 |
+
max_owned_bytes: 128 * 1024 * 1024,
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
#[derive(Debug)]
|
| 32 |
+
#[non_exhaustive]
|
| 33 |
+
pub enum ErrorKind {
|
| 34 |
+
Io(io::Error),
|
| 35 |
+
UnexpectedEof,
|
| 36 |
+
InvalidMagic,
|
| 37 |
+
UnsupportedVersion(u32),
|
| 38 |
+
InvalidFlags(u8),
|
| 39 |
+
ReservedWidth,
|
| 40 |
+
NegativeCount,
|
| 41 |
+
CountOverflow,
|
| 42 |
+
LimitExceeded(&'static str),
|
| 43 |
+
Allocation,
|
| 44 |
+
NonAscii,
|
| 45 |
+
EmbeddedNul,
|
| 46 |
+
UnterminatedString,
|
| 47 |
+
NonFinite,
|
| 48 |
+
PropertyIndex(u32),
|
| 49 |
+
DuplicateProperty(u32),
|
| 50 |
+
UnsortedProperties,
|
| 51 |
+
InconsistentComputedData,
|
| 52 |
+
InvalidPatchLength,
|
| 53 |
+
LegacyProperties,
|
| 54 |
+
TrailingData,
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
#[derive(Debug)]
|
| 58 |
+
pub struct Error {
|
| 59 |
+
/// Byte offset in the input, encoded output, or completed I/O prefix.
|
| 60 |
+
pub offset: usize,
|
| 61 |
+
pub kind: ErrorKind,
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
impl fmt::Display for Error {
|
| 65 |
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
| 66 |
+
write!(f, "MB3 DAT at byte {}: {:?}", self.offset, self.kind)
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
impl std::error::Error for Error {
|
| 71 |
+
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
| 72 |
+
match &self.kind {
|
| 73 |
+
ErrorKind::Io(e) => Some(e),
|
| 74 |
+
_ => None,
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
type Result<T> = std::result::Result<T, Error>;
|
| 80 |
+
|
| 81 |
+
fn err(offset: usize, kind: ErrorKind) -> Error {
|
| 82 |
+
Error { offset, kind }
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
#[derive(Default)]
|
| 86 |
+
struct Budget {
|
| 87 |
+
items: usize,
|
| 88 |
+
owned: usize,
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
impl Budget {
|
| 92 |
+
fn items(&mut self, n: usize, limits: &Limits, at: usize) -> Result<()> {
|
| 93 |
+
self.items = self
|
| 94 |
+
.items
|
| 95 |
+
.checked_add(n)
|
| 96 |
+
.ok_or_else(|| err(at, ErrorKind::CountOverflow))?;
|
| 97 |
+
if self.items > limits.max_items {
|
| 98 |
+
return Err(err(at, ErrorKind::LimitExceeded("aggregate items")));
|
| 99 |
+
}
|
| 100 |
+
Ok(())
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
fn bytes(&mut self, n: usize, limits: &Limits, at: usize) -> Result<()> {
|
| 104 |
+
self.owned = self
|
| 105 |
+
.owned
|
| 106 |
+
.checked_add(n)
|
| 107 |
+
.ok_or_else(|| err(at, ErrorKind::CountOverflow))?;
|
| 108 |
+
if self.owned > limits.max_owned_bytes {
|
| 109 |
+
return Err(err(at, ErrorKind::LimitExceeded("owned storage")));
|
| 110 |
+
}
|
| 111 |
+
Ok(())
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
fn claim<T>(&mut self, n: usize, limits: &Limits, at: usize) -> Result<()> {
|
| 115 |
+
self.items(n, limits, at)?;
|
| 116 |
+
self.bytes(
|
| 117 |
+
n.checked_mul(size_of::<T>())
|
| 118 |
+
.ok_or_else(|| err(at, ErrorKind::CountOverflow))?,
|
| 119 |
+
limits,
|
| 120 |
+
at,
|
| 121 |
+
)
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
#[derive(Clone, Copy)]
|
| 126 |
+
struct Width(u8);
|
| 127 |
+
|
| 128 |
+
impl Width {
|
| 129 |
+
fn from_bits(bits: u8, at: usize) -> Result<Self> {
|
| 130 |
+
if bits == 3 {
|
| 131 |
+
Err(err(at, ErrorKind::ReservedWidth))
|
| 132 |
+
} else {
|
| 133 |
+
Ok(Self(bits))
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
fn for_max(n: usize, version: Version, at: usize) -> Result<Self> {
|
| 138 |
+
if n > i32::MAX as usize {
|
| 139 |
+
return Err(err(at, ErrorKind::CountOverflow));
|
| 140 |
+
}
|
| 141 |
+
Ok(Self(if n <= 255 {
|
| 142 |
+
0
|
| 143 |
+
} else if n
|
| 144 |
+
<= if version == Version::V201501280 {
|
| 145 |
+
32767
|
| 146 |
+
} else {
|
| 147 |
+
65535
|
| 148 |
+
}
|
| 149 |
+
{
|
| 150 |
+
1
|
| 151 |
+
} else {
|
| 152 |
+
2
|
| 153 |
+
}))
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
fn bytes(self) -> usize {
|
| 157 |
+
[1, 2, 4][self.0 as usize]
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
fn sentinel(self) -> u32 {
|
| 161 |
+
[255, 65535, u32::MAX][self.0 as usize]
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
struct Decoder<'a> {
|
| 166 |
+
data: &'a [u8],
|
| 167 |
+
pos: usize,
|
| 168 |
+
limits: &'a Limits,
|
| 169 |
+
budget: Budget,
|
| 170 |
+
version: Version,
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
impl<'a> Decoder<'a> {
|
| 174 |
+
fn take(&mut self, n: usize) -> Result<&'a [u8]> {
|
| 175 |
+
if n > self.data.len() - self.pos {
|
| 176 |
+
return Err(err(self.pos, ErrorKind::UnexpectedEof));
|
| 177 |
+
}
|
| 178 |
+
let start = self.pos;
|
| 179 |
+
self.pos += n;
|
| 180 |
+
Ok(&self.data[start..self.pos])
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
fn byte(&mut self) -> Result<u8> {
|
| 184 |
+
Ok(self.take(1)?[0])
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
fn raw(&mut self, width: Width) -> Result<u32> {
|
| 188 |
+
let mut bytes = [0; 4];
|
| 189 |
+
bytes[..width.bytes()].copy_from_slice(self.take(width.bytes())?);
|
| 190 |
+
Ok(u32::from_le_bytes(bytes))
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
fn count(&mut self, width: Width) -> Result<usize> {
|
| 194 |
+
let at = self.pos;
|
| 195 |
+
let n = self.raw(width)?;
|
| 196 |
+
if (width.0 == 2 && n > i32::MAX as u32)
|
| 197 |
+
|| (width.0 == 1 && self.version == Version::V201501280 && n > i16::MAX as u32)
|
| 198 |
+
{
|
| 199 |
+
return Err(err(at, ErrorKind::NegativeCount));
|
| 200 |
+
}
|
| 201 |
+
Ok(n as usize)
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
fn reserve<T>(&mut self, n: usize, minimum_wire_bytes: usize) -> Result<Vec<T>> {
|
| 205 |
+
if n > (self.data.len() - self.pos) / minimum_wire_bytes.max(1) {
|
| 206 |
+
return Err(err(self.pos, ErrorKind::UnexpectedEof));
|
| 207 |
+
}
|
| 208 |
+
self.budget.claim::<T>(n, self.limits, self.pos)?;
|
| 209 |
+
let mut out = Vec::new();
|
| 210 |
+
out.try_reserve_exact(n)
|
| 211 |
+
.map_err(|_| err(self.pos, ErrorKind::Allocation))?;
|
| 212 |
+
Ok(out)
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
fn push<T>(&mut self, out: &mut Vec<T>, item: T, maximum: usize) -> Result<()> {
|
| 216 |
+
self.budget.items(1, self.limits, self.pos)?;
|
| 217 |
+
if out.len() == out.capacity() {
|
| 218 |
+
let capacity = out.capacity().saturating_mul(2).max(1).min(maximum);
|
| 219 |
+
let bytes = (capacity - out.capacity())
|
| 220 |
+
.checked_mul(size_of::<T>())
|
| 221 |
+
.ok_or_else(|| err(self.pos, ErrorKind::CountOverflow))?;
|
| 222 |
+
self.budget.bytes(bytes, self.limits, self.pos)?;
|
| 223 |
+
out.try_reserve_exact(capacity - out.len())
|
| 224 |
+
.map_err(|_| err(self.pos, ErrorKind::Allocation))?;
|
| 225 |
+
}
|
| 226 |
+
out.push(item);
|
| 227 |
+
Ok(())
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
fn string(&mut self) -> Result<String> {
|
| 231 |
+
let at = self.pos;
|
| 232 |
+
let remaining = &self.data[at..];
|
| 233 |
+
let n = remaining
|
| 234 |
+
.iter()
|
| 235 |
+
.take(self.limits.max_string_bytes.saturating_add(1))
|
| 236 |
+
.position(|b| *b == 0)
|
| 237 |
+
.ok_or_else(|| {
|
| 238 |
+
err(
|
| 239 |
+
at,
|
| 240 |
+
if remaining.len() > self.limits.max_string_bytes {
|
| 241 |
+
ErrorKind::LimitExceeded("string bytes")
|
| 242 |
+
} else {
|
| 243 |
+
ErrorKind::UnterminatedString
|
| 244 |
+
},
|
| 245 |
+
)
|
| 246 |
+
})?;
|
| 247 |
+
let raw = self.take(n + 1)?;
|
| 248 |
+
if !raw[..n].is_ascii() {
|
| 249 |
+
return Err(err(at, ErrorKind::NonAscii));
|
| 250 |
+
}
|
| 251 |
+
self.budget.bytes(n, self.limits, at)?;
|
| 252 |
+
let mut value = String::new();
|
| 253 |
+
value
|
| 254 |
+
.try_reserve_exact(n)
|
| 255 |
+
.map_err(|_| err(at, ErrorKind::Allocation))?;
|
| 256 |
+
value.push_str(std::str::from_utf8(&raw[..n]).map_err(|_| err(at, ErrorKind::NonAscii))?);
|
| 257 |
+
Ok(value)
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
fn table(&mut self, width: Width) -> Result<Vec<String>> {
|
| 261 |
+
let n = self.count(width)?;
|
| 262 |
+
let mut out = self.reserve(n, 1)?;
|
| 263 |
+
for _ in 0..n {
|
| 264 |
+
out.push(self.string()?);
|
| 265 |
+
}
|
| 266 |
+
Ok(out)
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
fn float(&mut self) -> Result<f32> {
|
| 270 |
+
let at = self.pos;
|
| 271 |
+
let value = f32::from_bits(self.raw(Width(2))?);
|
| 272 |
+
if !value.is_finite() {
|
| 273 |
+
return Err(err(at, ErrorKind::NonFinite));
|
| 274 |
+
}
|
| 275 |
+
Ok(value)
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
fn vector(&mut self) -> Result<Vec3> {
|
| 279 |
+
Ok([self.float()?, self.float()?, self.float()?])
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
fn vectors(&mut self, n: usize) -> Result<Vec<Vec3>> {
|
| 283 |
+
let mut out = self.reserve(n, 12)?;
|
| 284 |
+
for _ in 0..n {
|
| 285 |
+
out.push(self.vector()?);
|
| 286 |
+
}
|
| 287 |
+
Ok(out)
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
fn uv(&mut self) -> Result<Uv> {
|
| 291 |
+
Ok(Uv {
|
| 292 |
+
u: self.vector()?,
|
| 293 |
+
v: self.vector()?,
|
| 294 |
+
u_scale: self.float()?,
|
| 295 |
+
u_shift: self.float()?,
|
| 296 |
+
v_scale: self.float()?,
|
| 297 |
+
v_shift: self.float()?,
|
| 298 |
+
})
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
fn properties<T>(
|
| 302 |
+
&mut self,
|
| 303 |
+
names: usize,
|
| 304 |
+
width: Width,
|
| 305 |
+
mut value: impl FnMut(&mut Self) -> Result<T>,
|
| 306 |
+
) -> Result<Vec<Property<T>>> {
|
| 307 |
+
let mut out = Vec::new();
|
| 308 |
+
if names == 0 {
|
| 309 |
+
return Ok(out);
|
| 310 |
+
}
|
| 311 |
+
loop {
|
| 312 |
+
let at = self.pos;
|
| 313 |
+
let index = self.raw(width)?;
|
| 314 |
+
if index == width.sentinel() {
|
| 315 |
+
break;
|
| 316 |
+
}
|
| 317 |
+
if index as usize >= names {
|
| 318 |
+
return Err(err(at, ErrorKind::PropertyIndex(index)));
|
| 319 |
+
}
|
| 320 |
+
if out.len() >= names {
|
| 321 |
+
return Err(err(at, ErrorKind::DuplicateProperty(index)));
|
| 322 |
+
}
|
| 323 |
+
let entry = Property {
|
| 324 |
+
index,
|
| 325 |
+
value: value(self)?,
|
| 326 |
+
};
|
| 327 |
+
self.push(&mut out, entry, names)?;
|
| 328 |
+
}
|
| 329 |
+
out.sort_unstable_by_key(|p| p.index);
|
| 330 |
+
for pair in out.windows(2) {
|
| 331 |
+
if pair[0].index == pair[1].index {
|
| 332 |
+
return Err(err(self.pos, ErrorKind::DuplicateProperty(pair[0].index)));
|
| 333 |
+
}
|
| 334 |
+
}
|
| 335 |
+
Ok(out)
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
fn brush(&mut self, world: &World, sw: Width, uw: Width) -> Result<Brush> {
|
| 339 |
+
let at = self.pos;
|
| 340 |
+
let flags = self.byte()?;
|
| 341 |
+
if flags & 0xf0 != 0 {
|
| 342 |
+
return Err(err(at, ErrorKind::InvalidFlags(flags)));
|
| 343 |
+
}
|
| 344 |
+
let pw = Width::from_bits(flags & 3, at)?;
|
| 345 |
+
let vw = Width::from_bits((flags >> 2) & 3, at)?;
|
| 346 |
+
let n = self.count(pw)?;
|
| 347 |
+
let mut planes = self.reserve(n, 36)?;
|
| 348 |
+
for _ in 0..n {
|
| 349 |
+
let strings = self.properties(world.string_properties.len(), sw, Self::string)?;
|
| 350 |
+
let uv = self.properties(world.uv_properties.len(), uw, Self::uv)?;
|
| 351 |
+
let p0 = self.vector()?;
|
| 352 |
+
let u = self.vector()?;
|
| 353 |
+
let v = self.vector()?;
|
| 354 |
+
let polygon = if world.computed {
|
| 355 |
+
let n = self.count(vw)?;
|
| 356 |
+
Some(self.vectors(n)?)
|
| 357 |
+
} else {
|
| 358 |
+
None
|
| 359 |
+
};
|
| 360 |
+
planes.push(Plane {
|
| 361 |
+
p0,
|
| 362 |
+
u,
|
| 363 |
+
v,
|
| 364 |
+
strings,
|
| 365 |
+
uv,
|
| 366 |
+
polygon,
|
| 367 |
+
});
|
| 368 |
+
}
|
| 369 |
+
Ok(Brush { planes })
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
fn patch(&mut self, computed: bool) -> Result<Patch9> {
|
| 373 |
+
let at = self.pos;
|
| 374 |
+
let flags = self.byte()?;
|
| 375 |
+
if flags & 0xf0 != 0 {
|
| 376 |
+
return Err(err(at, ErrorKind::InvalidFlags(flags)));
|
| 377 |
+
}
|
| 378 |
+
let aw = Width::from_bits(flags & 3, at)?;
|
| 379 |
+
let qw = Width::from_bits((flags >> 2) & 3, at)?;
|
| 380 |
+
let au = self.count(aw)?;
|
| 381 |
+
let av = self.count(aw)?;
|
| 382 |
+
let qu = self.count(qw)?;
|
| 383 |
+
let qv = self.count(qw)?;
|
| 384 |
+
let n = product(au, av, at)?;
|
| 385 |
+
let anchors = self.vectors(n)?;
|
| 386 |
+
let computed = if computed {
|
| 387 |
+
let n = grid_size(au, av, qu, qv, at)?;
|
| 388 |
+
Some(PatchComputed {
|
| 389 |
+
points: self.vectors(n)?,
|
| 390 |
+
normals: self.vectors(n)?,
|
| 391 |
+
})
|
| 392 |
+
} else {
|
| 393 |
+
None
|
| 394 |
+
};
|
| 395 |
+
Ok(Patch9 {
|
| 396 |
+
anchors_u: au as u32,
|
| 397 |
+
anchors_v: av as u32,
|
| 398 |
+
quads_u: qu as u32,
|
| 399 |
+
quads_v: qv as u32,
|
| 400 |
+
anchors,
|
| 401 |
+
computed,
|
| 402 |
+
})
|
| 403 |
+
}
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
fn product(a: usize, b: usize, at: usize) -> Result<usize> {
|
| 407 |
+
a.checked_mul(b)
|
| 408 |
+
.ok_or_else(|| err(at, ErrorKind::CountOverflow))
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
fn grid_size(au: usize, av: usize, qu: usize, qv: usize, at: usize) -> Result<usize> {
|
| 412 |
+
let u = product(au / 2, qu, at)?
|
| 413 |
+
.checked_add(1)
|
| 414 |
+
.ok_or_else(|| err(at, ErrorKind::CountOverflow))?;
|
| 415 |
+
let v = product(av / 2, qv, at)?
|
| 416 |
+
.checked_add(1)
|
| 417 |
+
.ok_or_else(|| err(at, ErrorKind::CountOverflow))?;
|
| 418 |
+
product(u, v, at)
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
/// Decode one complete document. Unknown bits, selector 3, non-ASCII strings,
|
| 422 |
+
/// duplicate slots, non-finite floats and trailing bytes are rejected.
|
| 423 |
+
pub fn decode(data: &[u8], limits: &Limits) -> Result<Document> {
|
| 424 |
+
if data.len() > limits.max_bytes {
|
| 425 |
+
return Err(err(0, ErrorKind::LimitExceeded("file bytes")));
|
| 426 |
+
}
|
| 427 |
+
let mut r = Decoder {
|
| 428 |
+
data,
|
| 429 |
+
pos: 0,
|
| 430 |
+
limits,
|
| 431 |
+
budget: Budget::default(),
|
| 432 |
+
version: Version::V201512200,
|
| 433 |
+
};
|
| 434 |
+
if r.take(8)? != b"Mb3World" {
|
| 435 |
+
return Err(err(0, ErrorKind::InvalidMagic));
|
| 436 |
+
}
|
| 437 |
+
r.version = match r.raw(Width(2))? {
|
| 438 |
+
201501280 => Version::V201501280,
|
| 439 |
+
201512200 => Version::V201512200,
|
| 440 |
+
n => return Err(err(8, ErrorKind::UnsupportedVersion(n))),
|
| 441 |
+
};
|
| 442 |
+
let flags = r.byte()?;
|
| 443 |
+
let old = r.version == Version::V201501280;
|
| 444 |
+
if (old && flags > 1) || (!old && flags & 0xe0 != 0) {
|
| 445 |
+
return Err(err(12, ErrorKind::InvalidFlags(flags)));
|
| 446 |
+
}
|
| 447 |
+
let sw = Width::from_bits(if old { 0 } else { (flags >> 1) & 3 }, 12)?;
|
| 448 |
+
let uw = Width::from_bits(if old { 0 } else { (flags >> 3) & 3 }, 12)?;
|
| 449 |
+
let mut world = World {
|
| 450 |
+
computed: flags & 1 != 0,
|
| 451 |
+
..World::default()
|
| 452 |
+
};
|
| 453 |
+
if !old {
|
| 454 |
+
world.string_properties = r.table(sw)?;
|
| 455 |
+
world.uv_properties = r.table(uw)?;
|
| 456 |
+
}
|
| 457 |
+
let brushes = r.count(Width(2))?;
|
| 458 |
+
let patches = if old { Some(r.count(Width(2))?) } else { None };
|
| 459 |
+
world.brushes = r.reserve(brushes, 2)?;
|
| 460 |
+
for _ in 0..brushes {
|
| 461 |
+
let brush = r.brush(&world, sw, uw)?;
|
| 462 |
+
world.brushes.push(brush);
|
| 463 |
+
}
|
| 464 |
+
let patches = match patches {
|
| 465 |
+
Some(n) => n,
|
| 466 |
+
None => r.count(Width(2))?,
|
| 467 |
+
};
|
| 468 |
+
world.patches = r.reserve(patches, 5)?;
|
| 469 |
+
for _ in 0..patches {
|
| 470 |
+
world.patches.push(r.patch(world.computed)?);
|
| 471 |
+
}
|
| 472 |
+
if r.pos != data.len() {
|
| 473 |
+
return Err(err(r.pos, ErrorKind::TrailingData));
|
| 474 |
+
}
|
| 475 |
+
Ok(Document {
|
| 476 |
+
version: r.version,
|
| 477 |
+
world,
|
| 478 |
+
})
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
// Growth stays within the wire-byte limit and uses fallible allocation.
|
| 482 |
+
fn append(out: &mut Vec<u8>, bytes: &[u8], limit: usize) -> Result<()> {
|
| 483 |
+
let at = out.len();
|
| 484 |
+
let n = at
|
| 485 |
+
.checked_add(bytes.len())
|
| 486 |
+
.ok_or_else(|| err(at, ErrorKind::CountOverflow))?;
|
| 487 |
+
if n > limit {
|
| 488 |
+
return Err(err(at, ErrorKind::LimitExceeded("file bytes")));
|
| 489 |
+
}
|
| 490 |
+
if n > out.capacity() {
|
| 491 |
+
let capacity = n.max(out.capacity().saturating_mul(2)).min(limit);
|
| 492 |
+
out.try_reserve_exact(capacity - at)
|
| 493 |
+
.map_err(|_| err(at, ErrorKind::Allocation))?;
|
| 494 |
+
}
|
| 495 |
+
out.extend_from_slice(bytes);
|
| 496 |
+
Ok(())
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
struct Encoder<'a> {
|
| 500 |
+
data: Vec<u8>,
|
| 501 |
+
limits: &'a Limits,
|
| 502 |
+
budget: Budget,
|
| 503 |
+
version: Version,
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
impl Encoder<'_> {
|
| 507 |
+
fn bytes(&mut self, bytes: &[u8]) -> Result<()> {
|
| 508 |
+
append(&mut self.data, bytes, self.limits.max_bytes)
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
fn byte(&mut self, value: u8) -> Result<()> {
|
| 512 |
+
self.bytes(&[value])
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
fn raw(&mut self, value: u32, width: Width) -> Result<()> {
|
| 516 |
+
self.bytes(&value.to_le_bytes()[..width.bytes()])
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
fn count(&mut self, value: usize, width: Width) -> Result<()> {
|
| 520 |
+
let max = match width.0 {
|
| 521 |
+
0 => 255,
|
| 522 |
+
1 if self.version == Version::V201501280 => 32767,
|
| 523 |
+
1 => 65535,
|
| 524 |
+
_ => i32::MAX as usize,
|
| 525 |
+
};
|
| 526 |
+
if value > max {
|
| 527 |
+
return Err(err(self.data.len(), ErrorKind::CountOverflow));
|
| 528 |
+
}
|
| 529 |
+
self.raw(value as u32, width)
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
fn items(&mut self, n: usize) -> Result<()> {
|
| 533 |
+
self.budget.items(n, self.limits, self.data.len())
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
fn string(&mut self, s: &str) -> Result<()> {
|
| 537 |
+
let at = self.data.len();
|
| 538 |
+
if s.len() > self.limits.max_string_bytes {
|
| 539 |
+
return Err(err(at, ErrorKind::LimitExceeded("string bytes")));
|
| 540 |
+
}
|
| 541 |
+
if !s.is_ascii() {
|
| 542 |
+
return Err(err(at, ErrorKind::NonAscii));
|
| 543 |
+
}
|
| 544 |
+
if s.as_bytes().contains(&0) {
|
| 545 |
+
return Err(err(at, ErrorKind::EmbeddedNul));
|
| 546 |
+
}
|
| 547 |
+
self.bytes(s.as_bytes())?;
|
| 548 |
+
self.byte(0)
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
fn table(&mut self, names: &[String], width: Width) -> Result<()> {
|
| 552 |
+
self.items(names.len())?;
|
| 553 |
+
self.count(names.len(), width)?;
|
| 554 |
+
for name in names {
|
| 555 |
+
self.string(name)?;
|
| 556 |
+
}
|
| 557 |
+
Ok(())
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
fn float(&mut self, value: f32) -> Result<()> {
|
| 561 |
+
if !value.is_finite() {
|
| 562 |
+
return Err(err(self.data.len(), ErrorKind::NonFinite));
|
| 563 |
+
}
|
| 564 |
+
self.bytes(&value.to_le_bytes())
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
fn vector(&mut self, value: &Vec3) -> Result<()> {
|
| 568 |
+
for f in value {
|
| 569 |
+
self.float(*f)?;
|
| 570 |
+
}
|
| 571 |
+
Ok(())
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
fn vectors(&mut self, values: &[Vec3]) -> Result<()> {
|
| 575 |
+
self.items(values.len())?;
|
| 576 |
+
for value in values {
|
| 577 |
+
self.vector(value)?;
|
| 578 |
+
}
|
| 579 |
+
Ok(())
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
fn uv(&mut self, value: &Uv) -> Result<()> {
|
| 583 |
+
self.vector(&value.u)?;
|
| 584 |
+
self.vector(&value.v)?;
|
| 585 |
+
for f in [value.u_scale, value.u_shift, value.v_scale, value.v_shift] {
|
| 586 |
+
self.float(f)?;
|
| 587 |
+
}
|
| 588 |
+
Ok(())
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
fn properties<T>(
|
| 592 |
+
&mut self,
|
| 593 |
+
values: &[Property<T>],
|
| 594 |
+
names: usize,
|
| 595 |
+
width: Width,
|
| 596 |
+
mut write: impl FnMut(&mut Self, &T) -> Result<()>,
|
| 597 |
+
) -> Result<()> {
|
| 598 |
+
self.items(values.len())?;
|
| 599 |
+
let mut previous = None;
|
| 600 |
+
for p in values {
|
| 601 |
+
if p.index as usize >= names {
|
| 602 |
+
return Err(err(self.data.len(), ErrorKind::PropertyIndex(p.index)));
|
| 603 |
+
}
|
| 604 |
+
if previous == Some(p.index) {
|
| 605 |
+
return Err(err(self.data.len(), ErrorKind::DuplicateProperty(p.index)));
|
| 606 |
+
}
|
| 607 |
+
if previous.is_some_and(|n| n > p.index) {
|
| 608 |
+
return Err(err(self.data.len(), ErrorKind::UnsortedProperties));
|
| 609 |
+
}
|
| 610 |
+
previous = Some(p.index);
|
| 611 |
+
self.raw(p.index, width)?;
|
| 612 |
+
write(self, &p.value)?;
|
| 613 |
+
}
|
| 614 |
+
if names != 0 {
|
| 615 |
+
self.raw(width.sentinel(), width)?;
|
| 616 |
+
}
|
| 617 |
+
Ok(())
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
fn brush(&mut self, brush: &Brush, world: &World, sw: Width, uw: Width) -> Result<()> {
|
| 621 |
+
let at = self.data.len();
|
| 622 |
+
self.items(brush.planes.len())?;
|
| 623 |
+
let pw = Width::for_max(brush.planes.len(), self.version, at)?;
|
| 624 |
+
let mut largest = 0;
|
| 625 |
+
for plane in &brush.planes {
|
| 626 |
+
if plane.polygon.is_some() != world.computed {
|
| 627 |
+
return Err(err(at, ErrorKind::InconsistentComputedData));
|
| 628 |
+
}
|
| 629 |
+
largest = largest.max(plane.polygon.as_ref().map_or(0, Vec::len));
|
| 630 |
+
}
|
| 631 |
+
let vw = Width::for_max(largest, self.version, at)?;
|
| 632 |
+
self.byte(pw.0 | vw.0 << 2)?;
|
| 633 |
+
self.count(brush.planes.len(), pw)?;
|
| 634 |
+
for plane in &brush.planes {
|
| 635 |
+
if self.version == Version::V201501280
|
| 636 |
+
&& (!plane.strings.is_empty() || !plane.uv.is_empty())
|
| 637 |
+
{
|
| 638 |
+
return Err(err(self.data.len(), ErrorKind::LegacyProperties));
|
| 639 |
+
}
|
| 640 |
+
self.properties(&plane.strings, world.string_properties.len(), sw, |e, s| {
|
| 641 |
+
e.string(s)
|
| 642 |
+
})?;
|
| 643 |
+
self.properties(&plane.uv, world.uv_properties.len(), uw, Self::uv)?;
|
| 644 |
+
self.vector(&plane.p0)?;
|
| 645 |
+
self.vector(&plane.u)?;
|
| 646 |
+
self.vector(&plane.v)?;
|
| 647 |
+
if let Some(polygon) = &plane.polygon {
|
| 648 |
+
self.count(polygon.len(), vw)?;
|
| 649 |
+
self.vectors(polygon)?;
|
| 650 |
+
}
|
| 651 |
+
}
|
| 652 |
+
Ok(())
|
| 653 |
+
}
|
| 654 |
+
|
| 655 |
+
fn patch(&mut self, patch: &Patch9, computed: bool) -> Result<()> {
|
| 656 |
+
let at = self.data.len();
|
| 657 |
+
if patch.computed.is_some() != computed {
|
| 658 |
+
return Err(err(at, ErrorKind::InconsistentComputedData));
|
| 659 |
+
}
|
| 660 |
+
let au = patch.anchors_u as usize;
|
| 661 |
+
let av = patch.anchors_v as usize;
|
| 662 |
+
let qu = patch.quads_u as usize;
|
| 663 |
+
let qv = patch.quads_v as usize;
|
| 664 |
+
if patch.anchors.len() != product(au, av, at)? {
|
| 665 |
+
return Err(err(at, ErrorKind::InvalidPatchLength));
|
| 666 |
+
}
|
| 667 |
+
if let Some(data) = &patch.computed {
|
| 668 |
+
let n = grid_size(au, av, qu, qv, at)?;
|
| 669 |
+
if data.points.len() != n || data.normals.len() != n {
|
| 670 |
+
return Err(err(at, ErrorKind::InvalidPatchLength));
|
| 671 |
+
}
|
| 672 |
+
}
|
| 673 |
+
let aw = Width::for_max(au.max(av), self.version, at)?;
|
| 674 |
+
let qw = Width::for_max(qu.max(qv), self.version, at)?;
|
| 675 |
+
self.byte(aw.0 | qw.0 << 2)?;
|
| 676 |
+
self.count(au, aw)?;
|
| 677 |
+
self.count(av, aw)?;
|
| 678 |
+
self.count(qu, qw)?;
|
| 679 |
+
self.count(qv, qw)?;
|
| 680 |
+
self.vectors(&patch.anchors)?;
|
| 681 |
+
if let Some(data) = &patch.computed {
|
| 682 |
+
self.vectors(&data.points)?;
|
| 683 |
+
self.vectors(&data.normals)?;
|
| 684 |
+
}
|
| 685 |
+
Ok(())
|
| 686 |
+
}
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
/// Encode a document without optimizing properties or dropping data. Legacy
|
| 690 |
+
/// output refuses properties and uses four-byte counts for 32768..=65535.
|
| 691 |
+
/// All validation completes before bytes are returned or any I/O is attempted.
|
| 692 |
+
pub fn encode(document: &Document, limits: &Limits) -> Result<Vec<u8>> {
|
| 693 |
+
let world = &document.world;
|
| 694 |
+
let old = document.version == Version::V201501280;
|
| 695 |
+
if old && (!world.string_properties.is_empty() || !world.uv_properties.is_empty()) {
|
| 696 |
+
return Err(err(0, ErrorKind::LegacyProperties));
|
| 697 |
+
}
|
| 698 |
+
let mut e = Encoder {
|
| 699 |
+
data: Vec::new(),
|
| 700 |
+
limits,
|
| 701 |
+
budget: Budget::default(),
|
| 702 |
+
version: document.version,
|
| 703 |
+
};
|
| 704 |
+
let sw = Width::for_max(world.string_properties.len(), document.version, 12)?;
|
| 705 |
+
let uw = Width::for_max(world.uv_properties.len(), document.version, 12)?;
|
| 706 |
+
e.bytes(b"Mb3World")?;
|
| 707 |
+
e.raw(document.version as u32, Width(2))?;
|
| 708 |
+
e.byte(u8::from(world.computed) | sw.0 << 1 | uw.0 << 3)?;
|
| 709 |
+
if !old {
|
| 710 |
+
e.table(&world.string_properties, sw)?;
|
| 711 |
+
e.table(&world.uv_properties, uw)?;
|
| 712 |
+
}
|
| 713 |
+
e.items(world.brushes.len())?;
|
| 714 |
+
e.count(world.brushes.len(), Width(2))?;
|
| 715 |
+
if old {
|
| 716 |
+
e.count(world.patches.len(), Width(2))?;
|
| 717 |
+
}
|
| 718 |
+
for brush in &world.brushes {
|
| 719 |
+
e.brush(brush, world, sw, uw)?;
|
| 720 |
+
}
|
| 721 |
+
e.items(world.patches.len())?;
|
| 722 |
+
if !old {
|
| 723 |
+
e.count(world.patches.len(), Width(2))?;
|
| 724 |
+
}
|
| 725 |
+
for patch in &world.patches {
|
| 726 |
+
e.patch(patch, world.computed)?;
|
| 727 |
+
}
|
| 728 |
+
Ok(e.data)
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
/// Read until EOF, bounded to `max_bytes + 1` observed bytes. Short reads and
|
| 732 |
+
/// Interrupted are supported. The buffered input is then strictly decoded.
|
| 733 |
+
pub fn read(mut source: impl Read, limits: &Limits) -> Result<Document> {
|
| 734 |
+
let mut data = Vec::new();
|
| 735 |
+
let mut chunk = [0; 8192];
|
| 736 |
+
loop {
|
| 737 |
+
let n = limits
|
| 738 |
+
.max_bytes
|
| 739 |
+
.saturating_sub(data.len())
|
| 740 |
+
.saturating_add(1)
|
| 741 |
+
.min(chunk.len());
|
| 742 |
+
let n = match source.read(&mut chunk[..n]) {
|
| 743 |
+
Ok(0) => break,
|
| 744 |
+
Ok(n) => n,
|
| 745 |
+
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
| 746 |
+
Err(e) => return Err(err(data.len(), ErrorKind::Io(e))),
|
| 747 |
+
};
|
| 748 |
+
append(&mut data, &chunk[..n], limits.max_bytes)?;
|
| 749 |
+
}
|
| 750 |
+
decode(&data, limits)
|
| 751 |
+
}
|
| 752 |
+
|
| 753 |
+
/// Validate and encode before touching the destination. I/O failure can still
|
| 754 |
+
/// leave a written prefix; callers needing atomic files must save via a temp file.
|
| 755 |
+
pub fn write(mut destination: impl Write, document: &Document, limits: &Limits) -> Result<()> {
|
| 756 |
+
let data = encode(document, limits)?;
|
| 757 |
+
let mut written = 0;
|
| 758 |
+
while written != data.len() {
|
| 759 |
+
match destination.write(&data[written..]) {
|
| 760 |
+
Ok(0) => return Err(err(written, ErrorKind::Io(io::ErrorKind::WriteZero.into()))),
|
| 761 |
+
Ok(n) => written += n,
|
| 762 |
+
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
| 763 |
+
Err(e) => return Err(err(written, ErrorKind::Io(e))),
|
| 764 |
+
}
|
| 765 |
+
}
|
| 766 |
+
Ok(())
|
| 767 |
+
}
|
crates/mb3-dat/src/lib.rs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Safe, strict MB3 DAT serialization, independent of the editor or renderer.
|
| 2 |
+
//!
|
| 3 |
+
//! See `docs/RUST_DAT.md` for compatibility and resource limits. No geometry is
|
| 4 |
+
//! computed or validated. Decoding owns its results and never retains the input.
|
| 5 |
+
//!
|
| 6 |
+
//! ```
|
| 7 |
+
//! use mb3_dat::{Document, Limits, Version, World, decode, encode};
|
| 8 |
+
//! let document = Document { version: Version::V201512200, world: World::default() };
|
| 9 |
+
//! let bytes = encode(&document, &Limits::default())?;
|
| 10 |
+
//! assert_eq!(decode(&bytes, &Limits::default())?, document);
|
| 11 |
+
//! # Ok::<(), mb3_dat::Error>(())
|
| 12 |
+
//! ```
|
| 13 |
+
mod codec;
|
| 14 |
+
pub use codec::{Error, ErrorKind, Limits, decode, encode, read, write};
|
| 15 |
+
|
| 16 |
+
pub type Vec3 = [f32; 3];
|
| 17 |
+
|
| 18 |
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
| 19 |
+
#[repr(u32)]
|
| 20 |
+
pub enum Version {
|
| 21 |
+
V201501280 = 201501280,
|
| 22 |
+
V201512200 = 201512200,
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
#[derive(Clone, Debug, PartialEq)]
|
| 26 |
+
pub struct Document {
|
| 27 |
+
pub version: Version,
|
| 28 |
+
pub world: World,
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/// Table order and sparse indices are preserved; no converter optimization runs.
|
| 32 |
+
#[derive(Clone, Debug, Default, PartialEq)]
|
| 33 |
+
pub struct World {
|
| 34 |
+
pub computed: bool,
|
| 35 |
+
pub string_properties: Vec<String>,
|
| 36 |
+
pub uv_properties: Vec<String>,
|
| 37 |
+
pub brushes: Vec<Brush>,
|
| 38 |
+
pub patches: Vec<Patch9>,
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
#[derive(Clone, Debug, Default, PartialEq)]
|
| 42 |
+
pub struct Brush {
|
| 43 |
+
pub planes: Vec<Plane>,
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/// Sparse entries must be unique and sorted by index when encoding. Decoding
|
| 47 |
+
/// sorts wire entries by index. An absent entry differs from an empty value.
|
| 48 |
+
#[derive(Clone, Debug, PartialEq)]
|
| 49 |
+
pub struct Property<T> {
|
| 50 |
+
pub index: u32,
|
| 51 |
+
pub value: T,
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
#[derive(Clone, Debug, PartialEq)]
|
| 55 |
+
pub struct Uv {
|
| 56 |
+
pub u: Vec3,
|
| 57 |
+
pub v: Vec3,
|
| 58 |
+
pub u_scale: f32,
|
| 59 |
+
pub u_shift: f32,
|
| 60 |
+
pub v_scale: f32,
|
| 61 |
+
pub v_shift: f32,
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
#[derive(Clone, Debug, Default, PartialEq)]
|
| 65 |
+
pub struct Plane {
|
| 66 |
+
pub p0: Vec3,
|
| 67 |
+
pub u: Vec3,
|
| 68 |
+
pub v: Vec3,
|
| 69 |
+
pub strings: Vec<Property<String>>,
|
| 70 |
+
pub uv: Vec<Property<Uv>>,
|
| 71 |
+
/// Some (including an empty polygon) iff the world's computed flag is true.
|
| 72 |
+
pub polygon: Option<Vec<Vec3>>,
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
#[derive(Clone, Debug, Default, PartialEq)]
|
| 76 |
+
pub struct Patch9 {
|
| 77 |
+
pub anchors_u: u32,
|
| 78 |
+
pub anchors_v: u32,
|
| 79 |
+
pub quads_u: u32,
|
| 80 |
+
pub quads_v: u32,
|
| 81 |
+
pub anchors: Vec<Vec3>,
|
| 82 |
+
/// Some iff the world's computed flag is true.
|
| 83 |
+
pub computed: Option<PatchComputed>,
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
#[derive(Clone, Debug, Default, PartialEq)]
|
| 87 |
+
pub struct PatchComputed {
|
| 88 |
+
pub points: Vec<Vec3>,
|
| 89 |
+
pub normals: Vec<Vec3>,
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
#[cfg(test)]
|
| 93 |
+
mod tests;
|
crates/mb3-dat/src/tests.rs
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use super::*;
|
| 2 |
+
use std::io::{self, Cursor, Read, Write};
|
| 3 |
+
|
| 4 |
+
const VERSIONS: [Version; 2] = [Version::V201501280, Version::V201512200];
|
| 5 |
+
|
| 6 |
+
fn empty(version: Version) -> Document {
|
| 7 |
+
Document {
|
| 8 |
+
version,
|
| 9 |
+
world: World::default(),
|
| 10 |
+
}
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
fn rich(version: Version) -> Document {
|
| 14 |
+
let plane = Plane {
|
| 15 |
+
p0: [1.0, -0.0, 3.0],
|
| 16 |
+
u: [4.0, 5.0, 6.0],
|
| 17 |
+
v: [7.0, 8.0, 9.0],
|
| 18 |
+
polygon: Some(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]),
|
| 19 |
+
..Plane::default()
|
| 20 |
+
};
|
| 21 |
+
let mut d = Document {
|
| 22 |
+
version,
|
| 23 |
+
world: World {
|
| 24 |
+
computed: true,
|
| 25 |
+
brushes: vec![Brush {
|
| 26 |
+
planes: vec![plane],
|
| 27 |
+
}],
|
| 28 |
+
patches: vec![Patch9 {
|
| 29 |
+
anchors_u: 3,
|
| 30 |
+
anchors_v: 3,
|
| 31 |
+
quads_u: 2,
|
| 32 |
+
quads_v: 2,
|
| 33 |
+
anchors: vec![[1.0, 2.0, 3.0]; 9],
|
| 34 |
+
computed: Some(PatchComputed {
|
| 35 |
+
points: vec![[4.0, 5.0, 6.0]; 9],
|
| 36 |
+
normals: vec![[0.0, 1.0, 0.0]; 9],
|
| 37 |
+
}),
|
| 38 |
+
}],
|
| 39 |
+
..World::default()
|
| 40 |
+
},
|
| 41 |
+
};
|
| 42 |
+
if version == Version::V201512200 {
|
| 43 |
+
d.world.string_properties = vec!["unused".into(), "name".into()];
|
| 44 |
+
d.world.uv_properties = vec!["uv".into()];
|
| 45 |
+
d.world.brushes[0].planes[0].strings = vec![Property {
|
| 46 |
+
index: 1,
|
| 47 |
+
value: String::new(),
|
| 48 |
+
}];
|
| 49 |
+
d.world.brushes[0].planes[0].uv = vec![Property {
|
| 50 |
+
index: 0,
|
| 51 |
+
value: Uv {
|
| 52 |
+
u: [1.0, 2.0, 3.0],
|
| 53 |
+
v: [6.0, 7.0, 8.0],
|
| 54 |
+
u_scale: 4.0,
|
| 55 |
+
u_shift: 5.0,
|
| 56 |
+
v_scale: 9.0,
|
| 57 |
+
v_shift: 10.0,
|
| 58 |
+
},
|
| 59 |
+
}];
|
| 60 |
+
}
|
| 61 |
+
d
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
#[test]
|
| 65 |
+
fn exact_empty_headers() {
|
| 66 |
+
for version in VERSIONS {
|
| 67 |
+
let mut expected = b"Mb3World".to_vec();
|
| 68 |
+
expected.extend_from_slice(&(version as u32).to_le_bytes());
|
| 69 |
+
expected.extend_from_slice(&[0; 9]);
|
| 70 |
+
if version == Version::V201512200 {
|
| 71 |
+
expected.extend_from_slice(&[0; 2]);
|
| 72 |
+
}
|
| 73 |
+
let d = empty(version);
|
| 74 |
+
assert_eq!(encode(&d, &Limits::default()).unwrap(), expected);
|
| 75 |
+
assert_eq!(decode(&expected, &Limits::default()).unwrap(), d);
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
#[test]
|
| 80 |
+
fn rich_semantic_and_bit_exact_roundtrips() {
|
| 81 |
+
for version in VERSIONS {
|
| 82 |
+
let d = rich(version);
|
| 83 |
+
let data = encode(&d, &Limits::default()).unwrap();
|
| 84 |
+
let decoded = decode(&data, &Limits::default()).unwrap();
|
| 85 |
+
assert_eq!(decoded, d);
|
| 86 |
+
assert_eq!(
|
| 87 |
+
decoded.world.brushes[0].planes[0].p0[1].to_bits(),
|
| 88 |
+
(-0.0f32).to_bits()
|
| 89 |
+
);
|
| 90 |
+
assert_eq!(encode(&decoded, &Limits::default()).unwrap(), data);
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
#[test]
|
| 95 |
+
fn rejects_every_prefix_truncation() {
|
| 96 |
+
for version in VERSIONS {
|
| 97 |
+
let data = encode(&rich(version), &Limits::default()).unwrap();
|
| 98 |
+
for n in 0..data.len() {
|
| 99 |
+
assert!(
|
| 100 |
+
decode(&data[..n], &Limits::default()).is_err(),
|
| 101 |
+
"version {version:?}, prefix {n}"
|
| 102 |
+
);
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
#[test]
|
| 108 |
+
fn rejects_header_flags_versions_trailing_and_reserved_widths() {
|
| 109 |
+
for version in VERSIONS {
|
| 110 |
+
let data = encode(&empty(version), &Limits::default()).unwrap();
|
| 111 |
+
for (at, value) in [(0, b'x'), (8, 0), (12, 0xe0), (12, 6)] {
|
| 112 |
+
let mut bad = data.clone();
|
| 113 |
+
bad[at] = value;
|
| 114 |
+
assert!(decode(&bad, &Limits::default()).is_err());
|
| 115 |
+
}
|
| 116 |
+
let mut extra = data;
|
| 117 |
+
extra.push(0);
|
| 118 |
+
assert!(matches!(
|
| 119 |
+
decode(&extra, &Limits::default()).unwrap_err().kind,
|
| 120 |
+
ErrorKind::TrailingData
|
| 121 |
+
));
|
| 122 |
+
}
|
| 123 |
+
for flags in [3, 12, 0xf0] {
|
| 124 |
+
let mut data = encode(&empty(Version::V201512200), &Limits::default()).unwrap();
|
| 125 |
+
data[15..19].copy_from_slice(&1i32.to_le_bytes());
|
| 126 |
+
data.splice(19..19, [flags, 0]);
|
| 127 |
+
assert!(decode(&data, &Limits::default()).is_err());
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
#[test]
|
| 132 |
+
fn counts_rejected_before_allocation() {
|
| 133 |
+
for count in [-1i32, i32::MAX] {
|
| 134 |
+
let mut data = encode(&empty(Version::V201512200), &Limits::default()).unwrap();
|
| 135 |
+
data[15..19].copy_from_slice(&count.to_le_bytes());
|
| 136 |
+
assert!(decode(&data, &Limits::default()).is_err());
|
| 137 |
+
}
|
| 138 |
+
let mut old = encode(&empty(Version::V201501280), &Limits::default()).unwrap();
|
| 139 |
+
old[13..17].copy_from_slice(&1i32.to_le_bytes());
|
| 140 |
+
old.extend_from_slice(&[1, 0, 128]);
|
| 141 |
+
assert!(matches!(
|
| 142 |
+
decode(&old, &Limits::default()).unwrap_err().kind,
|
| 143 |
+
ErrorKind::NegativeCount
|
| 144 |
+
));
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
#[test]
|
| 148 |
+
fn plane_width_boundaries_and_legacy_repair() {
|
| 149 |
+
for version in VERSIONS {
|
| 150 |
+
for n in [255, 256, 32767, 32768, 65535, 65536] {
|
| 151 |
+
let mut d = empty(version);
|
| 152 |
+
d.world.brushes.push(Brush {
|
| 153 |
+
planes: vec![Plane::default(); n],
|
| 154 |
+
});
|
| 155 |
+
let data = encode(&d, &Limits::default()).unwrap();
|
| 156 |
+
let flag_at = if version == Version::V201501280 {
|
| 157 |
+
21
|
| 158 |
+
} else {
|
| 159 |
+
19
|
| 160 |
+
};
|
| 161 |
+
let expected = if n <= 255 {
|
| 162 |
+
0
|
| 163 |
+
} else if n
|
| 164 |
+
<= if version == Version::V201501280 {
|
| 165 |
+
32767
|
| 166 |
+
} else {
|
| 167 |
+
65535
|
| 168 |
+
}
|
| 169 |
+
{
|
| 170 |
+
1
|
| 171 |
+
} else {
|
| 172 |
+
2
|
| 173 |
+
};
|
| 174 |
+
assert_eq!(data[flag_at], expected);
|
| 175 |
+
assert_eq!(decode(&data, &Limits::default()).unwrap(), d);
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
#[test]
|
| 181 |
+
fn string_and_uv_table_width_boundaries_preserve_unused_names() {
|
| 182 |
+
for n in [255, 256, 65535, 65536] {
|
| 183 |
+
let mut d = empty(Version::V201512200);
|
| 184 |
+
d.world.string_properties = (0..n).map(|i| format!("s{i}")).collect();
|
| 185 |
+
d.world.uv_properties = (0..n).map(|i| format!("u{i}")).collect();
|
| 186 |
+
let data = encode(&d, &Limits::default()).unwrap();
|
| 187 |
+
let width = if n <= 255 {
|
| 188 |
+
0
|
| 189 |
+
} else if n <= 65535 {
|
| 190 |
+
1
|
| 191 |
+
} else {
|
| 192 |
+
2
|
| 193 |
+
};
|
| 194 |
+
assert_eq!(data[12], width << 1 | width << 3);
|
| 195 |
+
assert_eq!(decode(&data, &Limits::default()).unwrap(), d);
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
#[test]
|
| 200 |
+
fn sparse_index_and_sentinel_width_boundaries() {
|
| 201 |
+
for n in [255, 256, 65535, 65536] {
|
| 202 |
+
let mut d = empty(Version::V201512200);
|
| 203 |
+
d.world.string_properties = (0..n).map(|i| format!("p{i}")).collect();
|
| 204 |
+
d.world.uv_properties = d.world.string_properties.clone();
|
| 205 |
+
d.world.brushes.push(Brush {
|
| 206 |
+
planes: vec![Plane {
|
| 207 |
+
strings: vec![Property {
|
| 208 |
+
index: n - 1,
|
| 209 |
+
value: String::new(),
|
| 210 |
+
}],
|
| 211 |
+
uv: vec![Property {
|
| 212 |
+
index: n - 1,
|
| 213 |
+
value: Uv {
|
| 214 |
+
u: [1.0; 3],
|
| 215 |
+
v: [2.0; 3],
|
| 216 |
+
u_scale: 3.0,
|
| 217 |
+
u_shift: 4.0,
|
| 218 |
+
v_scale: 5.0,
|
| 219 |
+
v_shift: 6.0,
|
| 220 |
+
},
|
| 221 |
+
}],
|
| 222 |
+
..Plane::default()
|
| 223 |
+
}],
|
| 224 |
+
});
|
| 225 |
+
let data = encode(&d, &Limits::default()).unwrap();
|
| 226 |
+
assert_eq!(decode(&data, &Limits::default()).unwrap(), d);
|
| 227 |
+
}
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
#[test]
|
| 231 |
+
fn shared_contour_width_and_legacy_repair() {
|
| 232 |
+
for version in VERSIONS {
|
| 233 |
+
for n in [256, 32768, 65536] {
|
| 234 |
+
let mut d = empty(version);
|
| 235 |
+
d.world.computed = true;
|
| 236 |
+
d.world.brushes.push(Brush {
|
| 237 |
+
planes: vec![
|
| 238 |
+
Plane {
|
| 239 |
+
polygon: Some(vec![[1.0, 2.0, 3.0]; n]),
|
| 240 |
+
..Plane::default()
|
| 241 |
+
},
|
| 242 |
+
Plane {
|
| 243 |
+
polygon: Some(vec![[4.0, 5.0, 6.0]]),
|
| 244 |
+
..Plane::default()
|
| 245 |
+
},
|
| 246 |
+
],
|
| 247 |
+
});
|
| 248 |
+
let data = encode(&d, &Limits::default()).unwrap();
|
| 249 |
+
assert_eq!(decode(&data, &Limits::default()).unwrap(), d);
|
| 250 |
+
}
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
#[test]
|
| 255 |
+
fn patch_dimensions_and_tessellation_widths() {
|
| 256 |
+
for version in VERSIONS {
|
| 257 |
+
for n in [255, 256, 32768, 65536] {
|
| 258 |
+
let mut d = empty(version);
|
| 259 |
+
d.world.patches.push(Patch9 {
|
| 260 |
+
anchors_u: n,
|
| 261 |
+
anchors_v: 0,
|
| 262 |
+
quads_u: n,
|
| 263 |
+
quads_v: 2,
|
| 264 |
+
..Patch9::default()
|
| 265 |
+
});
|
| 266 |
+
assert_eq!(
|
| 267 |
+
decode(&encode(&d, &Limits::default()).unwrap(), &Limits::default()).unwrap(),
|
| 268 |
+
d
|
| 269 |
+
);
|
| 270 |
+
}
|
| 271 |
+
}
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
#[test]
|
| 275 |
+
fn strict_strings_on_read_and_write() {
|
| 276 |
+
for text in ["é", "a\0b"] {
|
| 277 |
+
let mut d = empty(Version::V201512200);
|
| 278 |
+
d.world.string_properties.push(text.into());
|
| 279 |
+
assert!(encode(&d, &Limits::default()).is_err());
|
| 280 |
+
}
|
| 281 |
+
for tail in [vec![1, b'a'], vec![1, 0xff, 0]] {
|
| 282 |
+
let mut data = b"Mb3World".to_vec();
|
| 283 |
+
data.extend_from_slice(&201512200i32.to_le_bytes());
|
| 284 |
+
data.push(0);
|
| 285 |
+
data.extend(tail);
|
| 286 |
+
assert!(decode(&data, &Limits::default()).is_err());
|
| 287 |
+
}
|
| 288 |
+
let mut d = empty(Version::V201512200);
|
| 289 |
+
d.world.string_properties = vec!["same".into(), "same".into()];
|
| 290 |
+
assert_eq!(
|
| 291 |
+
decode(&encode(&d, &Limits::default()).unwrap(), &Limits::default()).unwrap(),
|
| 292 |
+
d
|
| 293 |
+
);
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
fn sparse_wire(entries: &[u8]) -> Vec<u8> {
|
| 297 |
+
let mut data = b"Mb3World".to_vec();
|
| 298 |
+
data.extend_from_slice(&201512200i32.to_le_bytes());
|
| 299 |
+
data.extend_from_slice(b"\0\x02a\0b\0\0");
|
| 300 |
+
data.extend_from_slice(&1i32.to_le_bytes());
|
| 301 |
+
data.extend_from_slice(&[0, 1]);
|
| 302 |
+
data.extend_from_slice(entries);
|
| 303 |
+
data.push(255);
|
| 304 |
+
data.extend_from_slice(&[0; 36]);
|
| 305 |
+
data.extend_from_slice(&0i32.to_le_bytes());
|
| 306 |
+
data
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
#[test]
|
| 310 |
+
fn binary_properties_sorted_but_duplicates_and_bad_indices_rejected() {
|
| 311 |
+
let d = decode(&sparse_wire(b"\x01B\0\0A\0"), &Limits::default()).unwrap();
|
| 312 |
+
assert_eq!(
|
| 313 |
+
d.world.brushes[0].planes[0].strings,
|
| 314 |
+
vec![
|
| 315 |
+
Property {
|
| 316 |
+
index: 0,
|
| 317 |
+
value: "A".into()
|
| 318 |
+
},
|
| 319 |
+
Property {
|
| 320 |
+
index: 1,
|
| 321 |
+
value: "B".into()
|
| 322 |
+
}
|
| 323 |
+
]
|
| 324 |
+
);
|
| 325 |
+
for entries in [b"\0a\0\0b\0".as_slice(), b"\x02a\0"] {
|
| 326 |
+
assert!(decode(&sparse_wire(entries), &Limits::default()).is_err());
|
| 327 |
+
}
|
| 328 |
+
let mut d = d;
|
| 329 |
+
d.world.brushes[0].planes[0].strings.reverse();
|
| 330 |
+
assert!(matches!(
|
| 331 |
+
encode(&d, &Limits::default()).unwrap_err().kind,
|
| 332 |
+
ErrorKind::UnsortedProperties
|
| 333 |
+
));
|
| 334 |
+
d.world.brushes[0].planes[0].strings = vec![
|
| 335 |
+
Property {
|
| 336 |
+
index: 0,
|
| 337 |
+
value: "a".into()
|
| 338 |
+
};
|
| 339 |
+
2
|
| 340 |
+
];
|
| 341 |
+
assert!(matches!(
|
| 342 |
+
encode(&d, &Limits::default()).unwrap_err().kind,
|
| 343 |
+
ErrorKind::DuplicateProperty(0)
|
| 344 |
+
));
|
| 345 |
+
d.world.brushes[0].planes[0].strings[0].index = 9;
|
| 346 |
+
assert!(matches!(
|
| 347 |
+
encode(&d, &Limits::default()).unwrap_err().kind,
|
| 348 |
+
ErrorKind::PropertyIndex(9)
|
| 349 |
+
));
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
#[test]
|
| 353 |
+
fn legacy_downgrade_refuses_properties() {
|
| 354 |
+
let mut d = rich(Version::V201512200);
|
| 355 |
+
d.version = Version::V201501280;
|
| 356 |
+
assert!(matches!(
|
| 357 |
+
encode(&d, &Limits::default()).unwrap_err().kind,
|
| 358 |
+
ErrorKind::LegacyProperties
|
| 359 |
+
));
|
| 360 |
+
d.world.string_properties.clear();
|
| 361 |
+
d.world.uv_properties.clear();
|
| 362 |
+
assert!(matches!(
|
| 363 |
+
encode(&d, &Limits::default()).unwrap_err().kind,
|
| 364 |
+
ErrorKind::LegacyProperties
|
| 365 |
+
));
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
#[test]
|
| 369 |
+
fn refuses_inconsistent_computed_arrays() {
|
| 370 |
+
for version in VERSIONS {
|
| 371 |
+
for mode in 0..5 {
|
| 372 |
+
let mut d = rich(version);
|
| 373 |
+
match mode {
|
| 374 |
+
0 => d.world.computed = false,
|
| 375 |
+
1 => d.world.brushes[0].planes[0].polygon = None,
|
| 376 |
+
2 => d.world.patches[0].computed = None,
|
| 377 |
+
3 => {
|
| 378 |
+
d.world.patches[0].anchors.pop();
|
| 379 |
+
}
|
| 380 |
+
_ => {
|
| 381 |
+
d.world.patches[0].computed.as_mut().unwrap().normals.pop();
|
| 382 |
+
}
|
| 383 |
+
}
|
| 384 |
+
assert!(encode(&d, &Limits::default()).is_err());
|
| 385 |
+
}
|
| 386 |
+
}
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
#[test]
|
| 390 |
+
fn overflow_and_impossible_dimensions_are_errors() {
|
| 391 |
+
for version in VERSIONS {
|
| 392 |
+
let mut d = empty(version);
|
| 393 |
+
d.world.computed = true;
|
| 394 |
+
d.world.patches.push(Patch9 {
|
| 395 |
+
anchors_u: 7,
|
| 396 |
+
anchors_v: 7,
|
| 397 |
+
quads_u: i32::MAX as u32,
|
| 398 |
+
quads_v: i32::MAX as u32,
|
| 399 |
+
anchors: vec![[0.0; 3]; 49],
|
| 400 |
+
computed: Some(PatchComputed::default()),
|
| 401 |
+
});
|
| 402 |
+
assert!(encode(&d, &Limits::default()).is_err());
|
| 403 |
+
d.world.computed = false;
|
| 404 |
+
d.world.patches[0].computed = None;
|
| 405 |
+
d.world.patches[0].quads_u = u32::MAX;
|
| 406 |
+
assert!(matches!(
|
| 407 |
+
encode(&d, &Limits::default()).unwrap_err().kind,
|
| 408 |
+
ErrorKind::CountOverflow
|
| 409 |
+
));
|
| 410 |
+
}
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
#[test]
|
| 414 |
+
fn nonfinite_and_finite_extremes() {
|
| 415 |
+
for f in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
| 416 |
+
let mut d = rich(Version::V201512200);
|
| 417 |
+
d.world.brushes[0].planes[0].p0[0] = f;
|
| 418 |
+
assert!(matches!(
|
| 419 |
+
encode(&d, &Limits::default()).unwrap_err().kind,
|
| 420 |
+
ErrorKind::NonFinite
|
| 421 |
+
));
|
| 422 |
+
let mut data = sparse_wire(&[]);
|
| 423 |
+
// The final 40 bytes are the plane vectors and the patch count.
|
| 424 |
+
let pos = data.len() - 40;
|
| 425 |
+
data[pos..pos + 4].copy_from_slice(&f.to_le_bytes());
|
| 426 |
+
assert!(matches!(
|
| 427 |
+
decode(&data, &Limits::default()).unwrap_err().kind,
|
| 428 |
+
ErrorKind::NonFinite
|
| 429 |
+
));
|
| 430 |
+
}
|
| 431 |
+
let mut d = rich(Version::V201501280);
|
| 432 |
+
d.world.brushes[0].planes[0].p0 = [f32::MAX, f32::MIN_POSITIVE, f32::from_bits(1)];
|
| 433 |
+
assert_eq!(
|
| 434 |
+
decode(&encode(&d, &Limits::default()).unwrap(), &Limits::default()).unwrap(),
|
| 435 |
+
d
|
| 436 |
+
);
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
#[test]
|
| 440 |
+
fn each_resource_limit_is_enforced() {
|
| 441 |
+
let d = rich(Version::V201512200);
|
| 442 |
+
let data = encode(&d, &Limits::default()).unwrap();
|
| 443 |
+
for limits in [
|
| 444 |
+
Limits {
|
| 445 |
+
max_bytes: data.len() - 1,
|
| 446 |
+
..Limits::default()
|
| 447 |
+
},
|
| 448 |
+
Limits {
|
| 449 |
+
max_items: 1,
|
| 450 |
+
..Limits::default()
|
| 451 |
+
},
|
| 452 |
+
Limits {
|
| 453 |
+
max_string_bytes: 1,
|
| 454 |
+
..Limits::default()
|
| 455 |
+
},
|
| 456 |
+
] {
|
| 457 |
+
assert!(decode(&data, &limits).is_err());
|
| 458 |
+
assert!(encode(&d, &limits).is_err());
|
| 459 |
+
}
|
| 460 |
+
assert!(
|
| 461 |
+
decode(
|
| 462 |
+
&data,
|
| 463 |
+
&Limits {
|
| 464 |
+
max_owned_bytes: 1,
|
| 465 |
+
..Limits::default()
|
| 466 |
+
}
|
| 467 |
+
)
|
| 468 |
+
.is_err()
|
| 469 |
+
);
|
| 470 |
+
let mut input = Cursor::new(&data);
|
| 471 |
+
assert!(
|
| 472 |
+
read(
|
| 473 |
+
&mut input,
|
| 474 |
+
&Limits {
|
| 475 |
+
max_bytes: 20,
|
| 476 |
+
..Limits::default()
|
| 477 |
+
}
|
| 478 |
+
)
|
| 479 |
+
.is_err()
|
| 480 |
+
);
|
| 481 |
+
assert_eq!(input.position(), 21);
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
#[test]
|
| 485 |
+
fn exact_string_and_item_limits() {
|
| 486 |
+
let mut d = empty(Version::V201512200);
|
| 487 |
+
d.world.string_properties.push("abcd".into());
|
| 488 |
+
let limits = Limits {
|
| 489 |
+
max_items: 1,
|
| 490 |
+
max_string_bytes: 4,
|
| 491 |
+
..Limits::default()
|
| 492 |
+
};
|
| 493 |
+
let data = encode(&d, &limits).unwrap();
|
| 494 |
+
assert_eq!(decode(&data, &limits).unwrap(), d);
|
| 495 |
+
assert!(
|
| 496 |
+
decode(
|
| 497 |
+
&data,
|
| 498 |
+
&Limits {
|
| 499 |
+
max_string_bytes: 3,
|
| 500 |
+
..limits
|
| 501 |
+
}
|
| 502 |
+
)
|
| 503 |
+
.is_err()
|
| 504 |
+
);
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
struct Choppy {
|
| 508 |
+
bytes: Cursor<Vec<u8>>,
|
| 509 |
+
interrupt: bool,
|
| 510 |
+
}
|
| 511 |
+
impl Read for Choppy {
|
| 512 |
+
fn read(&mut self, b: &mut [u8]) -> io::Result<usize> {
|
| 513 |
+
if self.interrupt {
|
| 514 |
+
self.interrupt = false;
|
| 515 |
+
return Err(io::ErrorKind::Interrupted.into());
|
| 516 |
+
}
|
| 517 |
+
let n = b.len().min(1);
|
| 518 |
+
self.bytes.read(&mut b[..n])
|
| 519 |
+
}
|
| 520 |
+
}
|
| 521 |
+
impl Write for Choppy {
|
| 522 |
+
fn write(&mut self, b: &[u8]) -> io::Result<usize> {
|
| 523 |
+
if self.interrupt {
|
| 524 |
+
self.interrupt = false;
|
| 525 |
+
return Err(io::ErrorKind::Interrupted.into());
|
| 526 |
+
}
|
| 527 |
+
self.bytes.write(&b[..b.len().min(1)])
|
| 528 |
+
}
|
| 529 |
+
fn flush(&mut self) -> io::Result<()> {
|
| 530 |
+
Ok(())
|
| 531 |
+
}
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
#[test]
|
| 535 |
+
fn streaming_short_reads_writes_and_interruptions() {
|
| 536 |
+
let d = rich(Version::V201512200);
|
| 537 |
+
let data = encode(&d, &Limits::default()).unwrap();
|
| 538 |
+
assert_eq!(
|
| 539 |
+
read(
|
| 540 |
+
Choppy {
|
| 541 |
+
bytes: Cursor::new(data.clone()),
|
| 542 |
+
interrupt: true
|
| 543 |
+
},
|
| 544 |
+
&Limits::default()
|
| 545 |
+
)
|
| 546 |
+
.unwrap(),
|
| 547 |
+
d
|
| 548 |
+
);
|
| 549 |
+
let mut target = Choppy {
|
| 550 |
+
bytes: Cursor::new(Vec::new()),
|
| 551 |
+
interrupt: true,
|
| 552 |
+
};
|
| 553 |
+
write(&mut target, &d, &Limits::default()).unwrap();
|
| 554 |
+
assert_eq!(target.bytes.into_inner(), data);
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
#[test]
|
| 558 |
+
fn invalid_models_do_not_touch_writer() {
|
| 559 |
+
let mut d = rich(Version::V201501280);
|
| 560 |
+
d.world.patches[0].anchors.clear();
|
| 561 |
+
let mut target = vec![1, 2, 3];
|
| 562 |
+
assert!(write(&mut target, &d, &Limits::default()).is_err());
|
| 563 |
+
assert_eq!(target, [1, 2, 3]);
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
struct Broken {
|
| 567 |
+
written: usize,
|
| 568 |
+
}
|
| 569 |
+
impl Write for Broken {
|
| 570 |
+
fn write(&mut self, b: &[u8]) -> io::Result<usize> {
|
| 571 |
+
if self.written >= 7 {
|
| 572 |
+
return Err(io::ErrorKind::BrokenPipe.into());
|
| 573 |
+
}
|
| 574 |
+
let n = b.len().min(7 - self.written);
|
| 575 |
+
self.written += n;
|
| 576 |
+
Ok(n)
|
| 577 |
+
}
|
| 578 |
+
fn flush(&mut self) -> io::Result<()> {
|
| 579 |
+
Ok(())
|
| 580 |
+
}
|
| 581 |
+
}
|
| 582 |
+
impl Read for Broken {
|
| 583 |
+
fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
|
| 584 |
+
Err(io::ErrorKind::BrokenPipe.into())
|
| 585 |
+
}
|
| 586 |
+
}
|
| 587 |
+
#[test]
|
| 588 |
+
fn io_errors_and_offsets_propagate() {
|
| 589 |
+
let e = write(
|
| 590 |
+
Broken { written: 0 },
|
| 591 |
+
&empty(Version::V201501280),
|
| 592 |
+
&Limits::default(),
|
| 593 |
+
)
|
| 594 |
+
.unwrap_err();
|
| 595 |
+
assert_eq!(e.offset, 7);
|
| 596 |
+
assert!(matches!(e.kind, ErrorKind::Io(_)));
|
| 597 |
+
assert!(matches!(
|
| 598 |
+
read(Broken { written: 0 }, &Limits::default())
|
| 599 |
+
.unwrap_err()
|
| 600 |
+
.kind,
|
| 601 |
+
ErrorKind::Io(_)
|
| 602 |
+
));
|
| 603 |
+
assert!(matches!(
|
| 604 |
+
write(
|
| 605 |
+
[0u8; 0].as_mut_slice(),
|
| 606 |
+
&empty(Version::V201501280),
|
| 607 |
+
&Limits::default()
|
| 608 |
+
)
|
| 609 |
+
.unwrap_err()
|
| 610 |
+
.kind,
|
| 611 |
+
ErrorKind::Io(_)
|
| 612 |
+
));
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
#[test]
|
| 616 |
+
fn deterministic_mutation_smoke_no_panics_or_unbounded_allocations() {
|
| 617 |
+
let limits = Limits {
|
| 618 |
+
max_bytes: 2048,
|
| 619 |
+
max_string_bytes: 64,
|
| 620 |
+
max_items: 256,
|
| 621 |
+
max_owned_bytes: 32768,
|
| 622 |
+
};
|
| 623 |
+
let seeds: Vec<_> = VERSIONS
|
| 624 |
+
.iter()
|
| 625 |
+
.map(|v| encode(&rich(*v), &limits).unwrap())
|
| 626 |
+
.collect();
|
| 627 |
+
let mut state = 0x37ab12cdu32;
|
| 628 |
+
for i in 0..5000 {
|
| 629 |
+
let mut data = seeds[i % seeds.len()].clone();
|
| 630 |
+
for _ in 0..1 + i % 8 {
|
| 631 |
+
state = state.wrapping_mul(1664525).wrapping_add(1013904223);
|
| 632 |
+
let at = state as usize % data.len();
|
| 633 |
+
data[at] ^= (state >> 24) as u8;
|
| 634 |
+
}
|
| 635 |
+
if let Ok(d) = decode(&data, &limits) {
|
| 636 |
+
let canonical = encode(&d, &limits).unwrap();
|
| 637 |
+
assert_eq!(decode(&canonical, &limits).unwrap(), d);
|
| 638 |
+
assert_eq!(
|
| 639 |
+
encode(&decode(&canonical, &limits).unwrap(), &limits).unwrap(),
|
| 640 |
+
canonical
|
| 641 |
+
);
|
| 642 |
+
}
|
| 643 |
+
}
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
#[test]
|
| 647 |
+
fn valid_nonminimal_widths_are_normalized_and_patch_flags_are_strict() {
|
| 648 |
+
let canonical = encode(&empty(Version::V201512200), &Limits::default()).unwrap();
|
| 649 |
+
let mut wide = canonical.clone();
|
| 650 |
+
wide[12] = 2; // S uses u16 rather than u8, while its count is zero.
|
| 651 |
+
wide.insert(13, 0);
|
| 652 |
+
let d = decode(&wide, &Limits::default()).unwrap();
|
| 653 |
+
assert_eq!(encode(&d, &Limits::default()).unwrap(), canonical);
|
| 654 |
+
for version in VERSIONS {
|
| 655 |
+
let mut d = empty(version);
|
| 656 |
+
d.world.patches.push(Patch9::default());
|
| 657 |
+
let data = encode(&d, &Limits::default()).unwrap();
|
| 658 |
+
let flag_at = if version == Version::V201501280 {
|
| 659 |
+
21
|
| 660 |
+
} else {
|
| 661 |
+
23
|
| 662 |
+
};
|
| 663 |
+
for flags in [3, 12, 0x80] {
|
| 664 |
+
let mut bad = data.clone();
|
| 665 |
+
bad[flag_at] = flags;
|
| 666 |
+
assert!(decode(&bad, &Limits::default()).is_err());
|
| 667 |
+
}
|
| 668 |
+
}
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
#[test]
|
| 672 |
+
fn reader_rejects_computed_grid_arithmetic_overflow() {
|
| 673 |
+
for version in VERSIONS {
|
| 674 |
+
let mut data = b"Mb3World".to_vec();
|
| 675 |
+
data.extend_from_slice(&(version as u32).to_le_bytes());
|
| 676 |
+
data.push(1);
|
| 677 |
+
if version == Version::V201512200 {
|
| 678 |
+
data.extend_from_slice(&[0, 0]);
|
| 679 |
+
}
|
| 680 |
+
data.extend_from_slice(&0i32.to_le_bytes());
|
| 681 |
+
data.extend_from_slice(&1i32.to_le_bytes());
|
| 682 |
+
data.extend_from_slice(&[8, 7, 7]);
|
| 683 |
+
data.extend_from_slice(&i32::MAX.to_le_bytes());
|
| 684 |
+
data.extend_from_slice(&i32::MAX.to_le_bytes());
|
| 685 |
+
data.extend_from_slice(&[0; 49 * 12]);
|
| 686 |
+
assert!(matches!(
|
| 687 |
+
decode(&data, &Limits::default()).unwrap_err().kind,
|
| 688 |
+
ErrorKind::CountOverflow
|
| 689 |
+
));
|
| 690 |
+
}
|
| 691 |
+
}
|
crates/mb3-dat/tests/converter.rs
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Requires hydrated, hash-verified E02 assets. Never silently skips fixtures.
|
| 2 |
+
use mb3_dat::*;
|
| 3 |
+
use std::fs;
|
| 4 |
+
use std::path::PathBuf;
|
| 5 |
+
|
| 6 |
+
fn corpus() -> PathBuf {
|
| 7 |
+
std::env::var_os("NANOBRUSH_CONVERTER_FIXTURES")
|
| 8 |
+
.map(PathBuf::from)
|
| 9 |
+
.unwrap_or_else(|| {
|
| 10 |
+
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/analysis/converter/probes")
|
| 11 |
+
})
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
fn fixture(build: &str, case: &str, name: &str) -> Vec<u8> {
|
| 15 |
+
let path = corpus().join(build).join(case).join(name);
|
| 16 |
+
fs::read(&path).unwrap_or_else(|e| {
|
| 17 |
+
panic!(
|
| 18 |
+
"hydrate E02 assets with assets-fetch; {}: {e}",
|
| 19 |
+
path.display()
|
| 20 |
+
)
|
| 21 |
+
})
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
#[test]
|
| 25 |
+
fn all_58_converter_dat_outputs_are_byte_exact_roundtrips() {
|
| 26 |
+
for (build, version, expected) in [
|
| 27 |
+
("2015-10-03", Version::V201501280, 25),
|
| 28 |
+
("2016-03-10-c", Version::V201512200, 33),
|
| 29 |
+
] {
|
| 30 |
+
let paths = fs::read_dir(corpus().join(build)).expect("hydrate E02 fixtures; assets-fetch");
|
| 31 |
+
let mut count = 0;
|
| 32 |
+
for case in paths {
|
| 33 |
+
let path = case.unwrap().path().join("output.mb3dat");
|
| 34 |
+
if !path.exists() {
|
| 35 |
+
continue;
|
| 36 |
+
}
|
| 37 |
+
let data = fs::read(&path).unwrap();
|
| 38 |
+
let d = decode(&data, &Limits::default())
|
| 39 |
+
.unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
| 40 |
+
assert_eq!(d.version, version);
|
| 41 |
+
assert_eq!(
|
| 42 |
+
encode(&d, &Limits::default()).unwrap(),
|
| 43 |
+
data,
|
| 44 |
+
"{}",
|
| 45 |
+
path.display()
|
| 46 |
+
);
|
| 47 |
+
count += 1;
|
| 48 |
+
}
|
| 49 |
+
assert_eq!(count, expected, "missing or changed corpus for {build}");
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
#[test]
|
| 54 |
+
fn native_semantics_match_asymmetric_basis_sparse_uv_and_computed_patch() {
|
| 55 |
+
for build in ["2015-10-03", "2016-03-10-c"] {
|
| 56 |
+
let d = decode(
|
| 57 |
+
&fixture(build, "plane-to-dat", "output.mb3dat"),
|
| 58 |
+
&Limits::default(),
|
| 59 |
+
)
|
| 60 |
+
.unwrap();
|
| 61 |
+
let p = &d.world.brushes[0].planes[0];
|
| 62 |
+
assert_eq!((p.p0, p.u, p.v), ([1., 2., 3.], [4., 5., 6.], [7., 8., 9.]));
|
| 63 |
+
let d = decode(
|
| 64 |
+
&fixture(build, "patch-true", "output.mb3dat"),
|
| 65 |
+
&Limits::default(),
|
| 66 |
+
)
|
| 67 |
+
.unwrap();
|
| 68 |
+
let p = &d.world.patches[0];
|
| 69 |
+
assert_eq!(
|
| 70 |
+
(p.anchors_u, p.anchors_v, p.quads_u, p.quads_v),
|
| 71 |
+
(3, 3, 2, 2)
|
| 72 |
+
);
|
| 73 |
+
assert_eq!(p.anchors.len(), 9);
|
| 74 |
+
assert_eq!(p.computed.as_ref().unwrap().normals, vec![[0., 1., 0.]; 9]);
|
| 75 |
+
}
|
| 76 |
+
let d = decode(
|
| 77 |
+
&fixture("2016-03-10-c", "properties-to-dat", "output.mb3dat"),
|
| 78 |
+
&Limits::default(),
|
| 79 |
+
)
|
| 80 |
+
.unwrap();
|
| 81 |
+
assert_eq!(d.world.string_properties, ["common", "rare"]);
|
| 82 |
+
let p = &d.world.brushes[0].planes[0];
|
| 83 |
+
assert_eq!(
|
| 84 |
+
p.strings,
|
| 85 |
+
[
|
| 86 |
+
Property {
|
| 87 |
+
index: 0,
|
| 88 |
+
value: "c".into()
|
| 89 |
+
},
|
| 90 |
+
Property {
|
| 91 |
+
index: 1,
|
| 92 |
+
value: "r".into()
|
| 93 |
+
}
|
| 94 |
+
]
|
| 95 |
+
);
|
| 96 |
+
assert_eq!(
|
| 97 |
+
p.uv[0].value,
|
| 98 |
+
Uv {
|
| 99 |
+
u: [1., 2., 3.],
|
| 100 |
+
v: [6., 7., 8.],
|
| 101 |
+
u_scale: 4.,
|
| 102 |
+
u_shift: 5.,
|
| 103 |
+
v_scale: 9.,
|
| 104 |
+
v_shift: 10.
|
| 105 |
+
}
|
| 106 |
+
);
|
| 107 |
+
assert_eq!(
|
| 108 |
+
d.world.brushes[0].planes[1].strings,
|
| 109 |
+
[Property {
|
| 110 |
+
index: 0,
|
| 111 |
+
value: String::new()
|
| 112 |
+
}]
|
| 113 |
+
);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
#[test]
|
| 117 |
+
fn rejects_captured_corrupt_and_ambiguous_inputs() {
|
| 118 |
+
for build in ["2015-10-03", "2016-03-10-c"] {
|
| 119 |
+
for case in ["dat-trailing", "wrong-version", "truncated-last-float"] {
|
| 120 |
+
assert!(
|
| 121 |
+
decode(&fixture(build, case, "input.mb3dat"), &Limits::default()).is_err(),
|
| 122 |
+
"{build}/{case}"
|
| 123 |
+
);
|
| 124 |
+
}
|
| 125 |
+
// Autodetection intentionally accepts the other version; original converters reject it.
|
| 126 |
+
assert!(
|
| 127 |
+
decode(
|
| 128 |
+
&fixture(build, "cross-version", "input.mb3dat"),
|
| 129 |
+
&Limits::default()
|
| 130 |
+
)
|
| 131 |
+
.is_ok()
|
| 132 |
+
);
|
| 133 |
+
}
|
| 134 |
+
for case in [
|
| 135 |
+
"reserved-width-3",
|
| 136 |
+
"reserved-world-bits",
|
| 137 |
+
"binary-duplicate-slot",
|
| 138 |
+
"truncated-patch-count",
|
| 139 |
+
] {
|
| 140 |
+
assert!(
|
| 141 |
+
decode(
|
| 142 |
+
&fixture("2016-03-10-c", case, "input.mb3dat"),
|
| 143 |
+
&Limits::default()
|
| 144 |
+
)
|
| 145 |
+
.is_err(),
|
| 146 |
+
"{case}"
|
| 147 |
+
);
|
| 148 |
+
}
|
| 149 |
+
assert!(
|
| 150 |
+
decode(
|
| 151 |
+
&fixture("2015-10-03", "signed-16-count", "input.mb3dat"),
|
| 152 |
+
&Limits::default()
|
| 153 |
+
)
|
| 154 |
+
.is_err()
|
| 155 |
+
);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
#[test]
|
| 159 |
+
fn generated_properties_require_explicit_loss_before_legacy_migration() {
|
| 160 |
+
let mut d = decode(
|
| 161 |
+
&fixture("2016-03-10-c", "properties-to-dat", "output.mb3dat"),
|
| 162 |
+
&Limits::default(),
|
| 163 |
+
)
|
| 164 |
+
.unwrap();
|
| 165 |
+
d.version = Version::V201501280;
|
| 166 |
+
assert!(matches!(
|
| 167 |
+
encode(&d, &Limits::default()).unwrap_err().kind,
|
| 168 |
+
ErrorKind::LegacyProperties
|
| 169 |
+
));
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
#[test]
|
| 173 |
+
fn migrate_property_free_computed_document_both_directions() {
|
| 174 |
+
let old = fixture("2015-10-03", "patch-true", "output.mb3dat");
|
| 175 |
+
let latest = fixture("2016-03-10-c", "patch-true", "output.mb3dat");
|
| 176 |
+
let mut d = decode(&old, &Limits::default()).unwrap();
|
| 177 |
+
d.version = Version::V201512200;
|
| 178 |
+
assert_eq!(encode(&d, &Limits::default()).unwrap(), latest);
|
| 179 |
+
d = decode(&latest, &Limits::default()).unwrap();
|
| 180 |
+
d.version = Version::V201501280;
|
| 181 |
+
assert_eq!(encode(&d, &Limits::default()).unwrap(), old);
|
| 182 |
+
|
| 183 |
+
// Compose independently captured brush/patch record bytes to exercise both
|
| 184 |
+
// nonzero counts together, including their different positions by version.
|
| 185 |
+
for (build, version) in [
|
| 186 |
+
("2015-10-03", Version::V201501280),
|
| 187 |
+
("2016-03-10-c", Version::V201512200),
|
| 188 |
+
] {
|
| 189 |
+
let brush = fixture(build, "computed-plane", "output.mb3dat");
|
| 190 |
+
let patch = fixture(build, "patch-true", "output.mb3dat");
|
| 191 |
+
let mut mixed = decode(&brush, &Limits::default()).unwrap();
|
| 192 |
+
mixed.world.patches = decode(&patch, &Limits::default()).unwrap().world.patches;
|
| 193 |
+
let mut expected;
|
| 194 |
+
if version == Version::V201501280 {
|
| 195 |
+
expected = brush[..21].to_vec();
|
| 196 |
+
expected[17..21].copy_from_slice(&1i32.to_le_bytes());
|
| 197 |
+
expected.extend_from_slice(&brush[21..]);
|
| 198 |
+
expected.extend_from_slice(&patch[21..]);
|
| 199 |
+
} else {
|
| 200 |
+
expected = brush[..brush.len() - 4].to_vec();
|
| 201 |
+
expected.extend_from_slice(&1i32.to_le_bytes());
|
| 202 |
+
expected.extend_from_slice(&patch[23..]);
|
| 203 |
+
}
|
| 204 |
+
assert_eq!(encode(&mixed, &Limits::default()).unwrap(), expected);
|
| 205 |
+
assert_eq!(decode(&expected, &Limits::default()).unwrap(), mixed);
|
| 206 |
+
}
|
| 207 |
+
}
|
docs/FINDINGS.md
CHANGED
|
@@ -153,3 +153,7 @@ The [plugin map](PLUGINS.md) accounts for 17 top-level plugins, five experimenta
|
|
| 153 |
## F24–F28 — Executed converter investigation
|
| 154 |
|
| 155 |
[MB3 DAT](MB3_DAT.md) specifies the two recovered binary layouts, complete hierarchy/width/string/UV/computed-geometry contracts and method RVAs. [Converter behavior](CONVERTER.md) documents property optimization, encoding loss/drift, point/basis transforms, MAP/VMF/Q3/OBJ preservation/loss, CLI/log/overwrite behavior and malformed-file defects. [E02](experiments/converter-2026-10-21.md) ran 101 controlled probes using unchanged executables under Mono on macOS ARM64, retained raw evidence in Xet and added a bounded independent inspector. The authored network-free suite now has 52 tests. Native editor, Windows/Linux parity and full hostile-file coverage remain unverified.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
## F24–F28 — Executed converter investigation
|
| 154 |
|
| 155 |
[MB3 DAT](MB3_DAT.md) specifies the two recovered binary layouts, complete hierarchy/width/string/UV/computed-geometry contracts and method RVAs. [Converter behavior](CONVERTER.md) documents property optimization, encoding loss/drift, point/basis transforms, MAP/VMF/Q3/OBJ preservation/loss, CLI/log/overwrite behavior and malformed-file defects. [E02](experiments/converter-2026-10-21.md) ran 101 controlled probes using unchanged executables under Mono on macOS ARM64, retained raw evidence in Xet and added a bounded independent inspector. The authored network-free suite now has 52 tests. Native editor, Windows/Linux parity and full hostile-file coverage remain unverified.
|
| 156 |
+
|
| 157 |
+
## F29 — Native DAT serialization and compatibility profile
|
| 158 |
+
|
| 159 |
+
The [Rust DAT codec](RUST_DAT.md) reads and writes both recovered layouts with checked counts/products, finite f32 and strict ASCII policies, bounded allocation/streaming and explicit rejection of ambiguous or incomplete data. It preserves property table order and absent-versus-empty values, refuses silent property loss on downgrade, and repairs the old signed-16 count range by choosing four bytes. All 58 captured DAT outputs round-trip exactly; intentional departures from legacy corruption, normalization and permissive parsing are documented separately from remaining editor/platform interoperability work.
|
docs/MB3_DAT.md
CHANGED
|
@@ -116,4 +116,4 @@ Observed probes establish empty/computed worlds, asymmetric plane vectors, spars
|
|
| 116 |
|
| 117 |
The research [inspector](../scripts/inspect_mb3dat.py) independently reads both layouts and reports offsets and trailing bytes. It deliberately checks every read and bounds allocation: 64 MiB input and 100,000 elements per count/vector array are **research-tool limits**, not recovered format maxima. It neither validates convex geometry nor writes files nor claims Rust compatibility.
|
| 118 |
|
| 119 |
-
The Rust codec
|
|
|
|
| 116 |
|
| 117 |
The research [inspector](../scripts/inspect_mb3dat.py) independently reads both layouts and reports offsets and trailing bytes. It deliberately checks every read and bounds allocation: 64 MiB input and 100,000 elements per count/vector array are **research-tool limits**, not recovered format maxima. It neither validates convex geometry nor writes files nor claims Rust compatibility.
|
| 118 |
|
| 119 |
+
The implemented [Rust codec](RUST_DAT.md) uses explicit LE primitives, checked arithmetic, finite-number policy, bounded allocations, checked string termination/indexing and complete reads. It rejects unknown bits and trailing data and does not reproduce stale-buffer behavior. Both DAT versions pass all 58 converter-output byte round trips. Native editor compatibility, real user documents, non-finite floats, enormous counts, embedded NUL handling, malformed patch lengths and broader fuzzing remain P6.19/P6.20 work.
|
docs/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
Planning baseline: 2026-10-21. Project name: **Nanobrush**; reference software: **Microbrush 3 / Iteration X / Akula**.
|
| 4 |
|
| 5 |
-
The intended result is a complete native Rust and Bevy reconstruction for macOS and Linux, with Solari lighting on supported hardware. The initial plan is complete, and source and managed-converter reverse engineering are underway. Repository setup and permanent extraction are now implemented; see [setup validation](SETUP.md). A converter oracle and research DAT inspector are implemented;
|
| 6 |
|
| 7 |
| Document | Purpose |
|
| 8 |
| --- | --- |
|
|
@@ -18,6 +18,8 @@ The first source investigation is documented in [formats](FORMATS.md), [host API
|
|
| 18 |
|
| 19 |
The executed converter investigation adds [MB3 DAT layouts](MB3_DAT.md), [conversion behavior](CONVERTER.md), and [E02](experiments/converter-2026-10-21.md): 101 probes across both distinct binaries and 52 code-only tests.
|
| 20 |
|
|
|
|
|
|
|
| 21 |
The asset boundary and extraction pipeline are established. Continue with the open P0 decisions and P3–P9 investigation tasks in `TODO.md`, using the source maps to design focused runtime experiments. Run the rendering capability spike early, independently of most editor research. Implement the rewrite in small end-to-end slices after the corresponding behavior is specified.
|
| 22 |
|
| 23 |
## Status vocabulary
|
|
|
|
| 2 |
|
| 3 |
Planning baseline: 2026-10-21. Project name: **Nanobrush**; reference software: **Microbrush 3 / Iteration X / Akula**.
|
| 4 |
|
| 5 |
+
The intended result is a complete native Rust and Bevy reconstruction for macOS and Linux, with Solari lighting on supported hardware. The initial plan is complete, and source and managed-converter reverse engineering are underway. Repository setup and permanent extraction are now implemented; see [setup validation](SETUP.md). A converter oracle and research DAT inspector are implemented; safe Rust DAT readers/writers are implemented; other native codecs and the application remain future work.
|
| 6 |
|
| 7 |
| Document | Purpose |
|
| 8 |
| --- | --- |
|
|
|
|
| 18 |
|
| 19 |
The executed converter investigation adds [MB3 DAT layouts](MB3_DAT.md), [conversion behavior](CONVERTER.md), and [E02](experiments/converter-2026-10-21.md): 101 probes across both distinct binaries and 52 code-only tests.
|
| 20 |
|
| 21 |
+
The first native format implementation is [the Rust DAT codec](RUST_DAT.md), covering both recovered versions with 58 exact converter-output round trips and an explicit strict compatibility profile; reproduction and limits are in [E03](experiments/rust-dat-2026-10-21.md).
|
| 22 |
+
|
| 23 |
The asset boundary and extraction pipeline are established. Continue with the open P0 decisions and P3–P9 investigation tasks in `TODO.md`, using the source maps to design focused runtime experiments. Run the rendering capability spike early, independently of most editor research. Implement the rewrite in small end-to-end slices after the corresponding behavior is specified.
|
| 24 |
|
| 25 |
## Status vocabulary
|
docs/RUST_DAT.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Rust DAT codec and compatibility profile — F29
|
| 2 |
+
|
| 3 |
+
Implemented in `crates/mb3-dat`, a dependency-free Rust library in the root Cargo workspace. The [binary contract](MB3_DAT.md) remains the format specification. This implementation handles versions **201501280** and **201512200**; it does not require .NET, IXPL, Bevy, a GPU or the original converter at runtime. The package is unpublished pending the project's source-license decision.
|
| 4 |
+
|
| 5 |
+
## API and data model
|
| 6 |
+
|
| 7 |
+
```rust
|
| 8 |
+
use mb3_dat::{read, write, Document, Limits, Version};
|
| 9 |
+
|
| 10 |
+
fn migrate_property_free_file(input: impl std::io::Read,
|
| 11 |
+
output: impl std::io::Write)
|
| 12 |
+
-> Result<(), mb3_dat::Error>
|
| 13 |
+
{
|
| 14 |
+
let limits = Limits::default();
|
| 15 |
+
let mut document: Document = read(input, &limits)?;
|
| 16 |
+
document.version = Version::V201512200;
|
| 17 |
+
write(output, &document, &limits)
|
| 18 |
+
}
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
`decode(&[u8], &Limits)` detects the header version; `encode(&Document, &Limits)` writes the selected version. `read` buffers a bounded input and handles short/interrupted reads; `write` completes encoding/validation before touching the caller's writer, handles short/interrupted writes, and reports I/O failures with a completed-prefix byte offset. `write` does not flush the caller's writer. It cannot make filesystem writes atomic: callers must implement temporary-file/rename transactions. The current library does not open or overwrite files itself.
|
| 22 |
+
|
| 23 |
+
`Document` contains `Version` and `World`. World retains the computed flag, separate ordered string/UV name tables, brushes and patches. Plane triples are P0/U/V, not three absolute points. `Uv` names both axes, scales and shifts; serialization uses the recovered ten-float wire order. `Property<T>` stores an index and value in a sparse vector, retaining **absent versus present-empty** values. Decode sorts entries by index; encode requires unique, increasing indices. Table order, unused names, duplicate table names, object order and finite f32 bits (including negative zero and subnormals) are preserved. Property tables are not optimized or sorted by usage.
|
| 24 |
+
|
| 25 |
+
Each plane has `polygon: Option<Vec<Vec3>>`; each patch has `computed: Option<PatchComputed>`. These must be present exactly when the world flag is true, even for an empty polygon. Patch anchor lengths must equal `anchors_u * anchors_v`. Computed points and normals must each match the implicit grid-size formula in the specification. Encode rejects inconsistent models rather than dropping arrays or producing an incoherent file. This is structural serialization validation, not convexity, brush validity, normal-length or patch evaluation. Zero/even anchor dimensions and zero tessellation remain representable when their array lengths agree, including the unusual width-isolation converter fixtures.
|
| 26 |
+
|
| 27 |
+
Errors expose `ErrorKind` and `offset`; I/O causes remain available through `std::error::Error::source`. There is no permissive recovery mode. A version change on a decoded property-free document performs actual hierarchy migration. Selecting the old version with property names or plane property values returns `LegacyProperties`; callers must make and report any loss decision themselves.
|
| 28 |
+
|
| 29 |
+
## Intentional differences from the converters
|
| 30 |
+
|
| 31 |
+
| Area | Legacy behavior (E02) | Rust behavior |
|
| 32 |
+
| --- | --- | --- |
|
| 33 |
+
| Version dispatch | Each executable accepts only its own DAT version | Reader automatically handles either; writer uses explicit `Document.version` |
|
| 34 |
+
| Short numeric reads | Reuses stale buffer bytes and can succeed with corrupt values | Every field must be complete; every tested proper prefix is rejected |
|
| 35 |
+
| Unterminated strings | EOF can create an unbounded byte list | Terminator, per-string limit, input limit and allocation budget checked |
|
| 36 |
+
| ASCII | Non-ASCII writes become `?`; high-bit bytes decode lossily | Rejects non-ASCII on read/write; rejects NUL in in-memory strings |
|
| 37 |
+
| Float payloads | No explicit finiteness checks | NaN/infinity rejected; finite binary32 values retained bit-for-bit |
|
| 38 |
+
| Duplicate slots | Binary last-wins | Rejected; duplicate table **names** remain legal and index-distinct |
|
| 39 |
+
| Slot ordering | Accepts arbitrary order | Accepts unique arbitrary wire order, returns sorted sparse entries; requires sorted entries when writing |
|
| 40 |
+
| Reserved flags/selector 3 | Ignored or treated as a four-byte selector | Rejected, including an old boolean byte other than 0/1 |
|
| 41 |
+
| Valid nonminimal widths | Accepted and minimized on save | Accepted and minimized on save; original width bytes are not retained |
|
| 42 |
+
| Trailing bytes | Ignored/dropped | Rejected; reader expects one complete document |
|
| 43 |
+
| Property normalization | Latest CLI removes unused names/reorders used ones | Preserves names/order/indices; no hidden normalization |
|
| 44 |
+
| 2015 counts 32,768–65,535 | Writer chooses signed i16 and produces negative reader counts | Writer chooses i32; reader rejects the malformed signed-i16 representation |
|
| 45 |
+
| Computed data | TXT can emit missing/wrong-sized arrays; no evaluation | Writer requires consistent arrays; no synthesis or geometry evaluation |
|
| 46 |
+
| Count arithmetic/allocation | Unchecked products/counts and allocations | Checked products/sums, wire-length checks, aggregate budgets, fallible reservation |
|
| 47 |
+
| Save behavior | `File.Create` may truncate before a later failure | No I/O until validation/encoding succeeds; later I/O failure can still leave a prefix |
|
| 48 |
+
|
| 49 |
+
The legacy count repair applies consistently to plane counts, shared contour widths and paired patch dimensions. It intentionally changes bytes where the old writer was broken, while using the four-byte branch already present in the recovered old reader. Rust synthetic boundary tests cover this; direct execution of the legacy converter on the repaired large-count output remains an interoperability follow-up. Existing E02 outputs within the old valid count range are byte-identical after Rust round trips.
|
| 50 |
+
|
| 51 |
+
## Resource policy
|
| 52 |
+
|
| 53 |
+
Defaults are application policy, not file-format maxima:
|
| 54 |
+
|
| 55 |
+
| Limit | Default | Scope |
|
| 56 |
+
| --- | ---: | --- |
|
| 57 |
+
| `max_bytes` | 64 MiB | Complete input and encoded output; streaming input observes at most one excess byte |
|
| 58 |
+
| `max_string_bytes` | 1 MiB | Each ASCII name/value, excluding terminator |
|
| 59 |
+
| `max_items` | 1,000,000 | Aggregate names, brushes, planes, sparse entries, patches and vector-array elements |
|
| 60 |
+
| `max_owned_bytes` | 128 MiB | Decoder's requested owned record/array/sparse-capacity/string storage |
|
| 61 |
+
|
| 62 |
+
Decoded storage budget excludes caller-owned input, allocator overhead and transient reallocations. Streaming `read` also owns its input buffer, bounded separately by `max_bytes`; encoding owns an output buffer bounded by `max_bytes`. `max_owned_bytes` does not limit an already caller-owned document or the encoded buffer. These are explicit accounting limits, not an exact process-RSS guarantee. All heap reservations in codec parsing/serialization are fallible; `unsafe` is forbidden by workspace lint. Integer fields must fit their version's nonnegative count representation. Sparse vector growth is geometric, capped by the table size, with capacity charged to the decoder budget; duplicate checking sorts once instead of repeatedly scanning prior entries. There is no recursive input traversal.
|
| 63 |
+
|
| 64 |
+
## Reproduction and evidence
|
| 65 |
+
|
| 66 |
+
Run inside the pinned Nix shell:
|
| 67 |
+
|
| 68 |
+
```sh
|
| 69 |
+
cargo fmt --all --check
|
| 70 |
+
cargo clippy --workspace --all-targets --locked --offline -- -D warnings
|
| 71 |
+
analyze-converter check
|
| 72 |
+
cargo test --workspace --locked --offline
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
The `converter` integration test requires hydrated E02 assets under `assets/analysis/converter/probes`. Missing files, pointer stubs or changed corpus counts fail; no fixture test silently skips. An explicit alternate corpus path can be supplied with `NANOBRUSH_CONVERTER_FIXTURES`. `analyze-converter check` verifies the original SHA-256-indexed captures before Rust tests; the fixtures remain Xet payloads and the test source remains ordinary Git.
|
| 76 |
+
|
| 77 |
+
Validation:
|
| 78 |
+
|
| 79 |
+
- All **58** E02 DAT output files (25 old, 33 latest) decode and re-encode byte-for-byte, including computed brush/patch arrays, sparse UV/string data, width transitions and normalized outputs from malformed legacy inputs. A valid normalized output cannot reveal or repair corruption already introduced by the converter; the captured truncated source inputs are separately rejected.
|
| 80 |
+
- Five integration tests cover the entire output corpus, asymmetric vectors/property/UV/patch semantics, **11** captured malformed/ambiguous inputs, automatic cross-version detection, explicit lossy-downgrade refusal and computed property-free migration matching both converters' bytes, and mixed nonempty brush/patch worlds assembled independently from captured record bytes.
|
| 81 |
+
- **24** synthetic unit tests cover headers, every proper prefix of rich documents, binary ordering/duplicates, limits, string policies, computed consistency, all compact count boundaries through 65,536 (planes, names/UV slots, contours and patch dimensions), arithmetic overflow, finite extremes/non-finites, short/interrupted I/O, partial I/O errors, validation before writing, reserved bits and normalization of valid nonminimal widths. A deterministic 5,000-mutation test checks rejection or stable canonical round trips under tight limits; this is a smoke test, not coverage-guided fuzzing.
|
| 82 |
+
- One compiled API doctest exercises an empty latest world.
|
| 83 |
+
|
| 84 |
+
The `checks.<system>.rust` Nix derivation runs formatting, Clippy, 24 unit tests and the doctest from an asset-free source fileset. Full integration tests run separately against hydrated, verified captures. The setup derivation still runs the 52 Python regressions and code-tree storage checks. Only macOS ARM64 execution is validated in this stage; Linux/Intel execution, native editor interoperability, real user documents, long-running fuzzing, hostile-stream timeouts, atomic saves and the remaining text/MAP/VMF/OBJ codecs stay open. The declared Rust minimum is 1.85; the measured pinned toolchain is 1.99.0, not a separate minimum-version validation.
|
docs/TECHNICAL_DIRECTION.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# Proposed technical direction
|
| 2 |
|
| 3 |
-
Storage, environment, hydration, and extraction are implemented; see [SETUP.md](SETUP.md). Rust/rendering/conversion boundaries below remain proposed and must be validated through the [TODO phases](TODO.md).
|
| 4 |
|
| 5 |
## Storage and repository boundaries
|
| 6 |
|
|
@@ -15,7 +15,7 @@ nanobrush/ # remote hf: git@hf.co:nmarshall/nanobrush
|
|
| 15 |
scripts/ # setup, storage, hydration, extraction, checks
|
| 16 |
tests/ # ordinary Git test source
|
| 17 |
docs/ # ordinary Git authored findings/specifications
|
| 18 |
-
crates/
|
| 19 |
assets/references/ # every file is Xet-backed, including small text
|
| 20 |
archives/ # unchanged ZIPs
|
| 21 |
provenance/collection/ # original reports/manifest/checksum files
|
|
@@ -43,7 +43,7 @@ Provide a lightweight preservation/research shell first. Add the Rust workspace,
|
|
| 43 |
|
| 44 |
## Planned commands and responsibilities
|
| 45 |
|
| 46 |
-
Setup/storage/hydration/reference commands are implemented. Conversion, oracle, replay, and packaging commands remain proposed interfaces.
|
| 47 |
|
| 48 |
| Command | Inputs | Output and acceptance contract |
|
| 49 |
| --- | --- | --- |
|
|
|
|
| 1 |
# Proposed technical direction
|
| 2 |
|
| 3 |
+
Storage, environment, hydration, and extraction are implemented; see [SETUP.md](SETUP.md). The dependency-free `mb3-dat` crate now implements the first native format slice ([compatibility profile](RUST_DAT.md)); the broader Rust/rendering/conversion boundaries below remain proposed and must be validated through the [TODO phases](TODO.md).
|
| 4 |
|
| 5 |
## Storage and repository boundaries
|
| 6 |
|
|
|
|
| 15 |
scripts/ # setup, storage, hydration, extraction, checks
|
| 16 |
tests/ # ordinary Git test source
|
| 17 |
docs/ # ordinary Git authored findings/specifications
|
| 18 |
+
crates/mb3-dat/ # implemented native DAT library and tests
|
| 19 |
assets/references/ # every file is Xet-backed, including small text
|
| 20 |
archives/ # unchanged ZIPs
|
| 21 |
provenance/collection/ # original reports/manifest/checksum files
|
|
|
|
| 43 |
|
| 44 |
## Planned commands and responsibilities
|
| 45 |
|
| 46 |
+
Setup/storage/hydration/reference commands and a converter-only Mono oracle are implemented. The first Rust DAT library is tested through Cargo; a production conversion CLI remains future work. Conversion, oracle, replay, and packaging commands remain proposed interfaces.
|
| 47 |
|
| 48 |
| Command | Inputs | Output and acceptance contract |
|
| 49 |
| --- | --- | --- |
|
docs/TODO.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# Exhaustive work list
|
| 2 |
|
| 3 |
-
Baseline: 2026-10-21. The setup and extraction implementation is complete; checked items are verified in [SETUP.md](SETUP.md). The single-repository layout supersedes the initial separate-dataset proposal. Findings F01–
|
| 4 |
|
| 5 |
Each task ID is stable. Split tasks into child IDs as evidence grows. Record dependencies, output documents/code, validation evidence, and remaining limitations when checking a task off. New discoveries extend this list. P0–P2 establish preservation; P3–P9 establish behavioral contracts; P10–P16 implement and prove the rewrite. P10's GPU spike should happen early alongside source research, before renderer architecture is locked.
|
| 6 |
|
|
@@ -144,14 +144,16 @@ Each task ID is stable. Split tasks into child IDs as evidence grows. Record dep
|
|
| 144 |
- [ ] P6.12 Measure converter failure cases: empty/missing files, invalid syntax/types, malformed geometry, permission failures, long/non-ASCII paths, output overwrite, and interrupted operation.
|
| 145 |
- [ ] P6.13 Define a loss-report schema and compatibility profile; preserve originals and unknown data where feasible, and never silently overwrite a source file after lossy conversion.
|
| 146 |
- [ ] P6.14 Choose/version the canonical native format, schema migrations, stable IDs, precision, unknown-field policy, and atomic save strategy. Avoid serializing transient Bevy entities as the only durable model.
|
| 147 |
-
- [ ] P6.15 Implement safe Rust parsers/writers and native `inspect/validate/convert` CLI; fuzz parsers, bound allocation/recursion, and test all supported round trips.
|
| 148 |
- [ ] P6.16 Verify output interoperability with independent consumers where available, alongside reference-compatible results; separate intentional improvements from differences that indicate bugs.
|
| 149 |
|
| 150 |
- [x] P6.17 Document the initial grammar/writer format map and source discrepancies, keeping MB3 DAT, IXModel and plugin artifacts distinct (F15–F20).
|
| 151 |
|
| 152 |
- [x] P6.18 Establish a focused two-binary conversion matrix: DAT/TXT properties, computed brushes/patches, width transitions, three brush MAP/VMF round trips, Q3 patch losses, OBJ output and CLI/log/corruption probes; document F26–F28 and 101 captures (E02).
|
| 153 |
-
- [ ] P6.19 Validate recovered DAT structures against native-editor saves/loads and real documents
|
| 154 |
-
- [ ] P6.20 Extend converter/codec coverage: 65,535/65,536 plane/property boundaries, UV width transitions, malformed patch lengths/dimensions, overflow/negative counts, non-finite/extreme floats, embedded NUL/escape edges, duplicate DAT names, Unicode/long paths, log-write failure, interrupted/partial output, independent consumers and Windows/Linux comparison. Fuzz bounded native codecs, not the legacy unbounded string reader.
|
|
|
|
|
|
|
| 155 |
|
| 156 |
## P7 — Input, cameras, grids, views, and editing workflows
|
| 157 |
|
|
@@ -312,4 +314,4 @@ Each task ID is stable. Split tasks into child IDs as evidence grows. Record dep
|
|
| 312 |
| M6 Full reconstruction | All required P12/P13 modules and workflows pass parity and GPU checks |
|
| 313 |
| M7 Distributable release | P14–P16 regeneration, packaging, documentation, clean-machine acceptance |
|
| 314 |
|
| 315 |
-
Next work after E02: validate the
|
|
|
|
| 1 |
# Exhaustive work list
|
| 2 |
|
| 3 |
+
Baseline: 2026-10-21. The setup and extraction implementation is complete; checked items are verified in [SETUP.md](SETUP.md). The single-repository layout supersedes the initial separate-dataset proposal. Findings F01–F29 are in [FINDINGS.md](FINDINGS.md). The [investigation method](REVERSE_ENGINEERING.md) defines evidence requirements; [technical direction](TECHNICAL_DIRECTION.md) describes proposed boundaries.
|
| 4 |
|
| 5 |
Each task ID is stable. Split tasks into child IDs as evidence grows. Record dependencies, output documents/code, validation evidence, and remaining limitations when checking a task off. New discoveries extend this list. P0–P2 establish preservation; P3–P9 establish behavioral contracts; P10–P16 implement and prove the rewrite. P10's GPU spike should happen early alongside source research, before renderer architecture is locked.
|
| 6 |
|
|
|
|
| 144 |
- [ ] P6.12 Measure converter failure cases: empty/missing files, invalid syntax/types, malformed geometry, permission failures, long/non-ASCII paths, output overwrite, and interrupted operation.
|
| 145 |
- [ ] P6.13 Define a loss-report schema and compatibility profile; preserve originals and unknown data where feasible, and never silently overwrite a source file after lossy conversion.
|
| 146 |
- [ ] P6.14 Choose/version the canonical native format, schema migrations, stable IDs, precision, unknown-field policy, and atomic save strategy. Avoid serializing transient Bevy entities as the only durable model.
|
| 147 |
+
- [ ] P6.15 Implement safe Rust parsers/writers and native `inspect/validate/convert` CLI; fuzz parsers, bound allocation/recursion, and test all supported round trips. Both DAT codecs and focused fixture/boundary tests are implemented in P6.21; other codecs, the CLI and coverage-guided fuzzing remain open.
|
| 148 |
- [ ] P6.16 Verify output interoperability with independent consumers where available, alongside reference-compatible results; separate intentional improvements from differences that indicate bugs.
|
| 149 |
|
| 150 |
- [x] P6.17 Document the initial grammar/writer format map and source discrepancies, keeping MB3 DAT, IXModel and plugin artifacts distinct (F15–F20).
|
| 151 |
|
| 152 |
- [x] P6.18 Establish a focused two-binary conversion matrix: DAT/TXT properties, computed brushes/patches, width transitions, three brush MAP/VMF round trips, Q3 patch losses, OBJ output and CLI/log/corruption probes; document F26–F28 and 101 captures (E02).
|
| 153 |
+
- [ ] P6.19 Validate recovered DAT structures against native-editor saves/loads and real documents. The bounded Rust readers/writers, E02 byte/semantic round trips and absent-versus-empty preservation are complete in P6.21; editor/real-document evidence remains open.
|
| 154 |
+
- [ ] P6.20 Extend converter/codec coverage: 65,535/65,536 plane/property boundaries, UV width transitions, malformed patch lengths/dimensions, overflow/negative counts, non-finite/extreme floats, embedded NUL/escape edges, duplicate DAT names, Unicode/long paths, log-write failure, interrupted/partial output, independent consumers and Windows/Linux comparison. Rust synthetic tests now cover 65,535/65,536 plane/property/UV widths, slot sentinels, malformed array consistency, overflow, finite extremes, non-finites and decoder rejection of duplicate slots/reserved bits. Remaining legacy measurements, embedded-NUL/escape edges, platform comparisons and coverage-guided fuzzing remain open. Fuzz bounded native codecs, not the legacy unbounded string reader.
|
| 155 |
+
|
| 156 |
+
- [x] P6.21 Implement dependency-free safe Rust DAT readers/writers for both versions, bounded streaming I/O and allocations, explicit compatibility policy, checked computed arrays and non-lossy version selection. All 58 E02 DAT outputs round-trip byte-for-byte; 24 unit tests, five integration tests and one doctest validate boundaries and rejection cases ([RUST_DAT.md](RUST_DAT.md), E03/F29).
|
| 157 |
|
| 158 |
## P7 — Input, cameras, grids, views, and editing workflows
|
| 159 |
|
|
|
|
| 314 |
| M6 Full reconstruction | All required P12/P13 modules and workflows pass parity and GPU checks |
|
| 315 |
| M7 Distributable release | P14–P16 regeneration, packaging, documentation, clean-machine acceptance |
|
| 316 |
|
| 317 |
+
Next work after E02: validate the Rust DAT codecs against native-editor output, extend fuzzing/platform coverage and implement the remaining codecs (P6.19/P6.20/P6.15); establish the editor/compiler runtime oracle (P3.05–P3.10), derive consumer/dynamic config and localization keys (P3.03), extend format semantics and loss reports (P6), and continue P2.11 resource/semantic version analysis. The synthetic P10 rendering spike remains independent. Linux extraction/runtime validation remains open in P2.12. Treat M2 and M3 as separate gates; source investigation can proceed while GPU work is unresolved.
|
docs/experiments/rust-dat-2026-10-21.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# E03 — Safe Rust DAT codecs
|
| 2 |
+
|
| 3 |
+
Date: 2026-10-21. Scope: implement the two E02 converter DAT layouts as an independent Rust reader/writer, validate fixture parity and deliberate rejection policies, and retain broader editor/format work as open tasks. See [F29](../RUST_DAT.md) for the public API, complete compatibility matrix and resource accounting.
|
| 4 |
+
|
| 5 |
+
## Inputs and identity
|
| 6 |
+
|
| 7 |
+
No assets were changed or regenerated. The 58 captured converter DAT outputs and selected source inputs come from E02's existing Xet tree. The full E02 `probes.json` SHA-256 is `d69e2a1e1f61af71fa345e2cab3063b35ed4a58979293d89aec0aec5e948c11d`; it pins per-file identities and converter binary hashes. `analyze-converter check` reverified that manifest, original binary identities and semantic assertions before Rust fixture tests.
|
| 8 |
+
|
| 9 |
+
The implementation is the dependency-free `mb3-dat` crate in the committed Cargo workspace. Original executables/decompiled code are not Rust dependencies. No original executable was executed by E03. Code and tests are ordinary Git; input fixtures stay in their existing Xet locations. No generated binary fixture bytes were embedded in Rust source; synthetic tests construct small fields or boundary records programmatically.
|
| 10 |
+
|
| 11 |
+
## Commands and observed results
|
| 12 |
+
|
| 13 |
+
Host: macOS ARM64, pinned Nix Rust/Cargo 1.99.0. Tests run entirely offline after entering the development environment:
|
| 14 |
+
|
| 15 |
+
```sh
|
| 16 |
+
cargo fmt --all --check
|
| 17 |
+
cargo clippy --workspace --all-targets --locked --offline -- -D warnings
|
| 18 |
+
analyze-converter check
|
| 19 |
+
cargo test --workspace --locked --offline
|
| 20 |
+
cargo test --workspace --release --locked --offline
|
| 21 |
+
test-setup
|
| 22 |
+
nix build .#checks.aarch64-darwin.rust .#checks.aarch64-darwin.setup --no-link
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
- Debug and release each pass **24 Rust unit tests, five fixture integration tests and one doctest**. All **58** converter output files re-encode byte-for-byte. Eleven malformed/ambiguous captured inputs are rejected; two captured cross-version inputs are accepted through version detection. Property-free computed migration matches the opposite converter's exact output; mixed nonempty brush/patch worlds match independently composed captured record bytes; a downgrade containing properties fails explicitly.
|
| 26 |
+
- Synthetic tests cover compact count/index widths through 65,536, shared contours, patch dimensions, properties/UVs, every prefix truncation of both rich documents, count/grid overflow, ASCII/NUL policies, finite bit preservation/non-finites, resource accounting, short/interrupted I/O, write failures and no destination writes on validation failure. Five thousand deterministic mutations under tight budgets produce either an error or a stable canonical round trip, with no panic in these runs.
|
| 27 |
+
- Clippy and formatting pass. The separate asset-free Rust Nix derivation runs Clippy/formatting, 24 unit tests and one doctest. The setup Nix derivation passes all **52 Python regressions** and source-tree storage checks.
|
| 28 |
+
|
| 29 |
+
The fixture suite fails when assets are missing; `cargo test --workspace --lib --locked --offline` and `cargo test --workspace --doc --locked --offline` are the intentional asset-free subset. Compile-only Clippy checks all targets without opening fixture files.
|
| 30 |
+
|
| 31 |
+
## Interpretation and limits
|
| 32 |
+
|
| 33 |
+
This proves serializer compatibility for the retained converter corpus, including exact scalar/UV ordering and old/new hierarchy differences. It does not validate native-editor save/load, convex geometry, computed patch evaluation, all legacy malformed inputs, every possible float pattern or every document size. In particular, the old signed-16 writer repair is covered by Rust boundary tests and uses the recovered old reader's existing four-byte branch; direct large-count legacy execution remains open.
|
| 34 |
+
|
| 35 |
+
The reader deliberately rejects rather than reproduces stale-buffer reads, unterminated-string growth, duplicate slots, high-bit string replacement, reserved flags/selector 3, non-finite numbers and trailing data. It preserves unused property names and original index meaning instead of the converter CLI's usage optimization. These are the fixed compatibility policy in F29, not unexplained regressions.
|
| 36 |
+
|
| 37 |
+
The four declared Nix systems are evaluation targets; execution in this stage is macOS ARM64 only. Real documents, Linux/Intel execution, minimum-toolchain validation, coverage-guided fuzzing, independent consumers, atomic file workflows and the remaining codecs/CLI remain in P6.15/P6.19/P6.20. P6.21 records the completed DAT implementation without closing those broader tasks.
|
docs/parity.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# Reference coverage ledger
|
| 2 |
|
| 3 |
-
Initial source coverage, 2026-10-21. Owner for this ledger and the external dependencies listed in [DEPENDENCIES.md](DEPENDENCIES.md): **Nanobrush reference research**. E01 establishes source coverage; E02 adds converter-only runtime observations.
|
| 4 |
|
| 5 |
Every one of the 560 regular-member occurrences has its own path/hash/category/coverage/owner/follow-up entry in [source-map.json](../assets/analysis/source-map.json). Direct include candidates, qualified calls, interrupt wrappers, tool registrations and literal-resource candidates remain attached to their source file. The human documents summarize behavior without copying the raw reference tree into ordinary Git.
|
| 6 |
|
|
@@ -37,3 +37,7 @@ Keep the broad parent TODO tasks open until their promised behavioral evidence e
|
|
| 37 |
## Converter coverage extension (E02)
|
| 38 |
|
| 39 |
[static.json](../assets/analysis/converter/static.json) covers the 183/193 managed methods in both distinct converter binaries with RVAs and hashes; the four 2015 members are byte-identical. [probes.json](../assets/analysis/converter/probes.json) indexes 101 controlled cases and every captured payload. DAT hierarchy is experimentally specified on this corpus; format edge cases and editor cross-validation remain P6.19/P6.20, and external consumers remain P6.16. Broad parent tasks stay open.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# Reference coverage ledger
|
| 2 |
|
| 3 |
+
Initial source coverage, 2026-10-21. Owner for this ledger and the external dependencies listed in [DEPENDENCIES.md](DEPENDENCIES.md): **Nanobrush reference research**. E01 establishes source coverage; E02 adds converter-only runtime observations. Rust DAT parity is scoped to the F29 fixtures below; no editor parity is claimed.
|
| 4 |
|
| 5 |
Every one of the 560 regular-member occurrences has its own path/hash/category/coverage/owner/follow-up entry in [source-map.json](../assets/analysis/source-map.json). Direct include candidates, qualified calls, interrupt wrappers, tool registrations and literal-resource candidates remain attached to their source file. The human documents summarize behavior without copying the raw reference tree into ordinary Git.
|
| 6 |
|
|
|
|
| 37 |
## Converter coverage extension (E02)
|
| 38 |
|
| 39 |
[static.json](../assets/analysis/converter/static.json) covers the 183/193 managed methods in both distinct converter binaries with RVAs and hashes; the four 2015 members are byte-identical. [probes.json](../assets/analysis/converter/probes.json) indexes 101 controlled cases and every captured payload. DAT hierarchy is experimentally specified on this corpus; format edge cases and editor cross-validation remain P6.19/P6.20, and external consumers remain P6.16. Broad parent tasks stay open.
|
| 40 |
+
|
| 41 |
+
## Rust DAT implementation (F29)
|
| 42 |
+
|
| 43 |
+
`mb3-dat` implements both converter DAT layouts. The five Rust integration tests cover all 58 captured DAT outputs with exact bytes, selected semantic fields, captured malformed-input rejections and version migration. Twenty-four unit tests and one doctest add boundary/resource/I/O checks and 5,000 deterministic mutations. See [the compatibility matrix](RUST_DAT.md) for deliberate strictness and the old signed-16 writer repair. Native-editor conformance, real-document coverage, independent consumer testing and remaining codecs stay open.
|
docs/risks.md
CHANGED
|
@@ -17,5 +17,6 @@ Opened 2026-10-21. Owner: Nanobrush reference research. This register tracks con
|
|
| 17 |
| R11 | Mono converter oracle available (E02); editor/compiler and Windows/Linux unavailable | Converter observations cannot establish editor/platform parity | P3.05–P3.12, P6.20 |
|
| 18 |
| R12 | Solari/backend/platform qualification still open | Platform goal depends on measured hardware support | Synthetic P10 spike independent of these references |
|
| 19 |
| R13 | Static index deliberately lacks full IXPL typing/control-flow | Lexical edge/registration counts can overstate live behavior | Preserve uncertainty; refine analysis and compare actual traces |
|
| 20 |
-
| R14 | Legacy DAT short-read reuse and unbounded string EOF loop (F28) | Corruption, hangs/allocation growth on malformed data |
|
| 21 |
| R15 | DAT ASCII loss, BOM-less UTF-8 drift, property normalization and silent MAP/Q3/OBJ losses (F26/F27) | Successful conversions can alter meaningful content | Explicit encoding and loss reports, P6.01/P6.13 |
|
|
|
|
|
|
| 17 |
| R11 | Mono converter oracle available (E02); editor/compiler and Windows/Linux unavailable | Converter observations cannot establish editor/platform parity | P3.05–P3.12, P6.20 |
|
| 18 |
| R12 | Solari/backend/platform qualification still open | Platform goal depends on measured hardware support | Synthetic P10 spike independent of these references |
|
| 19 |
| R13 | Static index deliberately lacks full IXPL typing/control-flow | Lexical edge/registration counts can overstate live behavior | Preserve uncertainty; refine analysis and compare actual traces |
|
| 20 |
+
| R14 | Legacy DAT short-read reuse and unbounded string EOF loop (F28) | Corruption, hangs/allocation growth on malformed data | Strict bounded Rust DAT parser implemented (F29); extend fuzzing/editor validation in P6.19/P6.20 |
|
| 21 |
| R15 | DAT ASCII loss, BOM-less UTF-8 drift, property normalization and silent MAP/Q3/OBJ losses (F26/F27) | Successful conversions can alter meaningful content | Explicit encoding and loss reports, P6.01/P6.13 |
|
| 22 |
+
| R16 | Rust DAT policy deliberately rejects ambiguous/unknown data and avoids lossy table normalization (F29) | Some converter-accepted files need explicit diagnosis/migration | Preserve originals; validate native-editor/real documents, P6.19/P6.20 |
|
docs/validation.md
CHANGED
|
@@ -19,3 +19,9 @@ The current results establish static mapping only. No runtime API contract, form
|
|
| 19 |
[Experiment](experiments/converter-2026-10-21.md): both original binary identities and all generated decompilation/metadata/capture hashes verified. Original executables ran unchanged under Mono 6.14.1 on macOS ARM64: 43 cases for 1.4.0, 58 for 1.5.0 (101 total), with expected exits and semantic/log assertions passing. Fifteen authored network-free tests bring the suite to 52, including every prefix truncation of one representative DAT file. Runtime failures intentionally expected by cases are retained as evidence, including successful corrupt reads. Linux/Windows execution, native editor saves/loads, independent consumer interoperability and Rust parity are still open.
|
| 20 |
|
| 21 |
E02 final checks: all 52 tests passed on the host and in the Apple Silicon code-only Nix derivation; converter evidence/semantic checks and E01 source-map reproducibility passed; all five original archives and all 560 extracted files passed preservation verification. Staged storage policy and hydrated asset hashes were checked before committing.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
[Experiment](experiments/converter-2026-10-21.md): both original binary identities and all generated decompilation/metadata/capture hashes verified. Original executables ran unchanged under Mono 6.14.1 on macOS ARM64: 43 cases for 1.4.0, 58 for 1.5.0 (101 total), with expected exits and semantic/log assertions passing. Fifteen authored network-free tests bring the suite to 52, including every prefix truncation of one representative DAT file. Runtime failures intentionally expected by cases are retained as evidence, including successful corrupt reads. Linux/Windows execution, native editor saves/loads, independent consumer interoperability and Rust parity are still open.
|
| 20 |
|
| 21 |
E02 final checks: all 52 tests passed on the host and in the Apple Silicon code-only Nix derivation; converter evidence/semantic checks and E01 source-map reproducibility passed; all five original archives and all 560 extracted files passed preservation verification. Staged storage policy and hydrated asset hashes were checked before committing.
|
| 22 |
+
|
| 23 |
+
## E03 Rust DAT codecs — 2026-10-21
|
| 24 |
+
|
| 25 |
+
The [Rust compatibility profile](RUST_DAT.md) and [experiment](experiments/rust-dat-2026-10-21.md) document the implementation and its limits. Debug and release each pass 24 unit tests, five integration tests and one doctest. All 58 captured converter DAT outputs round-trip exactly; 11 captured malformed/ambiguous inputs are rejected; cross-version autodetection and property-free migration are covered. Boundary tests include 65,535/65,536 counts/indices and 5,000 bounded deterministic mutations. Formatting and Clippy with warnings denied pass.
|
| 26 |
+
|
| 27 |
+
Both Apple Silicon Nix derivations built successfully: the asset-free Rust check runs 24 unit tests/one doctest and lint/format checks; the setup check retains all 52 passing Python tests. Full fixture tests ran separately against the SHA-256-verified E02 corpus. Source/asset storage boundaries are preserved; no fixture payloads moved into ordinary Git. Native-editor interoperability, other-platform execution and coverage-guided fuzzing remain open.
|
flake.nix
CHANGED
|
@@ -101,6 +101,9 @@
|
|
| 101 |
codeSource = lib.fileset.toSource {
|
| 102 |
root = ./.;
|
| 103 |
fileset = lib.fileset.unions [
|
|
|
|
|
|
|
|
|
|
| 104 |
./scripts
|
| 105 |
./tests
|
| 106 |
./config
|
|
@@ -131,6 +134,24 @@
|
|
| 131 |
python3 scripts/storage_policy.py check-tree ${codeSource}
|
| 132 |
touch $out
|
| 133 |
'';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
in
|
| 135 |
{
|
| 136 |
packages = scripts;
|
|
@@ -161,6 +182,7 @@
|
|
| 161 |
'';
|
| 162 |
};
|
| 163 |
checks.setup = setupCheck;
|
|
|
|
| 164 |
formatter = pkgs.nixfmt;
|
| 165 |
};
|
| 166 |
}
|
|
|
|
| 101 |
codeSource = lib.fileset.toSource {
|
| 102 |
root = ./.;
|
| 103 |
fileset = lib.fileset.unions [
|
| 104 |
+
./Cargo.toml
|
| 105 |
+
./Cargo.lock
|
| 106 |
+
./crates
|
| 107 |
./scripts
|
| 108 |
./tests
|
| 109 |
./config
|
|
|
|
| 134 |
python3 scripts/storage_policy.py check-tree ${codeSource}
|
| 135 |
touch $out
|
| 136 |
'';
|
| 137 |
+
rustCheck =
|
| 138 |
+
pkgs.runCommand "nanobrush-rust-checks"
|
| 139 |
+
{
|
| 140 |
+
nativeBuildInputs = [
|
| 141 |
+
config.rust.toolchain
|
| 142 |
+
pkgs.stdenv.cc
|
| 143 |
+
];
|
| 144 |
+
}
|
| 145 |
+
''
|
| 146 |
+
cp -R ${codeSource}/. .
|
| 147 |
+
chmod -R u+w .
|
| 148 |
+
export CARGO_HOME="$TMPDIR/nanobrush-cargo"
|
| 149 |
+
cargo fmt --all --check
|
| 150 |
+
cargo clippy --workspace --all-targets --locked --offline -- -D warnings
|
| 151 |
+
cargo test --workspace --lib --locked --offline
|
| 152 |
+
cargo test --workspace --doc --locked --offline
|
| 153 |
+
touch $out
|
| 154 |
+
'';
|
| 155 |
in
|
| 156 |
{
|
| 157 |
packages = scripts;
|
|
|
|
| 182 |
'';
|
| 183 |
};
|
| 184 |
checks.setup = setupCheck;
|
| 185 |
+
checks.rust = rustCheck;
|
| 186 |
formatter = pkgs.nixfmt;
|
| 187 |
};
|
| 188 |
}
|