formats: add txt conversion cli
Browse files- Cargo.lock +15 -0
- Cargo.toml +1 -1
- README.md +6 -0
- crates/mb3-txt/Cargo.toml +13 -0
- crates/mb3-txt/src/lib.rs +197 -0
- crates/mb3-txt/src/parser.rs +468 -0
- crates/mb3-txt/src/tests.rs +317 -0
- crates/mb3-txt/src/writer.rs +244 -0
- crates/mb3-txt/tests/converter.rs +166 -0
- crates/nanobrush-cli/Cargo.toml +18 -0
- crates/nanobrush-cli/src/lib.rs +363 -0
- crates/nanobrush-cli/src/main.rs +9 -0
- crates/nanobrush-cli/src/tests.rs +198 -0
- crates/nanobrush-cli/tests/fixtures.rs +102 -0
- docs/CLI.md +56 -0
- docs/FINDINGS.md +4 -0
- docs/FORMATS.md +4 -0
- docs/README.md +3 -1
- docs/RUST_DAT.md +1 -1
- docs/RUST_TXT.md +55 -0
- docs/TECHNICAL_DIRECTION.md +4 -4
- docs/TODO.md +7 -5
- docs/experiments/txt-cli-2026-10-21.md +33 -0
- docs/parity.md +4 -0
- docs/risks.md +2 -0
- docs/validation.md +6 -0
- flake.nix +20 -1
Cargo.lock
CHANGED
|
@@ -5,3 +5,18 @@ version = 4
|
|
| 5 |
[[package]]
|
| 6 |
name = "mb3-dat"
|
| 7 |
version = "0.1.0"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
[[package]]
|
| 6 |
name = "mb3-dat"
|
| 7 |
version = "0.1.0"
|
| 8 |
+
|
| 9 |
+
[[package]]
|
| 10 |
+
name = "mb3-txt"
|
| 11 |
+
version = "0.1.0"
|
| 12 |
+
dependencies = [
|
| 13 |
+
"mb3-dat",
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
[[package]]
|
| 17 |
+
name = "nanobrush-cli"
|
| 18 |
+
version = "0.1.0"
|
| 19 |
+
dependencies = [
|
| 20 |
+
"mb3-dat",
|
| 21 |
+
"mb3-txt",
|
| 22 |
+
]
|
Cargo.toml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
[workspace]
|
| 2 |
-
members = ["crates/mb3-dat"]
|
| 3 |
resolver = "3"
|
| 4 |
|
| 5 |
[workspace.package]
|
|
|
|
| 1 |
[workspace]
|
| 2 |
+
members = ["crates/mb3-dat", "crates/mb3-txt", "crates/nanobrush-cli"]
|
| 3 |
resolver = "3"
|
| 4 |
|
| 5 |
[workspace.package]
|
README.md
CHANGED
|
@@ -70,3 +70,9 @@ See [setup validation](docs/SETUP.md) for the implementation, checks, and remain
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 73 |
+
|
| 74 |
+
## Native TXT/DAT CLI
|
| 75 |
+
|
| 76 |
+
`nix run .#nanobrush -- inspect FILE` prints a JSON summary; `validate FILE` checks structure. Convert with `convert INPUT OUTPUT --to txt`, `--to dat-201501280`, or `--to dat-201512200`. Inside the dev shell, use `cargo run -p nanobrush-cli -- ...`. Existing outputs require `--force`; source aliases and output symlinks are refused.
|
| 77 |
+
|
| 78 |
+
[TXT compatibility](docs/RUST_TXT.md) documents strict UTF-8 input, BOM output, finite numbers and computed-data consistency. [CLI usage and publication](docs/CLI.md) covers limits, exit codes and atomic saves. Full Cargo tests require hydrated converter fixtures; Nix checks and the native package build exclude assets.
|
crates/mb3-txt/Cargo.toml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[package]
|
| 2 |
+
name = "mb3-txt"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Bounded UTF-8 Microbrush 3 TXT readers and writers"
|
| 5 |
+
edition.workspace = true
|
| 6 |
+
rust-version.workspace = true
|
| 7 |
+
publish = false
|
| 8 |
+
|
| 9 |
+
[dependencies]
|
| 10 |
+
mb3-dat = { path = "../mb3-dat" }
|
| 11 |
+
|
| 12 |
+
[lints]
|
| 13 |
+
workspace = true
|
crates/mb3-txt/src/lib.rs
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Bounded MB3 TXT serialization using the shared `mb3_dat::World` model.
|
| 2 |
+
//! Input is UTF-8 with an optional BOM. Output is canonical UTF-8 with a BOM.
|
| 3 |
+
//! No geometry is evaluated, property tables optimized, or data silently dropped.
|
| 4 |
+
//!
|
| 5 |
+
//! ```
|
| 6 |
+
//! use mb3_dat::{Limits, World};
|
| 7 |
+
//! let world = World::default();
|
| 8 |
+
//! let bytes = mb3_txt::encode(&world, &Limits::default())?;
|
| 9 |
+
//! assert_eq!(mb3_txt::decode(&bytes, &Limits::default())?, world);
|
| 10 |
+
//! # Ok::<(), mb3_txt::Error>(())
|
| 11 |
+
//! ```
|
| 12 |
+
mod parser;
|
| 13 |
+
mod writer;
|
| 14 |
+
use mb3_dat::Limits;
|
| 15 |
+
pub use parser::decode;
|
| 16 |
+
use std::fmt;
|
| 17 |
+
use std::io::{self, Read, Write};
|
| 18 |
+
use std::mem::size_of;
|
| 19 |
+
pub use writer::encode;
|
| 20 |
+
|
| 21 |
+
#[derive(Debug)]
|
| 22 |
+
#[non_exhaustive]
|
| 23 |
+
pub enum ErrorKind {
|
| 24 |
+
Io(io::Error),
|
| 25 |
+
InvalidUtf8,
|
| 26 |
+
Syntax(&'static str),
|
| 27 |
+
InvalidNumber,
|
| 28 |
+
NonFinite,
|
| 29 |
+
InvalidEscape,
|
| 30 |
+
EmbeddedNul,
|
| 31 |
+
UnterminatedString,
|
| 32 |
+
UnterminatedComment,
|
| 33 |
+
LimitExceeded(&'static str),
|
| 34 |
+
Allocation,
|
| 35 |
+
CountOverflow,
|
| 36 |
+
DuplicateName,
|
| 37 |
+
DuplicateProperty(u32),
|
| 38 |
+
PropertyIndex(u32),
|
| 39 |
+
UnsortedProperties,
|
| 40 |
+
InconsistentComputedData,
|
| 41 |
+
InvalidPatchLength,
|
| 42 |
+
}
|
| 43 |
+
#[derive(Debug)]
|
| 44 |
+
pub struct Error {
|
| 45 |
+
pub offset: usize,
|
| 46 |
+
pub kind: ErrorKind,
|
| 47 |
+
}
|
| 48 |
+
impl fmt::Display for Error {
|
| 49 |
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
| 50 |
+
write!(f, "MB3 TXT at byte {}: {:?}", self.offset, self.kind)
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
impl std::error::Error for Error {
|
| 54 |
+
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
| 55 |
+
match &self.kind {
|
| 56 |
+
ErrorKind::Io(e) => Some(e),
|
| 57 |
+
_ => None,
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
type Result<T> = std::result::Result<T, Error>;
|
| 62 |
+
fn err(offset: usize, kind: ErrorKind) -> Error {
|
| 63 |
+
Error { offset, kind }
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
#[derive(Default)]
|
| 67 |
+
struct Budget {
|
| 68 |
+
items: usize,
|
| 69 |
+
owned: usize,
|
| 70 |
+
}
|
| 71 |
+
impl Budget {
|
| 72 |
+
fn items(&mut self, n: usize, limits: &Limits, at: usize) -> Result<()> {
|
| 73 |
+
self.items = self
|
| 74 |
+
.items
|
| 75 |
+
.checked_add(n)
|
| 76 |
+
.ok_or_else(|| err(at, ErrorKind::CountOverflow))?;
|
| 77 |
+
if self.items > limits.max_items {
|
| 78 |
+
return Err(err(at, ErrorKind::LimitExceeded("aggregate items")));
|
| 79 |
+
}
|
| 80 |
+
Ok(())
|
| 81 |
+
}
|
| 82 |
+
fn bytes(&mut self, n: usize, limits: &Limits, at: usize) -> Result<()> {
|
| 83 |
+
self.owned = self
|
| 84 |
+
.owned
|
| 85 |
+
.checked_add(n)
|
| 86 |
+
.ok_or_else(|| err(at, ErrorKind::CountOverflow))?;
|
| 87 |
+
if self.owned > limits.max_owned_bytes {
|
| 88 |
+
return Err(err(at, ErrorKind::LimitExceeded("owned storage")));
|
| 89 |
+
}
|
| 90 |
+
Ok(())
|
| 91 |
+
}
|
| 92 |
+
fn push<T>(&mut self, out: &mut Vec<T>, value: T, limits: &Limits, at: usize) -> Result<()> {
|
| 93 |
+
self.items(1, limits, at)?;
|
| 94 |
+
if out.len() == out.capacity() {
|
| 95 |
+
let cap = out
|
| 96 |
+
.capacity()
|
| 97 |
+
.saturating_mul(2)
|
| 98 |
+
.max(1)
|
| 99 |
+
.min(limits.max_items);
|
| 100 |
+
let bytes = (cap - out.capacity())
|
| 101 |
+
.checked_mul(size_of::<T>())
|
| 102 |
+
.ok_or_else(|| err(at, ErrorKind::CountOverflow))?;
|
| 103 |
+
self.bytes(bytes, limits, at)?;
|
| 104 |
+
out.try_reserve_exact(cap - out.len())
|
| 105 |
+
.map_err(|_| err(at, ErrorKind::Allocation))?;
|
| 106 |
+
}
|
| 107 |
+
out.push(value);
|
| 108 |
+
Ok(())
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
fn unique_names(names: &[String], budget: &mut Budget, limits: &Limits, at: usize) -> Result<()> {
|
| 113 |
+
budget.bytes(
|
| 114 |
+
names
|
| 115 |
+
.len()
|
| 116 |
+
.checked_mul(size_of::<usize>())
|
| 117 |
+
.ok_or_else(|| err(at, ErrorKind::CountOverflow))?,
|
| 118 |
+
limits,
|
| 119 |
+
at,
|
| 120 |
+
)?;
|
| 121 |
+
let mut indices = Vec::new();
|
| 122 |
+
indices
|
| 123 |
+
.try_reserve_exact(names.len())
|
| 124 |
+
.map_err(|_| err(at, ErrorKind::Allocation))?;
|
| 125 |
+
indices.extend(0..names.len());
|
| 126 |
+
indices.sort_unstable_by(|a, b| names[*a].cmp(&names[*b]));
|
| 127 |
+
if indices.windows(2).any(|p| names[p[0]] == names[p[1]]) {
|
| 128 |
+
return Err(err(at, ErrorKind::DuplicateName));
|
| 129 |
+
}
|
| 130 |
+
Ok(())
|
| 131 |
+
}
|
| 132 |
+
fn product(a: usize, b: usize, at: usize) -> Result<usize> {
|
| 133 |
+
a.checked_mul(b)
|
| 134 |
+
.ok_or_else(|| err(at, ErrorKind::CountOverflow))
|
| 135 |
+
}
|
| 136 |
+
fn grid(au: u32, av: u32, qu: u32, qv: u32, at: usize) -> Result<usize> {
|
| 137 |
+
let u = product(au as usize / 2, qu as usize, at)?
|
| 138 |
+
.checked_add(1)
|
| 139 |
+
.ok_or_else(|| err(at, ErrorKind::CountOverflow))?;
|
| 140 |
+
let v = product(av as usize / 2, qv as usize, at)?
|
| 141 |
+
.checked_add(1)
|
| 142 |
+
.ok_or_else(|| err(at, ErrorKind::CountOverflow))?;
|
| 143 |
+
product(u, v, at)
|
| 144 |
+
}
|
| 145 |
+
fn append(out: &mut Vec<u8>, bytes: &[u8], limit: usize) -> Result<()> {
|
| 146 |
+
let at = out.len();
|
| 147 |
+
let n = at
|
| 148 |
+
.checked_add(bytes.len())
|
| 149 |
+
.ok_or_else(|| err(at, ErrorKind::CountOverflow))?;
|
| 150 |
+
if n > limit {
|
| 151 |
+
return Err(err(at, ErrorKind::LimitExceeded("file bytes")));
|
| 152 |
+
}
|
| 153 |
+
if n > out.capacity() {
|
| 154 |
+
let cap = n.max(out.capacity().saturating_mul(2)).min(limit);
|
| 155 |
+
out.try_reserve_exact(cap - at)
|
| 156 |
+
.map_err(|_| err(at, ErrorKind::Allocation))?;
|
| 157 |
+
}
|
| 158 |
+
out.extend_from_slice(bytes);
|
| 159 |
+
Ok(())
|
| 160 |
+
}
|
| 161 |
+
/// Bounded buffering, then strict decoding; interrupted and short reads work.
|
| 162 |
+
pub fn read(mut source: impl Read, limits: &Limits) -> Result<mb3_dat::World> {
|
| 163 |
+
let mut data = Vec::new();
|
| 164 |
+
let mut chunk = [0; 8192];
|
| 165 |
+
loop {
|
| 166 |
+
let n = limits
|
| 167 |
+
.max_bytes
|
| 168 |
+
.saturating_sub(data.len())
|
| 169 |
+
.saturating_add(1)
|
| 170 |
+
.min(chunk.len());
|
| 171 |
+
let n = match source.read(&mut chunk[..n]) {
|
| 172 |
+
Ok(0) => break,
|
| 173 |
+
Ok(n) => n,
|
| 174 |
+
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
| 175 |
+
Err(e) => return Err(err(data.len(), ErrorKind::Io(e))),
|
| 176 |
+
};
|
| 177 |
+
append(&mut data, &chunk[..n], limits.max_bytes)?;
|
| 178 |
+
}
|
| 179 |
+
decode(&data, limits)
|
| 180 |
+
}
|
| 181 |
+
/// Validate/encode before touching the destination; does not flush or promise
|
| 182 |
+
/// filesystem atomicity. Later I/O failure can leave a completed prefix.
|
| 183 |
+
pub fn write(mut destination: impl Write, world: &mb3_dat::World, limits: &Limits) -> Result<()> {
|
| 184 |
+
let data = encode(world, limits)?;
|
| 185 |
+
let mut written = 0;
|
| 186 |
+
while written != data.len() {
|
| 187 |
+
match destination.write(&data[written..]) {
|
| 188 |
+
Ok(0) => return Err(err(written, ErrorKind::Io(io::ErrorKind::WriteZero.into()))),
|
| 189 |
+
Ok(n) => written += n,
|
| 190 |
+
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
| 191 |
+
Err(e) => return Err(err(written, ErrorKind::Io(e))),
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
Ok(())
|
| 195 |
+
}
|
| 196 |
+
#[cfg(test)]
|
| 197 |
+
mod tests;
|
crates/mb3-txt/src/parser.rs
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use super::*;
|
| 2 |
+
use mb3_dat::{Brush, Patch9, PatchComputed, Plane, Property, Uv, Vec3, World};
|
| 3 |
+
|
| 4 |
+
#[derive(Clone, Copy, PartialEq)]
|
| 5 |
+
enum Kind<'a> {
|
| 6 |
+
Word(&'a str),
|
| 7 |
+
Number(&'a str, bool),
|
| 8 |
+
Str(&'a str),
|
| 9 |
+
Symbol(u8),
|
| 10 |
+
End,
|
| 11 |
+
}
|
| 12 |
+
#[derive(Clone, Copy)]
|
| 13 |
+
struct Token<'a> {
|
| 14 |
+
kind: Kind<'a>,
|
| 15 |
+
at: usize,
|
| 16 |
+
}
|
| 17 |
+
struct Parser<'a> {
|
| 18 |
+
source: &'a str,
|
| 19 |
+
pos: usize,
|
| 20 |
+
look: Option<Token<'a>>,
|
| 21 |
+
limits: &'a Limits,
|
| 22 |
+
budget: Budget,
|
| 23 |
+
}
|
| 24 |
+
impl<'a> Parser<'a> {
|
| 25 |
+
fn lex(&mut self) -> Result<Token<'a>> {
|
| 26 |
+
let bytes = self.source.as_bytes();
|
| 27 |
+
loop {
|
| 28 |
+
while self.pos < bytes.len() && matches!(bytes[self.pos], b' ' | b'\t' | b'\r' | b'\n')
|
| 29 |
+
{
|
| 30 |
+
self.pos += 1;
|
| 31 |
+
}
|
| 32 |
+
if bytes.get(self.pos..self.pos.saturating_add(2)) == Some(b"//") {
|
| 33 |
+
self.pos += 2;
|
| 34 |
+
while self.pos < bytes.len() && !matches!(bytes[self.pos], b'\r' | b'\n') {
|
| 35 |
+
self.pos += 1;
|
| 36 |
+
}
|
| 37 |
+
} else if bytes.get(self.pos..self.pos.saturating_add(2)) == Some(b"/*") {
|
| 38 |
+
let at = self.pos;
|
| 39 |
+
self.pos += 2;
|
| 40 |
+
while self.pos + 1 < bytes.len() && &bytes[self.pos..self.pos + 2] != b"*/" {
|
| 41 |
+
self.pos += 1;
|
| 42 |
+
}
|
| 43 |
+
if self.pos + 1 >= bytes.len() {
|
| 44 |
+
return Err(err(at, ErrorKind::UnterminatedComment));
|
| 45 |
+
}
|
| 46 |
+
self.pos += 2;
|
| 47 |
+
} else {
|
| 48 |
+
break;
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
let at = self.pos;
|
| 52 |
+
let kind = match bytes.get(at).copied() {
|
| 53 |
+
None => Kind::End,
|
| 54 |
+
Some(b'(' | b')' | b'[' | b']' | b',') => {
|
| 55 |
+
self.pos += 1;
|
| 56 |
+
Kind::Symbol(bytes[at])
|
| 57 |
+
}
|
| 58 |
+
Some(b'"') => {
|
| 59 |
+
self.pos += 1;
|
| 60 |
+
let start = self.pos;
|
| 61 |
+
while self.pos < bytes.len() && bytes[self.pos] != b'"' {
|
| 62 |
+
match bytes[self.pos] {
|
| 63 |
+
0 => return Err(err(self.pos, ErrorKind::EmbeddedNul)),
|
| 64 |
+
b'\'' => {
|
| 65 |
+
return Err(err(self.pos, ErrorKind::Syntax("escape single quote")));
|
| 66 |
+
}
|
| 67 |
+
b'\\' => {
|
| 68 |
+
self.pos += 1;
|
| 69 |
+
if !matches!(
|
| 70 |
+
bytes.get(self.pos),
|
| 71 |
+
Some(b'r' | b'n' | b't' | b'\'' | b'"' | b'\\')
|
| 72 |
+
) {
|
| 73 |
+
return Err(err(self.pos, ErrorKind::InvalidEscape));
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
_ => (),
|
| 77 |
+
}
|
| 78 |
+
self.pos += 1;
|
| 79 |
+
}
|
| 80 |
+
if self.pos == bytes.len() {
|
| 81 |
+
return Err(err(at, ErrorKind::UnterminatedString));
|
| 82 |
+
}
|
| 83 |
+
let raw = &self.source[start..self.pos];
|
| 84 |
+
self.pos += 1;
|
| 85 |
+
Kind::Str(raw)
|
| 86 |
+
}
|
| 87 |
+
Some(b'a'..=b'z' | b'A'..=b'Z' | b'_' | b'$') => {
|
| 88 |
+
self.pos += 1;
|
| 89 |
+
while matches!(
|
| 90 |
+
bytes.get(self.pos),
|
| 91 |
+
Some(b'a'..=b'z' | b'A'..=b'Z' | b'_' | b'$' | b'0'..=b'9')
|
| 92 |
+
) {
|
| 93 |
+
self.pos += 1;
|
| 94 |
+
}
|
| 95 |
+
Kind::Word(&self.source[at..self.pos])
|
| 96 |
+
}
|
| 97 |
+
Some(b'+' | b'-' | b'.' | b'0'..=b'9') => {
|
| 98 |
+
if matches!(bytes[self.pos], b'+' | b'-') {
|
| 99 |
+
self.pos += 1;
|
| 100 |
+
}
|
| 101 |
+
let digits = self.pos;
|
| 102 |
+
self.digits(at)?;
|
| 103 |
+
let before = self.pos - digits;
|
| 104 |
+
let dot = bytes.get(self.pos) == Some(&b'.');
|
| 105 |
+
let mut after = 0;
|
| 106 |
+
if dot {
|
| 107 |
+
self.pos += 1;
|
| 108 |
+
let start = self.pos;
|
| 109 |
+
self.digits(at)?;
|
| 110 |
+
after = self.pos - start;
|
| 111 |
+
}
|
| 112 |
+
if before + after == 0 {
|
| 113 |
+
return Err(err(at, ErrorKind::InvalidNumber));
|
| 114 |
+
}
|
| 115 |
+
let exponent = matches!(bytes.get(self.pos), Some(b'e' | b'E'));
|
| 116 |
+
if exponent {
|
| 117 |
+
self.pos += 1;
|
| 118 |
+
if matches!(bytes.get(self.pos), Some(b'+' | b'-')) {
|
| 119 |
+
self.pos += 1;
|
| 120 |
+
}
|
| 121 |
+
let start = self.pos;
|
| 122 |
+
self.digits(at)?;
|
| 123 |
+
if start == self.pos {
|
| 124 |
+
return Err(err(at, ErrorKind::InvalidNumber));
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
if self.pos - at > 128 {
|
| 128 |
+
return Err(err(at, ErrorKind::LimitExceeded("numeric token bytes")));
|
| 129 |
+
}
|
| 130 |
+
if !dot && before > 1 && bytes[digits] == b'0' {
|
| 131 |
+
return Err(err(at, ErrorKind::InvalidNumber));
|
| 132 |
+
}
|
| 133 |
+
Kind::Number(&self.source[at..self.pos], !dot && !exponent)
|
| 134 |
+
}
|
| 135 |
+
_ => return Err(err(at, ErrorKind::Syntax("unexpected character"))),
|
| 136 |
+
};
|
| 137 |
+
Ok(Token { kind, at })
|
| 138 |
+
}
|
| 139 |
+
fn digits(&mut self, at: usize) -> Result<()> {
|
| 140 |
+
while matches!(self.source.as_bytes().get(self.pos), Some(b'0'..=b'9')) {
|
| 141 |
+
self.pos += 1;
|
| 142 |
+
if self.pos - at > 128 {
|
| 143 |
+
return Err(err(at, ErrorKind::LimitExceeded("numeric token bytes")));
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
Ok(())
|
| 147 |
+
}
|
| 148 |
+
fn peek(&mut self) -> Result<Token<'a>> {
|
| 149 |
+
if let Some(token) = self.look {
|
| 150 |
+
return Ok(token);
|
| 151 |
+
}
|
| 152 |
+
let token = self.lex()?;
|
| 153 |
+
self.look = Some(token);
|
| 154 |
+
Ok(token)
|
| 155 |
+
}
|
| 156 |
+
fn next(&mut self) -> Result<Token<'a>> {
|
| 157 |
+
let token = self.peek()?;
|
| 158 |
+
self.look = None;
|
| 159 |
+
Ok(token)
|
| 160 |
+
}
|
| 161 |
+
fn is(&mut self, kind: Kind<'a>) -> Result<bool> {
|
| 162 |
+
Ok(self.peek()?.kind == kind)
|
| 163 |
+
}
|
| 164 |
+
fn expect(&mut self, kind: Kind<'a>, message: &'static str) -> Result<()> {
|
| 165 |
+
let token = self.next()?;
|
| 166 |
+
if token.kind != kind {
|
| 167 |
+
return Err(err(token.at, ErrorKind::Syntax(message)));
|
| 168 |
+
}
|
| 169 |
+
Ok(())
|
| 170 |
+
}
|
| 171 |
+
fn symbol(&mut self, symbol: u8) -> Result<()> {
|
| 172 |
+
self.expect(Kind::Symbol(symbol), "expected punctuation")
|
| 173 |
+
}
|
| 174 |
+
fn word(&mut self, word: &'static str) -> Result<()> {
|
| 175 |
+
self.expect(Kind::Word(word), "expected keyword")
|
| 176 |
+
}
|
| 177 |
+
fn push<T>(&mut self, out: &mut Vec<T>, value: T) -> Result<()> {
|
| 178 |
+
self.budget.push(out, value, self.limits, self.pos)
|
| 179 |
+
}
|
| 180 |
+
fn string(&mut self) -> Result<String> {
|
| 181 |
+
let token = self.next()?;
|
| 182 |
+
let Kind::Str(raw) = token.kind else {
|
| 183 |
+
return Err(err(token.at, ErrorKind::Syntax("expected string")));
|
| 184 |
+
};
|
| 185 |
+
let mut out = String::new();
|
| 186 |
+
let mut chars = raw.chars();
|
| 187 |
+
while let Some(c) = chars.next() {
|
| 188 |
+
let c = if c == '\\' {
|
| 189 |
+
match chars.next() {
|
| 190 |
+
Some('r') => '\r',
|
| 191 |
+
Some('n') => '\n',
|
| 192 |
+
Some('t') => '\t',
|
| 193 |
+
Some('\'') => '\'',
|
| 194 |
+
Some('"') => '"',
|
| 195 |
+
Some('\\') => '\\',
|
| 196 |
+
_ => return Err(err(token.at, ErrorKind::InvalidEscape)),
|
| 197 |
+
}
|
| 198 |
+
} else {
|
| 199 |
+
c
|
| 200 |
+
};
|
| 201 |
+
let n = out
|
| 202 |
+
.len()
|
| 203 |
+
.checked_add(c.len_utf8())
|
| 204 |
+
.ok_or_else(|| err(token.at, ErrorKind::CountOverflow))?;
|
| 205 |
+
if n > self.limits.max_string_bytes {
|
| 206 |
+
return Err(err(token.at, ErrorKind::LimitExceeded("string bytes")));
|
| 207 |
+
}
|
| 208 |
+
if n > out.capacity() {
|
| 209 |
+
let cap = n
|
| 210 |
+
.max(out.capacity().saturating_mul(2))
|
| 211 |
+
.min(self.limits.max_string_bytes);
|
| 212 |
+
self.budget
|
| 213 |
+
.bytes(cap - out.capacity(), self.limits, token.at)?;
|
| 214 |
+
out.try_reserve_exact(cap - out.len())
|
| 215 |
+
.map_err(|_| err(token.at, ErrorKind::Allocation))?;
|
| 216 |
+
}
|
| 217 |
+
out.push(c);
|
| 218 |
+
}
|
| 219 |
+
Ok(out)
|
| 220 |
+
}
|
| 221 |
+
fn integer(&mut self) -> Result<u32> {
|
| 222 |
+
let token = self.next()?;
|
| 223 |
+
let Kind::Number(raw, true) = token.kind else {
|
| 224 |
+
return Err(err(
|
| 225 |
+
token.at,
|
| 226 |
+
ErrorKind::Syntax("expected nonnegative integer"),
|
| 227 |
+
));
|
| 228 |
+
};
|
| 229 |
+
let n = raw
|
| 230 |
+
.parse::<i32>()
|
| 231 |
+
.map_err(|_| err(token.at, ErrorKind::InvalidNumber))?;
|
| 232 |
+
u32::try_from(n).map_err(|_| err(token.at, ErrorKind::InvalidNumber))
|
| 233 |
+
}
|
| 234 |
+
fn float(&mut self) -> Result<f32> {
|
| 235 |
+
let token = self.next()?;
|
| 236 |
+
let Kind::Number(raw, _) = token.kind else {
|
| 237 |
+
return Err(err(token.at, ErrorKind::Syntax("expected number")));
|
| 238 |
+
};
|
| 239 |
+
let n = raw
|
| 240 |
+
.parse::<f32>()
|
| 241 |
+
.map_err(|_| err(token.at, ErrorKind::InvalidNumber))?;
|
| 242 |
+
if !n.is_finite() {
|
| 243 |
+
return Err(err(token.at, ErrorKind::NonFinite));
|
| 244 |
+
}
|
| 245 |
+
Ok(n)
|
| 246 |
+
}
|
| 247 |
+
fn vector(&mut self) -> Result<Vec3> {
|
| 248 |
+
Ok([self.float()?, self.float()?, self.float()?])
|
| 249 |
+
}
|
| 250 |
+
fn vectors(&mut self) -> Result<Vec<Vec3>> {
|
| 251 |
+
self.symbol(b'[')?;
|
| 252 |
+
let mut out = Vec::new();
|
| 253 |
+
if !self.is(Kind::Symbol(b']'))? {
|
| 254 |
+
loop {
|
| 255 |
+
let v = self.vector()?;
|
| 256 |
+
self.push(&mut out, v)?;
|
| 257 |
+
if !self.is(Kind::Symbol(b','))? {
|
| 258 |
+
break;
|
| 259 |
+
}
|
| 260 |
+
self.next()?;
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
self.symbol(b']')?;
|
| 264 |
+
Ok(out)
|
| 265 |
+
}
|
| 266 |
+
fn names(&mut self) -> Result<Vec<String>> {
|
| 267 |
+
self.symbol(b'[')?;
|
| 268 |
+
let mut out = Vec::new();
|
| 269 |
+
if !self.is(Kind::Symbol(b']'))? {
|
| 270 |
+
loop {
|
| 271 |
+
let s = self.string()?;
|
| 272 |
+
self.push(&mut out, s)?;
|
| 273 |
+
if !self.is(Kind::Symbol(b','))? {
|
| 274 |
+
break;
|
| 275 |
+
}
|
| 276 |
+
self.next()?;
|
| 277 |
+
}
|
| 278 |
+
}
|
| 279 |
+
self.symbol(b']')?;
|
| 280 |
+
unique_names(&out, &mut self.budget, self.limits, self.pos)?;
|
| 281 |
+
Ok(out)
|
| 282 |
+
}
|
| 283 |
+
fn properties<T>(
|
| 284 |
+
&mut self,
|
| 285 |
+
key: &'static str,
|
| 286 |
+
names: usize,
|
| 287 |
+
mut value: impl FnMut(&mut Self) -> Result<T>,
|
| 288 |
+
) -> Result<Vec<Property<T>>> {
|
| 289 |
+
let mut out = Vec::new();
|
| 290 |
+
while self.is(Kind::Word(key))? {
|
| 291 |
+
self.next()?;
|
| 292 |
+
let at = self.pos;
|
| 293 |
+
let index = self.integer()?;
|
| 294 |
+
if index as usize >= names {
|
| 295 |
+
return Err(err(at, ErrorKind::PropertyIndex(index)));
|
| 296 |
+
}
|
| 297 |
+
if out.len() >= names {
|
| 298 |
+
return Err(err(at, ErrorKind::DuplicateProperty(index)));
|
| 299 |
+
}
|
| 300 |
+
let entry = Property {
|
| 301 |
+
index,
|
| 302 |
+
value: value(self)?,
|
| 303 |
+
};
|
| 304 |
+
self.push(&mut out, entry)?;
|
| 305 |
+
}
|
| 306 |
+
out.sort_unstable_by_key(|p| p.index);
|
| 307 |
+
for pair in out.windows(2) {
|
| 308 |
+
if pair[0].index == pair[1].index {
|
| 309 |
+
return Err(err(self.pos, ErrorKind::DuplicateProperty(pair[0].index)));
|
| 310 |
+
}
|
| 311 |
+
}
|
| 312 |
+
Ok(out)
|
| 313 |
+
}
|
| 314 |
+
fn uv(&mut self) -> Result<Uv> {
|
| 315 |
+
self.symbol(b'(')?;
|
| 316 |
+
let u = self.vector()?;
|
| 317 |
+
let u_scale = self.float()?;
|
| 318 |
+
let u_shift = self.float()?;
|
| 319 |
+
self.symbol(b',')?;
|
| 320 |
+
let v = self.vector()?;
|
| 321 |
+
let v_scale = self.float()?;
|
| 322 |
+
let v_shift = self.float()?;
|
| 323 |
+
self.symbol(b')')?;
|
| 324 |
+
Ok(Uv {
|
| 325 |
+
u,
|
| 326 |
+
v,
|
| 327 |
+
u_scale,
|
| 328 |
+
u_shift,
|
| 329 |
+
v_scale,
|
| 330 |
+
v_shift,
|
| 331 |
+
})
|
| 332 |
+
}
|
| 333 |
+
fn brush(&mut self, world: &World) -> Result<Brush> {
|
| 334 |
+
self.word("brush")?;
|
| 335 |
+
self.symbol(b'(')?;
|
| 336 |
+
let mut planes = Vec::new();
|
| 337 |
+
while self.is(Kind::Word("plane"))? {
|
| 338 |
+
self.next()?;
|
| 339 |
+
self.symbol(b'(')?;
|
| 340 |
+
let p0 = self.vector()?;
|
| 341 |
+
self.symbol(b',')?;
|
| 342 |
+
let u = self.vector()?;
|
| 343 |
+
self.symbol(b',')?;
|
| 344 |
+
let v = self.vector()?;
|
| 345 |
+
let strings = self.properties("s", world.string_properties.len(), Self::string)?;
|
| 346 |
+
let uv = self.properties("uv", world.uv_properties.len(), Self::uv)?;
|
| 347 |
+
let polygon = if self.is(Kind::Word("polygon"))? {
|
| 348 |
+
self.next()?;
|
| 349 |
+
Some(self.vectors()?)
|
| 350 |
+
} else {
|
| 351 |
+
None
|
| 352 |
+
};
|
| 353 |
+
if polygon.is_some() != world.computed {
|
| 354 |
+
return Err(err(self.pos, ErrorKind::InconsistentComputedData));
|
| 355 |
+
}
|
| 356 |
+
self.symbol(b')')?;
|
| 357 |
+
self.push(
|
| 358 |
+
&mut planes,
|
| 359 |
+
Plane {
|
| 360 |
+
p0,
|
| 361 |
+
u,
|
| 362 |
+
v,
|
| 363 |
+
strings,
|
| 364 |
+
uv,
|
| 365 |
+
polygon,
|
| 366 |
+
},
|
| 367 |
+
)?;
|
| 368 |
+
}
|
| 369 |
+
self.symbol(b')')?;
|
| 370 |
+
Ok(Brush { planes })
|
| 371 |
+
}
|
| 372 |
+
fn patch(&mut self, computed: bool) -> Result<Patch9> {
|
| 373 |
+
self.word("patch9")?;
|
| 374 |
+
self.symbol(b'(')?;
|
| 375 |
+
let au = self.integer()?;
|
| 376 |
+
let av = self.integer()?;
|
| 377 |
+
let qu = self.integer()?;
|
| 378 |
+
let qv = self.integer()?;
|
| 379 |
+
self.word("anchors")?;
|
| 380 |
+
let anchors = self.vectors()?;
|
| 381 |
+
if anchors.len() != product(au as usize, av as usize, self.pos)? {
|
| 382 |
+
return Err(err(self.pos, ErrorKind::InvalidPatchLength));
|
| 383 |
+
}
|
| 384 |
+
let data = if self.is(Kind::Word("points"))? {
|
| 385 |
+
self.next()?;
|
| 386 |
+
let points = self.vectors()?;
|
| 387 |
+
self.word("normals")?;
|
| 388 |
+
let normals = self.vectors()?;
|
| 389 |
+
let n = grid(au, av, qu, qv, self.pos)?;
|
| 390 |
+
if points.len() != n || normals.len() != n {
|
| 391 |
+
return Err(err(self.pos, ErrorKind::InvalidPatchLength));
|
| 392 |
+
}
|
| 393 |
+
Some(PatchComputed { points, normals })
|
| 394 |
+
} else {
|
| 395 |
+
None
|
| 396 |
+
};
|
| 397 |
+
if data.is_some() != computed {
|
| 398 |
+
return Err(err(self.pos, ErrorKind::InconsistentComputedData));
|
| 399 |
+
}
|
| 400 |
+
self.symbol(b')')?;
|
| 401 |
+
Ok(Patch9 {
|
| 402 |
+
anchors_u: au,
|
| 403 |
+
anchors_v: av,
|
| 404 |
+
quads_u: qu,
|
| 405 |
+
quads_v: qv,
|
| 406 |
+
anchors,
|
| 407 |
+
computed: data,
|
| 408 |
+
})
|
| 409 |
+
}
|
| 410 |
+
}
|
| 411 |
+
/// Parse one complete TXT world with the latest grammar (including the older
|
| 412 |
+
/// property-free subset). Strict UTF-8, optional leading BOM, bounded ownership.
|
| 413 |
+
pub fn decode(bytes: &[u8], limits: &Limits) -> Result<World> {
|
| 414 |
+
if bytes.len() > limits.max_bytes {
|
| 415 |
+
return Err(err(0, ErrorKind::LimitExceeded("file bytes")));
|
| 416 |
+
}
|
| 417 |
+
let source =
|
| 418 |
+
std::str::from_utf8(bytes).map_err(|e| err(e.valid_up_to(), ErrorKind::InvalidUtf8))?;
|
| 419 |
+
let mut p = Parser {
|
| 420 |
+
source,
|
| 421 |
+
pos: if source.starts_with('\u{feff}') { 3 } else { 0 },
|
| 422 |
+
look: None,
|
| 423 |
+
limits,
|
| 424 |
+
budget: Budget::default(),
|
| 425 |
+
};
|
| 426 |
+
p.word("world")?;
|
| 427 |
+
p.symbol(b'(')?;
|
| 428 |
+
p.word("computedFeatures")?;
|
| 429 |
+
let computed = match p.next()? {
|
| 430 |
+
Token {
|
| 431 |
+
kind: Kind::Word("true"),
|
| 432 |
+
..
|
| 433 |
+
} => true,
|
| 434 |
+
Token {
|
| 435 |
+
kind: Kind::Word("false"),
|
| 436 |
+
..
|
| 437 |
+
} => false,
|
| 438 |
+
t => return Err(err(t.at, ErrorKind::Syntax("expected boolean"))),
|
| 439 |
+
};
|
| 440 |
+
let mut world = World {
|
| 441 |
+
computed,
|
| 442 |
+
..World::default()
|
| 443 |
+
};
|
| 444 |
+
if p.is(Kind::Word("stringProperties"))? {
|
| 445 |
+
p.next()?;
|
| 446 |
+
world.string_properties = p.names()?;
|
| 447 |
+
}
|
| 448 |
+
if p.is(Kind::Word("uvProperties"))? {
|
| 449 |
+
p.next()?;
|
| 450 |
+
world.uv_properties = p.names()?;
|
| 451 |
+
}
|
| 452 |
+
loop {
|
| 453 |
+
match p.peek()?.kind {
|
| 454 |
+
Kind::Word("brush") => {
|
| 455 |
+
let b = p.brush(&world)?;
|
| 456 |
+
p.push(&mut world.brushes, b)?;
|
| 457 |
+
}
|
| 458 |
+
Kind::Word("patch9") => {
|
| 459 |
+
let patch = p.patch(computed)?;
|
| 460 |
+
p.push(&mut world.patches, patch)?;
|
| 461 |
+
}
|
| 462 |
+
_ => break,
|
| 463 |
+
}
|
| 464 |
+
}
|
| 465 |
+
p.symbol(b')')?;
|
| 466 |
+
p.expect(Kind::End, "expected end of input")?;
|
| 467 |
+
Ok(world)
|
| 468 |
+
}
|
crates/mb3-txt/src/tests.rs
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use super::*;
|
| 2 |
+
use mb3_dat::World;
|
| 3 |
+
use std::io::{Cursor, Read, Write};
|
| 4 |
+
|
| 5 |
+
fn parse(s: &str) -> World {
|
| 6 |
+
decode(s.as_bytes(), &Limits::default()).unwrap()
|
| 7 |
+
}
|
| 8 |
+
fn rich() -> World {
|
| 9 |
+
parse(
|
| 10 |
+
"world(computedFeatures true stringProperties[\"unused\",\"name\"] uvProperties[\"uv\"] brush(plane(1 -0 3,4 5 6,7 8 9 s 1 \"é\\n\\t\\r\\'\\\"\\\\\" uv 0 (1 2 3 4 5,6 7 8 9 10) polygon[0 0 0,1 0 0,0 1 0])) patch9(1 1 0 0 anchors[1 2 3] points[4 5 6] normals[0 1 0]))",
|
| 11 |
+
)
|
| 12 |
+
}
|
| 13 |
+
#[test]
|
| 14 |
+
fn canonical_semantics_unicode_and_bits() {
|
| 15 |
+
let w = rich();
|
| 16 |
+
let data = encode(&w, &Limits::default()).unwrap();
|
| 17 |
+
assert!(data.starts_with(b"\xef\xbb\xbf"));
|
| 18 |
+
let decoded = decode(&data, &Limits::default()).unwrap();
|
| 19 |
+
assert_eq!(decoded, w);
|
| 20 |
+
assert_eq!(
|
| 21 |
+
decoded.brushes[0].planes[0].p0[1].to_bits(),
|
| 22 |
+
(-0.0f32).to_bits()
|
| 23 |
+
);
|
| 24 |
+
assert_eq!(encode(&decoded, &Limits::default()).unwrap(), data);
|
| 25 |
+
}
|
| 26 |
+
#[test]
|
| 27 |
+
fn old_subset_comments_and_signed_numbers() {
|
| 28 |
+
let w = parse(
|
| 29 |
+
"/*before*/world(// bool\n computedFeatures false brush(plane(+1 .2 3e-1,1. 2E+2 -3,0 0 1)))//EOF",
|
| 30 |
+
);
|
| 31 |
+
assert_eq!(w.brushes[0].planes[0].p0, [1., 0.2, 0.3]);
|
| 32 |
+
assert_eq!(w.brushes[0].planes[0].u, [1., 200., -3.]);
|
| 33 |
+
assert_eq!(
|
| 34 |
+
parse("world(computedFeatures true)"),
|
| 35 |
+
World {
|
| 36 |
+
computed: true,
|
| 37 |
+
..World::default()
|
| 38 |
+
}
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
#[test]
|
| 42 |
+
fn all_proper_prefixes_fail() {
|
| 43 |
+
let data = encode(&rich(), &Limits::default()).unwrap();
|
| 44 |
+
// A newline is optional after the closing world; exclude that valid prefix.
|
| 45 |
+
for n in 0..data.len() - 1 {
|
| 46 |
+
assert!(
|
| 47 |
+
decode(&data[..n], &Limits::default()).is_err(),
|
| 48 |
+
"prefix {n}"
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
#[test]
|
| 53 |
+
fn bad_lexical_inputs_are_rejected() {
|
| 54 |
+
for s in [
|
| 55 |
+
"",
|
| 56 |
+
"world(computedFeatures false) trailing",
|
| 57 |
+
"world(computedFeatures false) /*",
|
| 58 |
+
"world(computedFeatures false stringProperties[\"unterminated])",
|
| 59 |
+
"world(computedFeatures false stringProperties[\"\\x\"])",
|
| 60 |
+
"world(computedFeatures false stringProperties[\"'\"])",
|
| 61 |
+
"world(computedFeatures false stringProperties[\"\0\"])",
|
| 62 |
+
"world(computedFeatures false stringProperties[\"a\",])",
|
| 63 |
+
"world(computedFeatures FALSE)",
|
| 64 |
+
"world(computedFeatures false worlds())",
|
| 65 |
+
] {
|
| 66 |
+
assert!(decode(s.as_bytes(), &Limits::default()).is_err(), "{s:?}");
|
| 67 |
+
}
|
| 68 |
+
assert!(
|
| 69 |
+
decode(
|
| 70 |
+
b"world(computedFeatures false stringProperties[\"\xff\"])",
|
| 71 |
+
&Limits::default()
|
| 72 |
+
)
|
| 73 |
+
.is_err()
|
| 74 |
+
);
|
| 75 |
+
}
|
| 76 |
+
#[test]
|
| 77 |
+
fn numeric_grammar_and_ranges() {
|
| 78 |
+
for number in [
|
| 79 |
+
"NaN", "Infinity", "1e99", "1e", "1e+", "+", ".", "01", "00e1", "1f",
|
| 80 |
+
] {
|
| 81 |
+
let s = format!("world(computedFeatures false brush(plane({number} 0 0,0 1 0,0 0 1)))");
|
| 82 |
+
assert!(
|
| 83 |
+
decode(s.as_bytes(), &Limits::default()).is_err(),
|
| 84 |
+
"{number}"
|
| 85 |
+
);
|
| 86 |
+
}
|
| 87 |
+
for ints in ["-1 0 0 0", "2147483648 0 0 0", "1.0 1 1 1", "1e0 1 1 1"] {
|
| 88 |
+
assert!(
|
| 89 |
+
decode(
|
| 90 |
+
format!("world(computedFeatures false patch9({ints} anchors[]))").as_bytes(),
|
| 91 |
+
&Limits::default()
|
| 92 |
+
)
|
| 93 |
+
.is_err()
|
| 94 |
+
);
|
| 95 |
+
}
|
| 96 |
+
let number = "1".repeat(129);
|
| 97 |
+
assert!(
|
| 98 |
+
decode(
|
| 99 |
+
format!("world(computedFeatures false brush(plane({number} 0 0,0 0 0,0 0 0)))")
|
| 100 |
+
.as_bytes(),
|
| 101 |
+
&Limits::default()
|
| 102 |
+
)
|
| 103 |
+
.is_err()
|
| 104 |
+
);
|
| 105 |
+
}
|
| 106 |
+
#[test]
|
| 107 |
+
fn strict_computed_and_patch_lengths() {
|
| 108 |
+
for s in [
|
| 109 |
+
"world(computedFeatures true brush(plane(0 0 0,0 0 0,0 0 0)))",
|
| 110 |
+
"world(computedFeatures false brush(plane(0 0 0,0 0 0,0 0 0 polygon[])))",
|
| 111 |
+
"world(computedFeatures false patch9(3 3 2 2 anchors[]))",
|
| 112 |
+
"world(computedFeatures true patch9(0 0 0 0 anchors[]))",
|
| 113 |
+
"world(computedFeatures true patch9(0 0 0 0 anchors[] points[] normals[]))",
|
| 114 |
+
"world(computedFeatures false patch9(0 0 0 0 anchors[] points[0 0 0] normals[0 0 0]))",
|
| 115 |
+
] {
|
| 116 |
+
assert!(decode(s.as_bytes(), &Limits::default()).is_err(), "{s}");
|
| 117 |
+
}
|
| 118 |
+
let mut w = rich();
|
| 119 |
+
w.patches[0].anchors.clear();
|
| 120 |
+
assert!(encode(&w, &Limits::default()).is_err());
|
| 121 |
+
w = rich();
|
| 122 |
+
w.patches[0].computed.as_mut().unwrap().normals.clear();
|
| 123 |
+
assert!(encode(&w, &Limits::default()).is_err());
|
| 124 |
+
w = rich();
|
| 125 |
+
w.brushes[0].planes[0].polygon = None;
|
| 126 |
+
assert!(encode(&w, &Limits::default()).is_err());
|
| 127 |
+
}
|
| 128 |
+
#[test]
|
| 129 |
+
fn sparse_slots_and_names() {
|
| 130 |
+
let w = parse(
|
| 131 |
+
"world(computedFeatures false stringProperties[\"a\",\"b\"] brush(plane(0 0 0,0 0 0,0 0 0 s 1 \"\" s 0 \"a\")))",
|
| 132 |
+
);
|
| 133 |
+
assert_eq!(w.brushes[0].planes[0].strings[0].index, 0);
|
| 134 |
+
for s in [
|
| 135 |
+
"world(computedFeatures false stringProperties[\"p\",\"p\"])",
|
| 136 |
+
"world(computedFeatures false uvProperties[\"p\",\"p\"])",
|
| 137 |
+
"world(computedFeatures false stringProperties[\"p\"] brush(plane(0 0 0,0 0 0,0 0 0 s 0 \"a\" s 0 \"b\")))",
|
| 138 |
+
"world(computedFeatures false brush(plane(0 0 0,0 0 0,0 0 0 s 0 \"a\")))",
|
| 139 |
+
] {
|
| 140 |
+
assert!(decode(s.as_bytes(), &Limits::default()).is_err());
|
| 141 |
+
}
|
| 142 |
+
let mut w = w;
|
| 143 |
+
w.brushes[0].planes[0].strings.reverse();
|
| 144 |
+
assert!(encode(&w, &Limits::default()).is_err());
|
| 145 |
+
let mut w = rich();
|
| 146 |
+
w.string_properties[1] = w.string_properties[0].clone();
|
| 147 |
+
assert!(encode(&w, &Limits::default()).is_err());
|
| 148 |
+
}
|
| 149 |
+
#[test]
|
| 150 |
+
fn finite_float_extremes_roundtrip() {
|
| 151 |
+
let mut w = rich();
|
| 152 |
+
w.brushes[0].planes[0].p0 = [f32::MAX, f32::MIN_POSITIVE, f32::from_bits(1)];
|
| 153 |
+
assert_eq!(
|
| 154 |
+
decode(&encode(&w, &Limits::default()).unwrap(), &Limits::default()).unwrap(),
|
| 155 |
+
w
|
| 156 |
+
);
|
| 157 |
+
for f in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
| 158 |
+
w.brushes[0].planes[0].uv[0].value.v_shift = f;
|
| 159 |
+
assert!(encode(&w, &Limits::default()).is_err());
|
| 160 |
+
}
|
| 161 |
+
w = rich();
|
| 162 |
+
w.string_properties[0].push('\0');
|
| 163 |
+
assert!(encode(&w, &Limits::default()).is_err());
|
| 164 |
+
}
|
| 165 |
+
#[test]
|
| 166 |
+
fn all_limits_and_stream_size() {
|
| 167 |
+
let w = rich();
|
| 168 |
+
let data = encode(&w, &Limits::default()).unwrap();
|
| 169 |
+
for l in [
|
| 170 |
+
Limits {
|
| 171 |
+
max_bytes: data.len() - 1,
|
| 172 |
+
..Limits::default()
|
| 173 |
+
},
|
| 174 |
+
Limits {
|
| 175 |
+
max_items: 1,
|
| 176 |
+
..Limits::default()
|
| 177 |
+
},
|
| 178 |
+
Limits {
|
| 179 |
+
max_string_bytes: 1,
|
| 180 |
+
..Limits::default()
|
| 181 |
+
},
|
| 182 |
+
] {
|
| 183 |
+
assert!(decode(&data, &l).is_err());
|
| 184 |
+
assert!(encode(&w, &l).is_err());
|
| 185 |
+
}
|
| 186 |
+
assert!(
|
| 187 |
+
decode(
|
| 188 |
+
&data,
|
| 189 |
+
&Limits {
|
| 190 |
+
max_owned_bytes: 1,
|
| 191 |
+
..Limits::default()
|
| 192 |
+
}
|
| 193 |
+
)
|
| 194 |
+
.is_err()
|
| 195 |
+
);
|
| 196 |
+
let mut source = Cursor::new(&data);
|
| 197 |
+
assert!(
|
| 198 |
+
read(
|
| 199 |
+
&mut source,
|
| 200 |
+
&Limits {
|
| 201 |
+
max_bytes: 20,
|
| 202 |
+
..Limits::default()
|
| 203 |
+
}
|
| 204 |
+
)
|
| 205 |
+
.is_err()
|
| 206 |
+
);
|
| 207 |
+
assert_eq!(source.position(), 21);
|
| 208 |
+
let mut target = vec![1, 2];
|
| 209 |
+
let mut bad = w;
|
| 210 |
+
bad.computed = false;
|
| 211 |
+
assert!(write(&mut target, &bad, &Limits::default()).is_err());
|
| 212 |
+
assert_eq!(target, [1, 2]);
|
| 213 |
+
}
|
| 214 |
+
#[test]
|
| 215 |
+
fn string_limit_measures_decoded_utf8() {
|
| 216 |
+
let data = "world(computedFeatures false stringProperties[\"é\\n\"])";
|
| 217 |
+
let l = Limits {
|
| 218 |
+
max_string_bytes: 3,
|
| 219 |
+
..Limits::default()
|
| 220 |
+
};
|
| 221 |
+
let w = decode(data.as_bytes(), &l).unwrap();
|
| 222 |
+
assert!(encode(&w, &l).is_ok());
|
| 223 |
+
assert!(
|
| 224 |
+
decode(
|
| 225 |
+
data.as_bytes(),
|
| 226 |
+
&Limits {
|
| 227 |
+
max_string_bytes: 2,
|
| 228 |
+
..l
|
| 229 |
+
}
|
| 230 |
+
)
|
| 231 |
+
.is_err()
|
| 232 |
+
);
|
| 233 |
+
}
|
| 234 |
+
struct Choppy {
|
| 235 |
+
data: Cursor<Vec<u8>>,
|
| 236 |
+
interrupt: bool,
|
| 237 |
+
}
|
| 238 |
+
impl Read for Choppy {
|
| 239 |
+
fn read(&mut self, b: &mut [u8]) -> io::Result<usize> {
|
| 240 |
+
if self.interrupt {
|
| 241 |
+
self.interrupt = false;
|
| 242 |
+
return Err(io::ErrorKind::Interrupted.into());
|
| 243 |
+
}
|
| 244 |
+
let n = b.len().min(1);
|
| 245 |
+
self.data.read(&mut b[..n])
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
impl Write for Choppy {
|
| 249 |
+
fn write(&mut self, b: &[u8]) -> io::Result<usize> {
|
| 250 |
+
if self.interrupt {
|
| 251 |
+
self.interrupt = false;
|
| 252 |
+
return Err(io::ErrorKind::Interrupted.into());
|
| 253 |
+
}
|
| 254 |
+
self.data.write(&b[..b.len().min(1)])
|
| 255 |
+
}
|
| 256 |
+
fn flush(&mut self) -> io::Result<()> {
|
| 257 |
+
Ok(())
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
#[test]
|
| 261 |
+
fn short_io_interruptions_and_errors() {
|
| 262 |
+
let w = rich();
|
| 263 |
+
let data = encode(&w, &Limits::default()).unwrap();
|
| 264 |
+
assert_eq!(
|
| 265 |
+
read(
|
| 266 |
+
Choppy {
|
| 267 |
+
data: Cursor::new(data.clone()),
|
| 268 |
+
interrupt: true
|
| 269 |
+
},
|
| 270 |
+
&Limits::default()
|
| 271 |
+
)
|
| 272 |
+
.unwrap(),
|
| 273 |
+
w
|
| 274 |
+
);
|
| 275 |
+
let mut target = Choppy {
|
| 276 |
+
data: Cursor::new(Vec::new()),
|
| 277 |
+
interrupt: true,
|
| 278 |
+
};
|
| 279 |
+
write(&mut target, &w, &Limits::default()).unwrap();
|
| 280 |
+
assert_eq!(target.data.into_inner(), data);
|
| 281 |
+
assert!(matches!(
|
| 282 |
+
write([0u8; 0].as_mut_slice(), &w, &Limits::default())
|
| 283 |
+
.unwrap_err()
|
| 284 |
+
.kind,
|
| 285 |
+
ErrorKind::Io(_)
|
| 286 |
+
));
|
| 287 |
+
}
|
| 288 |
+
#[test]
|
| 289 |
+
fn bounded_mutations_and_no_recursion() {
|
| 290 |
+
let l = Limits {
|
| 291 |
+
max_bytes: 4096,
|
| 292 |
+
max_items: 256,
|
| 293 |
+
max_string_bytes: 64,
|
| 294 |
+
max_owned_bytes: 32768,
|
| 295 |
+
};
|
| 296 |
+
let seed = encode(&rich(), &l).unwrap();
|
| 297 |
+
let mut state = 0x12ab34cdu32;
|
| 298 |
+
for i in 0..3000 {
|
| 299 |
+
let mut data = seed.clone();
|
| 300 |
+
for _ in 0..1 + i % 7 {
|
| 301 |
+
state = state.wrapping_mul(1664525).wrapping_add(1013904223);
|
| 302 |
+
let at = state as usize % data.len();
|
| 303 |
+
data[at] ^= (state >> 24) as u8;
|
| 304 |
+
}
|
| 305 |
+
if let Ok(w) = decode(&data, &l) {
|
| 306 |
+
let canonical = encode(&w, &l).unwrap();
|
| 307 |
+
assert_eq!(decode(&canonical, &l).unwrap(), w);
|
| 308 |
+
}
|
| 309 |
+
}
|
| 310 |
+
assert!(
|
| 311 |
+
decode(
|
| 312 |
+
format!("world(computedFeatures false {} )", "brush(".repeat(1000)).as_bytes(),
|
| 313 |
+
&Limits::default()
|
| 314 |
+
)
|
| 315 |
+
.is_err()
|
| 316 |
+
);
|
| 317 |
+
}
|
crates/mb3-txt/src/writer.rs
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use super::*;
|
| 2 |
+
use mb3_dat::{Brush, Patch9, Property, Uv, Vec3, World};
|
| 3 |
+
use std::fmt::Write as _;
|
| 4 |
+
|
| 5 |
+
struct Number {
|
| 6 |
+
bytes: [u8; 128],
|
| 7 |
+
len: usize,
|
| 8 |
+
}
|
| 9 |
+
impl fmt::Write for Number {
|
| 10 |
+
fn write_str(&mut self, s: &str) -> fmt::Result {
|
| 11 |
+
if s.len() > self.bytes.len() - self.len {
|
| 12 |
+
return Err(fmt::Error);
|
| 13 |
+
}
|
| 14 |
+
self.bytes[self.len..self.len + s.len()].copy_from_slice(s.as_bytes());
|
| 15 |
+
self.len += s.len();
|
| 16 |
+
Ok(())
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
struct Encoder<'a> {
|
| 20 |
+
out: Vec<u8>,
|
| 21 |
+
limits: &'a Limits,
|
| 22 |
+
budget: Budget,
|
| 23 |
+
}
|
| 24 |
+
impl Encoder<'_> {
|
| 25 |
+
fn bytes(&mut self, s: &[u8]) -> Result<()> {
|
| 26 |
+
append(&mut self.out, s, self.limits.max_bytes)
|
| 27 |
+
}
|
| 28 |
+
fn text(&mut self, s: &str) -> Result<()> {
|
| 29 |
+
self.bytes(s.as_bytes())
|
| 30 |
+
}
|
| 31 |
+
fn items(&mut self, n: usize) -> Result<()> {
|
| 32 |
+
self.budget.items(n, self.limits, self.out.len())
|
| 33 |
+
}
|
| 34 |
+
fn integer(&mut self, n: u32) -> Result<()> {
|
| 35 |
+
if n > i32::MAX as u32 {
|
| 36 |
+
return Err(err(self.out.len(), ErrorKind::CountOverflow));
|
| 37 |
+
}
|
| 38 |
+
let mut buf = Number {
|
| 39 |
+
bytes: [0; 128],
|
| 40 |
+
len: 0,
|
| 41 |
+
};
|
| 42 |
+
write!(&mut buf, "{n}").map_err(|_| err(self.out.len(), ErrorKind::CountOverflow))?;
|
| 43 |
+
self.bytes(&buf.bytes[..buf.len])
|
| 44 |
+
}
|
| 45 |
+
fn float(&mut self, n: f32) -> Result<()> {
|
| 46 |
+
if !n.is_finite() {
|
| 47 |
+
return Err(err(self.out.len(), ErrorKind::NonFinite));
|
| 48 |
+
}
|
| 49 |
+
let mut buf = Number {
|
| 50 |
+
bytes: [0; 128],
|
| 51 |
+
len: 0,
|
| 52 |
+
};
|
| 53 |
+
write!(&mut buf, "{n}").map_err(|_| err(self.out.len(), ErrorKind::CountOverflow))?;
|
| 54 |
+
self.bytes(&buf.bytes[..buf.len])
|
| 55 |
+
}
|
| 56 |
+
fn vector(&mut self, v: &Vec3) -> Result<()> {
|
| 57 |
+
self.float(v[0])?;
|
| 58 |
+
self.text(" ")?;
|
| 59 |
+
self.float(v[1])?;
|
| 60 |
+
self.text(" ")?;
|
| 61 |
+
self.float(v[2])
|
| 62 |
+
}
|
| 63 |
+
fn vectors(&mut self, values: &[Vec3]) -> Result<()> {
|
| 64 |
+
self.items(values.len())?;
|
| 65 |
+
self.text("[")?;
|
| 66 |
+
for (i, v) in values.iter().enumerate() {
|
| 67 |
+
if i != 0 {
|
| 68 |
+
self.text(", ")?;
|
| 69 |
+
}
|
| 70 |
+
self.vector(v)?;
|
| 71 |
+
}
|
| 72 |
+
self.text("]")
|
| 73 |
+
}
|
| 74 |
+
fn string(&mut self, s: &str) -> Result<()> {
|
| 75 |
+
if s.len() > self.limits.max_string_bytes {
|
| 76 |
+
return Err(err(
|
| 77 |
+
self.out.len(),
|
| 78 |
+
ErrorKind::LimitExceeded("string bytes"),
|
| 79 |
+
));
|
| 80 |
+
}
|
| 81 |
+
self.text("\"")?;
|
| 82 |
+
for c in s.chars() {
|
| 83 |
+
match c {
|
| 84 |
+
'\0' => return Err(err(self.out.len(), ErrorKind::EmbeddedNul)),
|
| 85 |
+
'\r' => self.text("\\r")?,
|
| 86 |
+
'\n' => self.text("\\n")?,
|
| 87 |
+
'\t' => self.text("\\t")?,
|
| 88 |
+
'\'' => self.text("\\'")?,
|
| 89 |
+
'"' => self.text("\\\"")?,
|
| 90 |
+
'\\' => self.text("\\\\")?,
|
| 91 |
+
_ => {
|
| 92 |
+
let mut buf = [0; 4];
|
| 93 |
+
self.text(c.encode_utf8(&mut buf))?;
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
self.text("\"")
|
| 98 |
+
}
|
| 99 |
+
fn names(&mut self, names: &[String], key: &str) -> Result<()> {
|
| 100 |
+
self.items(names.len())?;
|
| 101 |
+
unique_names(names, &mut self.budget, self.limits, self.out.len())?;
|
| 102 |
+
if names.is_empty() {
|
| 103 |
+
return Ok(());
|
| 104 |
+
}
|
| 105 |
+
self.text(key)?;
|
| 106 |
+
self.text(" [")?;
|
| 107 |
+
for (i, s) in names.iter().enumerate() {
|
| 108 |
+
if i != 0 {
|
| 109 |
+
self.text(", ")?;
|
| 110 |
+
}
|
| 111 |
+
self.string(s)?;
|
| 112 |
+
}
|
| 113 |
+
self.text("]\n")
|
| 114 |
+
}
|
| 115 |
+
fn properties<T>(
|
| 116 |
+
&mut self,
|
| 117 |
+
values: &[Property<T>],
|
| 118 |
+
names: usize,
|
| 119 |
+
key: &str,
|
| 120 |
+
mut value: impl FnMut(&mut Self, &T) -> Result<()>,
|
| 121 |
+
) -> Result<()> {
|
| 122 |
+
self.items(values.len())?;
|
| 123 |
+
let mut previous = None;
|
| 124 |
+
for p in values {
|
| 125 |
+
if p.index as usize >= names {
|
| 126 |
+
return Err(err(self.out.len(), ErrorKind::PropertyIndex(p.index)));
|
| 127 |
+
}
|
| 128 |
+
if previous == Some(p.index) {
|
| 129 |
+
return Err(err(self.out.len(), ErrorKind::DuplicateProperty(p.index)));
|
| 130 |
+
}
|
| 131 |
+
if previous.is_some_and(|n| n > p.index) {
|
| 132 |
+
return Err(err(self.out.len(), ErrorKind::UnsortedProperties));
|
| 133 |
+
}
|
| 134 |
+
previous = Some(p.index);
|
| 135 |
+
self.text(key)?;
|
| 136 |
+
self.integer(p.index)?;
|
| 137 |
+
self.text(" ")?;
|
| 138 |
+
value(self, &p.value)?;
|
| 139 |
+
}
|
| 140 |
+
Ok(())
|
| 141 |
+
}
|
| 142 |
+
fn uv(&mut self, uv: &Uv) -> Result<()> {
|
| 143 |
+
self.text("(")?;
|
| 144 |
+
self.vector(&uv.u)?;
|
| 145 |
+
self.text(" ")?;
|
| 146 |
+
self.float(uv.u_scale)?;
|
| 147 |
+
self.text(" ")?;
|
| 148 |
+
self.float(uv.u_shift)?;
|
| 149 |
+
self.text(", ")?;
|
| 150 |
+
self.vector(&uv.v)?;
|
| 151 |
+
self.text(" ")?;
|
| 152 |
+
self.float(uv.v_scale)?;
|
| 153 |
+
self.text(" ")?;
|
| 154 |
+
self.float(uv.v_shift)?;
|
| 155 |
+
self.text(")")
|
| 156 |
+
}
|
| 157 |
+
fn brush(&mut self, brush: &Brush, world: &World) -> Result<()> {
|
| 158 |
+
self.items(brush.planes.len())?;
|
| 159 |
+
self.text("brush(\n")?;
|
| 160 |
+
for p in &brush.planes {
|
| 161 |
+
if p.polygon.is_some() != world.computed {
|
| 162 |
+
return Err(err(self.out.len(), ErrorKind::InconsistentComputedData));
|
| 163 |
+
}
|
| 164 |
+
self.text("plane(")?;
|
| 165 |
+
self.vector(&p.p0)?;
|
| 166 |
+
self.text(", ")?;
|
| 167 |
+
self.vector(&p.u)?;
|
| 168 |
+
self.text(", ")?;
|
| 169 |
+
self.vector(&p.v)?;
|
| 170 |
+
self.properties(&p.strings, world.string_properties.len(), " s ", |e, s| {
|
| 171 |
+
e.string(s)
|
| 172 |
+
})?;
|
| 173 |
+
self.properties(&p.uv, world.uv_properties.len(), " uv ", Self::uv)?;
|
| 174 |
+
if let Some(polygon) = &p.polygon {
|
| 175 |
+
self.text(" polygon")?;
|
| 176 |
+
self.vectors(polygon)?;
|
| 177 |
+
}
|
| 178 |
+
self.text(")\n")?;
|
| 179 |
+
}
|
| 180 |
+
self.text(")\n")
|
| 181 |
+
}
|
| 182 |
+
fn patch(&mut self, p: &Patch9, computed: bool) -> Result<()> {
|
| 183 |
+
if p.computed.is_some() != computed {
|
| 184 |
+
return Err(err(self.out.len(), ErrorKind::InconsistentComputedData));
|
| 185 |
+
}
|
| 186 |
+
if p.anchors.len() != product(p.anchors_u as usize, p.anchors_v as usize, self.out.len())? {
|
| 187 |
+
return Err(err(self.out.len(), ErrorKind::InvalidPatchLength));
|
| 188 |
+
}
|
| 189 |
+
if let Some(data) = &p.computed {
|
| 190 |
+
let n = grid(
|
| 191 |
+
p.anchors_u,
|
| 192 |
+
p.anchors_v,
|
| 193 |
+
p.quads_u,
|
| 194 |
+
p.quads_v,
|
| 195 |
+
self.out.len(),
|
| 196 |
+
)?;
|
| 197 |
+
if data.points.len() != n || data.normals.len() != n {
|
| 198 |
+
return Err(err(self.out.len(), ErrorKind::InvalidPatchLength));
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
self.text("patch9(\n")?;
|
| 202 |
+
for (i, n) in [p.anchors_u, p.anchors_v, p.quads_u, p.quads_v]
|
| 203 |
+
.iter()
|
| 204 |
+
.enumerate()
|
| 205 |
+
{
|
| 206 |
+
if i != 0 {
|
| 207 |
+
self.text(" ")?;
|
| 208 |
+
}
|
| 209 |
+
self.integer(*n)?;
|
| 210 |
+
}
|
| 211 |
+
self.text("\nanchors")?;
|
| 212 |
+
self.vectors(&p.anchors)?;
|
| 213 |
+
if let Some(data) = &p.computed {
|
| 214 |
+
self.text("\npoints")?;
|
| 215 |
+
self.vectors(&data.points)?;
|
| 216 |
+
self.text("\nnormals")?;
|
| 217 |
+
self.vectors(&data.normals)?;
|
| 218 |
+
}
|
| 219 |
+
self.text("\n)\n")
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
/// Canonical, BOM-prefixed UTF-8, LF, shortest round-tripping f32 decimals.
|
| 223 |
+
/// Structural validation finishes before output is returned. No optimization.
|
| 224 |
+
pub fn encode(world: &World, limits: &Limits) -> Result<Vec<u8>> {
|
| 225 |
+
let mut e = Encoder {
|
| 226 |
+
out: Vec::new(),
|
| 227 |
+
limits,
|
| 228 |
+
budget: Budget::default(),
|
| 229 |
+
};
|
| 230 |
+
e.bytes(b"\xef\xbb\xbfworld(\ncomputedFeatures ")?;
|
| 231 |
+
e.text(if world.computed { "true\n" } else { "false\n" })?;
|
| 232 |
+
e.names(&world.string_properties, "stringProperties")?;
|
| 233 |
+
e.names(&world.uv_properties, "uvProperties")?;
|
| 234 |
+
e.items(world.brushes.len())?;
|
| 235 |
+
for brush in &world.brushes {
|
| 236 |
+
e.brush(brush, world)?;
|
| 237 |
+
}
|
| 238 |
+
e.items(world.patches.len())?;
|
| 239 |
+
for patch in &world.patches {
|
| 240 |
+
e.patch(patch, world.computed)?;
|
| 241 |
+
}
|
| 242 |
+
e.text(")\n")?;
|
| 243 |
+
Ok(e.out)
|
| 244 |
+
}
|
crates/mb3-txt/tests/converter.rs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Hydrated E02 fixtures are mandatory; missing files never skip validation.
|
| 2 |
+
use mb3_dat::{Document, Limits, Version};
|
| 3 |
+
use std::{fs, path::PathBuf};
|
| 4 |
+
fn root() -> PathBuf {
|
| 5 |
+
std::env::var_os("NANOBRUSH_CONVERTER_FIXTURES")
|
| 6 |
+
.map(PathBuf::from)
|
| 7 |
+
.unwrap_or_else(|| {
|
| 8 |
+
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/analysis/converter/probes")
|
| 9 |
+
})
|
| 10 |
+
}
|
| 11 |
+
fn file(build: &str, case: &str, name: &str) -> Vec<u8> {
|
| 12 |
+
let p = root().join(build).join(case).join(name);
|
| 13 |
+
fs::read(&p).unwrap_or_else(|e| panic!("hydrate E02 assets; {}: {e}", p.display()))
|
| 14 |
+
}
|
| 15 |
+
#[test]
|
| 16 |
+
fn all_58_dat_fixtures_through_txt_preserve_exact_dat_bytes() {
|
| 17 |
+
let mut n = 0;
|
| 18 |
+
for build in ["2015-10-03", "2016-03-10-c"] {
|
| 19 |
+
for entry in fs::read_dir(root().join(build)).expect("hydrate E02 fixtures") {
|
| 20 |
+
let path = entry.unwrap().path().join("output.mb3dat");
|
| 21 |
+
if !path.exists() {
|
| 22 |
+
continue;
|
| 23 |
+
}
|
| 24 |
+
let original = fs::read(&path).unwrap();
|
| 25 |
+
let d = mb3_dat::decode(&original, &Limits::default()).unwrap();
|
| 26 |
+
let text = mb3_txt::encode(&d.world, &Limits::default()).unwrap();
|
| 27 |
+
let world = mb3_txt::decode(&text, &Limits::default()).unwrap();
|
| 28 |
+
assert_eq!(world, d.world, "{}", path.display());
|
| 29 |
+
assert_eq!(
|
| 30 |
+
mb3_dat::encode(
|
| 31 |
+
&Document {
|
| 32 |
+
version: d.version,
|
| 33 |
+
world
|
| 34 |
+
},
|
| 35 |
+
&Limits::default()
|
| 36 |
+
)
|
| 37 |
+
.unwrap(),
|
| 38 |
+
original,
|
| 39 |
+
"{}",
|
| 40 |
+
path.display()
|
| 41 |
+
);
|
| 42 |
+
n += 1;
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
assert_eq!(n, 58);
|
| 46 |
+
}
|
| 47 |
+
#[test]
|
| 48 |
+
fn seven_converter_txt_outputs_canonicalize_stably() {
|
| 49 |
+
let mut n = 0;
|
| 50 |
+
for build in ["2015-10-03", "2016-03-10-c"] {
|
| 51 |
+
for entry in fs::read_dir(root().join(build)).unwrap() {
|
| 52 |
+
let path = entry.unwrap().path().join("output.mb3txt");
|
| 53 |
+
if !path.exists() {
|
| 54 |
+
continue;
|
| 55 |
+
}
|
| 56 |
+
let source = fs::read(&path).unwrap();
|
| 57 |
+
let world = mb3_txt::decode(&source, &Limits::default()).unwrap();
|
| 58 |
+
let canonical = mb3_txt::encode(&world, &Limits::default()).unwrap();
|
| 59 |
+
assert_eq!(
|
| 60 |
+
mb3_txt::decode(&canonical, &Limits::default()).unwrap(),
|
| 61 |
+
world
|
| 62 |
+
);
|
| 63 |
+
assert_eq!(
|
| 64 |
+
mb3_txt::encode(
|
| 65 |
+
&mb3_txt::decode(&canonical, &Limits::default()).unwrap(),
|
| 66 |
+
&Limits::default()
|
| 67 |
+
)
|
| 68 |
+
.unwrap(),
|
| 69 |
+
canonical
|
| 70 |
+
);
|
| 71 |
+
n += 1;
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
assert_eq!(n, 7);
|
| 75 |
+
}
|
| 76 |
+
#[test]
|
| 77 |
+
fn sixteen_txt_to_dat_fixture_pairs_match_without_legacy_losses() {
|
| 78 |
+
let mut n = 0;
|
| 79 |
+
for (build, version) in [
|
| 80 |
+
("2015-10-03", Version::V201501280),
|
| 81 |
+
("2016-03-10-c", Version::V201512200),
|
| 82 |
+
] {
|
| 83 |
+
let mut cases = vec![
|
| 84 |
+
"empty-false",
|
| 85 |
+
"empty-true",
|
| 86 |
+
"plane-to-dat",
|
| 87 |
+
"computed-plane",
|
| 88 |
+
"patch-false",
|
| 89 |
+
"patch-true",
|
| 90 |
+
"overwrite-dat",
|
| 91 |
+
];
|
| 92 |
+
if build.starts_with("2016") {
|
| 93 |
+
cases.extend(["properties-255", "properties-256"]);
|
| 94 |
+
}
|
| 95 |
+
for case in cases {
|
| 96 |
+
let world =
|
| 97 |
+
mb3_txt::decode(&file(build, case, "input.mb3txt"), &Limits::default()).unwrap();
|
| 98 |
+
assert_eq!(
|
| 99 |
+
mb3_dat::encode(&Document { version, world }, &Limits::default()).unwrap(),
|
| 100 |
+
file(build, case, "output.mb3dat"),
|
| 101 |
+
"{build}/{case}"
|
| 102 |
+
);
|
| 103 |
+
n += 1;
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
assert_eq!(n, 16);
|
| 107 |
+
}
|
| 108 |
+
#[test]
|
| 109 |
+
fn rejects_nine_invalid_or_silently_lossy_txt_inputs() {
|
| 110 |
+
let mut n = 0;
|
| 111 |
+
for build in ["2015-10-03", "2016-03-10-c"] {
|
| 112 |
+
let mut cases = vec![
|
| 113 |
+
"computed-missing-polygon",
|
| 114 |
+
"false-ignores-polygon",
|
| 115 |
+
"bad-syntax",
|
| 116 |
+
];
|
| 117 |
+
if build.starts_with("2016") {
|
| 118 |
+
cases.extend(["duplicate-name", "duplicate-slot", "bad-slot"]);
|
| 119 |
+
}
|
| 120 |
+
for case in cases {
|
| 121 |
+
assert!(
|
| 122 |
+
mb3_txt::decode(&file(build, case, "input.mb3txt"), &Limits::default()).is_err(),
|
| 123 |
+
"{build}/{case}"
|
| 124 |
+
);
|
| 125 |
+
n += 1;
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
assert_eq!(n, 9);
|
| 129 |
+
}
|
| 130 |
+
#[test]
|
| 131 |
+
fn explicit_encoding_and_optimization_differences() {
|
| 132 |
+
let b = "2016-03-10-c";
|
| 133 |
+
let world = mb3_txt::decode(
|
| 134 |
+
&file(b, "properties-to-dat", "input.mb3txt"),
|
| 135 |
+
&Limits::default(),
|
| 136 |
+
)
|
| 137 |
+
.unwrap();
|
| 138 |
+
assert_eq!(world.string_properties, ["unused", "rare", "common"]);
|
| 139 |
+
let reference = mb3_dat::decode(
|
| 140 |
+
&file(b, "properties-to-dat", "output.mb3dat"),
|
| 141 |
+
&Limits::default(),
|
| 142 |
+
)
|
| 143 |
+
.unwrap();
|
| 144 |
+
assert_eq!(reference.world.string_properties, ["common", "rare"]);
|
| 145 |
+
assert_eq!(
|
| 146 |
+
world.brushes[0].planes[0].strings[0].value,
|
| 147 |
+
reference.world.brushes[0].planes[0].strings[1].value
|
| 148 |
+
);
|
| 149 |
+
for case in ["utf8-bom-ascii-loss", "utf8-no-bom-mojibake"] {
|
| 150 |
+
let world = mb3_txt::decode(&file(b, case, "input.mb3txt"), &Limits::default()).unwrap();
|
| 151 |
+
assert_eq!(world.string_properties, ["é"]);
|
| 152 |
+
assert!(
|
| 153 |
+
mb3_dat::encode(
|
| 154 |
+
&Document {
|
| 155 |
+
version: Version::V201512200,
|
| 156 |
+
world: world.clone()
|
| 157 |
+
},
|
| 158 |
+
&Limits::default()
|
| 159 |
+
)
|
| 160 |
+
.is_err()
|
| 161 |
+
);
|
| 162 |
+
let text = mb3_txt::encode(&world, &Limits::default()).unwrap();
|
| 163 |
+
assert!(text.starts_with(b"\xef\xbb\xbf"));
|
| 164 |
+
assert_eq!(mb3_txt::decode(&text, &Limits::default()).unwrap(), world);
|
| 165 |
+
}
|
| 166 |
+
}
|
crates/nanobrush-cli/Cargo.toml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[package]
|
| 2 |
+
name = "nanobrush-cli"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Headless MB3 TXT/DAT inspection, validation and conversion"
|
| 5 |
+
edition.workspace = true
|
| 6 |
+
rust-version.workspace = true
|
| 7 |
+
publish = false
|
| 8 |
+
|
| 9 |
+
[[bin]]
|
| 10 |
+
name = "nanobrush"
|
| 11 |
+
path = "src/main.rs"
|
| 12 |
+
|
| 13 |
+
[dependencies]
|
| 14 |
+
mb3-dat = { path = "../mb3-dat" }
|
| 15 |
+
mb3-txt = { path = "../mb3-txt" }
|
| 16 |
+
|
| 17 |
+
[lints]
|
| 18 |
+
workspace = true
|
crates/nanobrush-cli/src/lib.rs
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Headless CLI; source-preserving conversion with atomic destination publication.
|
| 2 |
+
use mb3_dat::{Document, Limits, Version, World};
|
| 3 |
+
use std::ffi::OsString;
|
| 4 |
+
use std::fmt;
|
| 5 |
+
use std::fs::{self, File, OpenOptions};
|
| 6 |
+
use std::io::{self, BufRead, BufReader, Write};
|
| 7 |
+
use std::path::{Path, PathBuf};
|
| 8 |
+
use std::sync::atomic::{AtomicU64, Ordering};
|
| 9 |
+
|
| 10 |
+
const HELP: &str = "nanobrush inspect INPUT [--from auto|txt|dat] [LIMITS]\nnanobrush validate INPUT [--from auto|txt|dat] [LIMITS]\nnanobrush convert INPUT OUTPUT --to txt|dat-201501280|dat-201512200 [--from auto|txt|dat] [--force] [LIMITS]\nLIMITS: --max-bytes N --max-items N --max-string-bytes N --max-owned-bytes N\nUse -- before positional paths beginning with a dash. TXT input is UTF-8; output has a BOM.\nExit: 0 success/help, 1 invalid data or I/O failure, 2 usage error.\n";
|
| 11 |
+
#[derive(Debug)]
|
| 12 |
+
pub struct Error {
|
| 13 |
+
pub usage: bool,
|
| 14 |
+
message: String,
|
| 15 |
+
}
|
| 16 |
+
impl fmt::Display for Error {
|
| 17 |
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
| 18 |
+
f.write_str(&self.message)
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
impl std::error::Error for Error {}
|
| 22 |
+
impl From<io::Error> for Error {
|
| 23 |
+
fn from(e: io::Error) -> Self {
|
| 24 |
+
fail(e.to_string())
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
fn fail(message: impl Into<String>) -> Error {
|
| 28 |
+
Error {
|
| 29 |
+
usage: false,
|
| 30 |
+
message: message.into(),
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
fn usage(message: impl Into<String>) -> Error {
|
| 34 |
+
Error {
|
| 35 |
+
usage: true,
|
| 36 |
+
message: message.into(),
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
type Result<T> = std::result::Result<T, Error>;
|
| 40 |
+
#[derive(Clone, Copy)]
|
| 41 |
+
enum InputFormat {
|
| 42 |
+
Auto,
|
| 43 |
+
Txt,
|
| 44 |
+
Dat,
|
| 45 |
+
}
|
| 46 |
+
#[derive(Clone, Copy)]
|
| 47 |
+
enum To {
|
| 48 |
+
Txt,
|
| 49 |
+
Dat(Version),
|
| 50 |
+
}
|
| 51 |
+
struct Options {
|
| 52 |
+
command: String,
|
| 53 |
+
paths: Vec<PathBuf>,
|
| 54 |
+
from: InputFormat,
|
| 55 |
+
to: Option<To>,
|
| 56 |
+
force: bool,
|
| 57 |
+
limits: Limits,
|
| 58 |
+
}
|
| 59 |
+
fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Option<Options>> {
|
| 60 |
+
let mut args = args.into_iter();
|
| 61 |
+
let command = args.next().ok_or_else(|| usage(HELP))?;
|
| 62 |
+
if command == "--help" || command == "-h" {
|
| 63 |
+
return Ok(None);
|
| 64 |
+
}
|
| 65 |
+
let command = command
|
| 66 |
+
.into_string()
|
| 67 |
+
.map_err(|_| usage("command must be UTF-8"))?;
|
| 68 |
+
if !matches!(command.as_str(), "inspect" | "validate" | "convert") {
|
| 69 |
+
return Err(usage("expected inspect, validate or convert; use --help"));
|
| 70 |
+
}
|
| 71 |
+
let mut options = Options {
|
| 72 |
+
command,
|
| 73 |
+
paths: Vec::new(),
|
| 74 |
+
from: InputFormat::Auto,
|
| 75 |
+
to: None,
|
| 76 |
+
force: false,
|
| 77 |
+
limits: Limits::default(),
|
| 78 |
+
};
|
| 79 |
+
let mut positional = false;
|
| 80 |
+
let mut seen = std::collections::HashSet::new();
|
| 81 |
+
while let Some(arg) = args.next() {
|
| 82 |
+
if !positional && arg == "--" {
|
| 83 |
+
positional = true;
|
| 84 |
+
continue;
|
| 85 |
+
}
|
| 86 |
+
if !positional && (arg == "--help" || arg == "-h") {
|
| 87 |
+
return Ok(None);
|
| 88 |
+
}
|
| 89 |
+
if !positional && arg.as_encoded_bytes().starts_with(b"-") {
|
| 90 |
+
let flag = arg.to_str().ok_or_else(|| usage("option must be UTF-8"))?;
|
| 91 |
+
if !seen.insert(flag.to_owned()) {
|
| 92 |
+
return Err(usage(format!("duplicate option {flag}")));
|
| 93 |
+
}
|
| 94 |
+
if flag == "--force" {
|
| 95 |
+
options.force = true;
|
| 96 |
+
continue;
|
| 97 |
+
}
|
| 98 |
+
if !matches!(
|
| 99 |
+
flag,
|
| 100 |
+
"--from"
|
| 101 |
+
| "--to"
|
| 102 |
+
| "--max-bytes"
|
| 103 |
+
| "--max-items"
|
| 104 |
+
| "--max-string-bytes"
|
| 105 |
+
| "--max-owned-bytes"
|
| 106 |
+
) {
|
| 107 |
+
return Err(usage(format!("unknown option {flag}")));
|
| 108 |
+
}
|
| 109 |
+
let value = args
|
| 110 |
+
.next()
|
| 111 |
+
.ok_or_else(|| usage(format!("missing value for {flag}")))?;
|
| 112 |
+
let value = value
|
| 113 |
+
.to_str()
|
| 114 |
+
.ok_or_else(|| usage("option value must be UTF-8"))?;
|
| 115 |
+
match flag {
|
| 116 |
+
"--from" => {
|
| 117 |
+
options.from = match value {
|
| 118 |
+
"auto" => InputFormat::Auto,
|
| 119 |
+
"txt" => InputFormat::Txt,
|
| 120 |
+
"dat" => InputFormat::Dat,
|
| 121 |
+
_ => return Err(usage("--from requires auto, txt or dat")),
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
"--to" => {
|
| 125 |
+
options.to = Some(match value {
|
| 126 |
+
"txt" => To::Txt,
|
| 127 |
+
"dat-201501280" => To::Dat(Version::V201501280),
|
| 128 |
+
"dat-201512200" => To::Dat(Version::V201512200),
|
| 129 |
+
_ => {
|
| 130 |
+
return Err(usage("--to requires txt, dat-201501280 or dat-201512200"));
|
| 131 |
+
}
|
| 132 |
+
})
|
| 133 |
+
}
|
| 134 |
+
_ => {
|
| 135 |
+
if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
|
| 136 |
+
return Err(usage("limits must be nonnegative decimal integers"));
|
| 137 |
+
}
|
| 138 |
+
let n = value
|
| 139 |
+
.parse::<usize>()
|
| 140 |
+
.map_err(|_| usage("limit outside platform usize range"))?;
|
| 141 |
+
match flag {
|
| 142 |
+
"--max-bytes" => options.limits.max_bytes = n,
|
| 143 |
+
"--max-items" => options.limits.max_items = n,
|
| 144 |
+
"--max-string-bytes" => options.limits.max_string_bytes = n,
|
| 145 |
+
_ => options.limits.max_owned_bytes = n,
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
} else {
|
| 150 |
+
options.paths.push(arg.into());
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
if options.command == "convert" {
|
| 154 |
+
if options.paths.len() != 2 || options.to.is_none() {
|
| 155 |
+
return Err(usage("convert requires INPUT OUTPUT --to FORMAT"));
|
| 156 |
+
}
|
| 157 |
+
} else if options.paths.len() != 1 || options.to.is_some() || options.force {
|
| 158 |
+
return Err(usage(
|
| 159 |
+
"inspect/validate require one INPUT and no --to/--force",
|
| 160 |
+
));
|
| 161 |
+
}
|
| 162 |
+
Ok(Some(options))
|
| 163 |
+
}
|
| 164 |
+
struct Input {
|
| 165 |
+
world: World,
|
| 166 |
+
version: Option<Version>,
|
| 167 |
+
}
|
| 168 |
+
fn load(path: &Path, from: InputFormat, limits: &Limits) -> Result<Input> {
|
| 169 |
+
let mut file =
|
| 170 |
+
BufReader::new(File::open(path).map_err(|e| fail(format!("{}: {e}", path.display())))?);
|
| 171 |
+
let dat = match from {
|
| 172 |
+
InputFormat::Dat => true,
|
| 173 |
+
InputFormat::Txt => false,
|
| 174 |
+
InputFormat::Auto => file.fill_buf()?.starts_with(b"Mb3World"),
|
| 175 |
+
};
|
| 176 |
+
if dat {
|
| 177 |
+
let d =
|
| 178 |
+
mb3_dat::read(file, limits).map_err(|e| fail(format!("{}: {e}", path.display())))?;
|
| 179 |
+
Ok(Input {
|
| 180 |
+
world: d.world,
|
| 181 |
+
version: Some(d.version),
|
| 182 |
+
})
|
| 183 |
+
} else {
|
| 184 |
+
let world =
|
| 185 |
+
mb3_txt::read(file, limits).map_err(|e| fail(format!("{}: {e}", path.display())))?;
|
| 186 |
+
Ok(Input {
|
| 187 |
+
world,
|
| 188 |
+
version: None,
|
| 189 |
+
})
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
|
| 193 |
+
struct Temp(PathBuf);
|
| 194 |
+
impl Drop for Temp {
|
| 195 |
+
fn drop(&mut self) {
|
| 196 |
+
let _ = fs::remove_file(&self.0);
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
fn same_file(a: &fs::Metadata, b: &fs::Metadata) -> bool {
|
| 200 |
+
#[cfg(unix)]
|
| 201 |
+
{
|
| 202 |
+
use std::os::unix::fs::MetadataExt;
|
| 203 |
+
a.dev() == b.dev() && a.ino() == b.ino()
|
| 204 |
+
}
|
| 205 |
+
#[cfg(not(unix))]
|
| 206 |
+
{
|
| 207 |
+
let _ = (a, b);
|
| 208 |
+
false
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
fn save(input: &Path, output: &Path, bytes: &[u8], force: bool) -> Result<()> {
|
| 212 |
+
let parent = output
|
| 213 |
+
.parent()
|
| 214 |
+
.filter(|p| !p.as_os_str().is_empty())
|
| 215 |
+
.unwrap_or(Path::new("."));
|
| 216 |
+
let parent = fs::canonicalize(parent)?;
|
| 217 |
+
let name = output
|
| 218 |
+
.file_name()
|
| 219 |
+
.ok_or_else(|| fail("output requires a filename"))?;
|
| 220 |
+
let output = parent.join(name);
|
| 221 |
+
let input_meta = fs::metadata(input)?;
|
| 222 |
+
match fs::symlink_metadata(&output) {
|
| 223 |
+
Ok(meta) => {
|
| 224 |
+
if !meta.is_file() || meta.file_type().is_symlink() {
|
| 225 |
+
return Err(fail(
|
| 226 |
+
"output must be a regular file, not a symlink or directory",
|
| 227 |
+
));
|
| 228 |
+
}
|
| 229 |
+
if same_file(&input_meta, &meta)
|
| 230 |
+
|| fs::canonicalize(input)? == fs::canonicalize(&output)?
|
| 231 |
+
{
|
| 232 |
+
return Err(fail(
|
| 233 |
+
"input and output must be different files (including hard links)",
|
| 234 |
+
));
|
| 235 |
+
}
|
| 236 |
+
if !force {
|
| 237 |
+
return Err(fail("output exists; use --force to replace it"));
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
Err(e) if e.kind() == io::ErrorKind::NotFound => (),
|
| 241 |
+
Err(e) => return Err(e.into()),
|
| 242 |
+
}
|
| 243 |
+
let mut created = None;
|
| 244 |
+
for _ in 0..100 {
|
| 245 |
+
let path = parent.join(format!(
|
| 246 |
+
".nanobrush-{}-{}.tmp",
|
| 247 |
+
std::process::id(),
|
| 248 |
+
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
|
| 249 |
+
));
|
| 250 |
+
let mut options = OpenOptions::new();
|
| 251 |
+
options.create_new(true).write(true);
|
| 252 |
+
#[cfg(unix)]
|
| 253 |
+
{
|
| 254 |
+
use std::os::unix::fs::OpenOptionsExt;
|
| 255 |
+
options.mode(0o600);
|
| 256 |
+
}
|
| 257 |
+
match options.open(&path) {
|
| 258 |
+
Ok(file) => {
|
| 259 |
+
created = Some((Temp(path), file));
|
| 260 |
+
break;
|
| 261 |
+
}
|
| 262 |
+
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => (),
|
| 263 |
+
Err(e) => return Err(e.into()),
|
| 264 |
+
}
|
| 265 |
+
}
|
| 266 |
+
let (temp, mut file) =
|
| 267 |
+
created.ok_or_else(|| fail("cannot allocate a unique output temporary file"))?;
|
| 268 |
+
file.write_all(bytes)?;
|
| 269 |
+
file.sync_all()?;
|
| 270 |
+
drop(file);
|
| 271 |
+
publish(temp, &output, force)
|
| 272 |
+
}
|
| 273 |
+
fn publish(temp: Temp, output: &Path, force: bool) -> Result<()> {
|
| 274 |
+
if force {
|
| 275 |
+
fs::rename(&temp.0, output)?;
|
| 276 |
+
} else {
|
| 277 |
+
fs::hard_link(&temp.0, output)?;
|
| 278 |
+
}
|
| 279 |
+
Ok(())
|
| 280 |
+
}
|
| 281 |
+
/// Execute without exiting the process. Errors distinguish usage (exit 2) from
|
| 282 |
+
/// data/I/O (exit 1). Arguments exclude the executable's name.
|
| 283 |
+
pub fn run(args: impl IntoIterator<Item = OsString>, stdout: &mut dyn Write) -> Result<()> {
|
| 284 |
+
let Some(o) = parse(args)? else {
|
| 285 |
+
stdout.write_all(HELP.as_bytes())?;
|
| 286 |
+
return Ok(());
|
| 287 |
+
};
|
| 288 |
+
let input = load(&o.paths[0], o.from, &o.limits)?;
|
| 289 |
+
match o.command.as_str() {
|
| 290 |
+
"inspect" => {
|
| 291 |
+
let w = &input.world;
|
| 292 |
+
let planes: usize = w.brushes.iter().map(|b| b.planes.len()).sum();
|
| 293 |
+
let polygons: usize = w
|
| 294 |
+
.brushes
|
| 295 |
+
.iter()
|
| 296 |
+
.flat_map(|b| &b.planes)
|
| 297 |
+
.map(|p| p.polygon.as_ref().map_or(0, Vec::len))
|
| 298 |
+
.sum();
|
| 299 |
+
let anchors: usize = w.patches.iter().map(|p| p.anchors.len()).sum();
|
| 300 |
+
let points: usize = w
|
| 301 |
+
.patches
|
| 302 |
+
.iter()
|
| 303 |
+
.map(|p| p.computed.as_ref().map_or(0, |c| c.points.len()))
|
| 304 |
+
.sum();
|
| 305 |
+
let normals: usize = w
|
| 306 |
+
.patches
|
| 307 |
+
.iter()
|
| 308 |
+
.map(|p| p.computed.as_ref().map_or(0, |c| c.normals.len()))
|
| 309 |
+
.sum();
|
| 310 |
+
let version = input
|
| 311 |
+
.version
|
| 312 |
+
.map_or("null".into(), |v| (v as u32).to_string());
|
| 313 |
+
writeln!(
|
| 314 |
+
stdout,
|
| 315 |
+
"{{\"format\":\"{}\",\"dat_version\":{},\"computed\":{},\"brushes\":{},\"planes\":{},\"patches\":{},\"string_properties\":{},\"uv_properties\":{},\"polygon_vertices\":{},\"anchors\":{},\"points\":{},\"normals\":{}}}",
|
| 316 |
+
if input.version.is_some() {
|
| 317 |
+
"mb3dat"
|
| 318 |
+
} else {
|
| 319 |
+
"mb3txt"
|
| 320 |
+
},
|
| 321 |
+
version,
|
| 322 |
+
w.computed,
|
| 323 |
+
w.brushes.len(),
|
| 324 |
+
planes,
|
| 325 |
+
w.patches.len(),
|
| 326 |
+
w.string_properties.len(),
|
| 327 |
+
w.uv_properties.len(),
|
| 328 |
+
polygons,
|
| 329 |
+
anchors,
|
| 330 |
+
points,
|
| 331 |
+
normals
|
| 332 |
+
)?;
|
| 333 |
+
}
|
| 334 |
+
"validate" => writeln!(
|
| 335 |
+
stdout,
|
| 336 |
+
"valid: {}",
|
| 337 |
+
if input.version.is_some() {
|
| 338 |
+
"mb3dat"
|
| 339 |
+
} else {
|
| 340 |
+
"mb3txt"
|
| 341 |
+
}
|
| 342 |
+
)?,
|
| 343 |
+
_ => {
|
| 344 |
+
let bytes = match o.to.ok_or_else(|| usage("missing --to"))? {
|
| 345 |
+
To::Txt => {
|
| 346 |
+
mb3_txt::encode(&input.world, &o.limits).map_err(|e| fail(e.to_string()))?
|
| 347 |
+
}
|
| 348 |
+
To::Dat(version) => mb3_dat::encode(
|
| 349 |
+
&Document {
|
| 350 |
+
version,
|
| 351 |
+
world: input.world,
|
| 352 |
+
},
|
| 353 |
+
&o.limits,
|
| 354 |
+
)
|
| 355 |
+
.map_err(|e| fail(e.to_string()))?,
|
| 356 |
+
};
|
| 357 |
+
save(&o.paths[0], &o.paths[1], &bytes, o.force)?;
|
| 358 |
+
}
|
| 359 |
+
}
|
| 360 |
+
Ok(())
|
| 361 |
+
}
|
| 362 |
+
#[cfg(test)]
|
| 363 |
+
mod tests;
|
crates/nanobrush-cli/src/main.rs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fn main() -> std::process::ExitCode {
|
| 2 |
+
match nanobrush_cli::run(std::env::args_os().skip(1), &mut std::io::stdout().lock()) {
|
| 3 |
+
Ok(()) => std::process::ExitCode::SUCCESS,
|
| 4 |
+
Err(e) => {
|
| 5 |
+
eprintln!("{e}");
|
| 6 |
+
std::process::ExitCode::from(if e.usage { 2 } else { 1 })
|
| 7 |
+
}
|
| 8 |
+
}
|
| 9 |
+
}
|
crates/nanobrush-cli/src/tests.rs
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use super::*;
|
| 2 |
+
struct Dir(PathBuf);
|
| 3 |
+
impl Dir {
|
| 4 |
+
fn new() -> Self {
|
| 5 |
+
for _ in 0..100 {
|
| 6 |
+
let p = std::env::temp_dir().join(format!(
|
| 7 |
+
"nanobrush-cli-test-{}-{}",
|
| 8 |
+
std::process::id(),
|
| 9 |
+
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
|
| 10 |
+
));
|
| 11 |
+
match fs::create_dir(&p) {
|
| 12 |
+
Ok(()) => return Self(p),
|
| 13 |
+
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => (),
|
| 14 |
+
Err(e) => panic!("{e}"),
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
panic!("temp dir collision")
|
| 18 |
+
}
|
| 19 |
+
fn file(&self, name: &str, bytes: &[u8]) -> PathBuf {
|
| 20 |
+
let p = self.0.join(name);
|
| 21 |
+
fs::write(&p, bytes).unwrap();
|
| 22 |
+
p
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
impl Drop for Dir {
|
| 26 |
+
fn drop(&mut self) {
|
| 27 |
+
let _ = fs::remove_dir_all(&self.0);
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
fn call(args: &[&std::ffi::OsStr]) -> Result<Vec<u8>> {
|
| 31 |
+
let mut out = Vec::new();
|
| 32 |
+
run(args.iter().map(|s| s.to_os_string()), &mut out)?;
|
| 33 |
+
Ok(out)
|
| 34 |
+
}
|
| 35 |
+
const EMPTY: &[u8] = b"world(computedFeatures false)";
|
| 36 |
+
#[test]
|
| 37 |
+
fn inspect_validate_and_both_versions() {
|
| 38 |
+
let d = Dir::new();
|
| 39 |
+
let input = d.file("é space.txt", EMPTY);
|
| 40 |
+
let summary = call(&["inspect".as_ref(), input.as_os_str()]).unwrap();
|
| 41 |
+
assert!(
|
| 42 |
+
String::from_utf8(summary)
|
| 43 |
+
.unwrap()
|
| 44 |
+
.contains("\"dat_version\":null")
|
| 45 |
+
);
|
| 46 |
+
for format in ["dat-201501280", "dat-201512200"] {
|
| 47 |
+
let target = d.0.join(format);
|
| 48 |
+
call(&[
|
| 49 |
+
"convert".as_ref(),
|
| 50 |
+
input.as_os_str(),
|
| 51 |
+
target.as_os_str(),
|
| 52 |
+
"--to".as_ref(),
|
| 53 |
+
format.as_ref(),
|
| 54 |
+
])
|
| 55 |
+
.unwrap();
|
| 56 |
+
assert!(mb3_dat::decode(&fs::read(&target).unwrap(), &Limits::default()).is_ok());
|
| 57 |
+
call(&["validate".as_ref(), target.as_os_str()]).unwrap();
|
| 58 |
+
let txt = d.0.join(format!("{format}.txt"));
|
| 59 |
+
call(&[
|
| 60 |
+
"convert".as_ref(),
|
| 61 |
+
target.as_os_str(),
|
| 62 |
+
txt.as_os_str(),
|
| 63 |
+
"--to".as_ref(),
|
| 64 |
+
"txt".as_ref(),
|
| 65 |
+
])
|
| 66 |
+
.unwrap();
|
| 67 |
+
assert_eq!(
|
| 68 |
+
mb3_txt::decode(&fs::read(txt).unwrap(), &Limits::default()).unwrap(),
|
| 69 |
+
World::default()
|
| 70 |
+
);
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
#[test]
|
| 74 |
+
fn usage_and_explicit_limits() {
|
| 75 |
+
for args in [
|
| 76 |
+
vec![],
|
| 77 |
+
vec!["x"],
|
| 78 |
+
vec!["inspect"],
|
| 79 |
+
vec!["convert", "in", "out"],
|
| 80 |
+
vec!["inspect", "in", "--force"],
|
| 81 |
+
vec!["inspect", "in", "--max-items", "-1"],
|
| 82 |
+
vec!["inspect", "in", "--from", "txt", "--from", "dat"],
|
| 83 |
+
] {
|
| 84 |
+
let args: Vec<_> = args.iter().map(std::ffi::OsStr::new).collect();
|
| 85 |
+
assert!(call(&args).unwrap_err().usage);
|
| 86 |
+
}
|
| 87 |
+
assert!(!call(&["--help".as_ref()]).unwrap().is_empty());
|
| 88 |
+
let d = Dir::new();
|
| 89 |
+
let p = d.file("in", EMPTY);
|
| 90 |
+
assert!(
|
| 91 |
+
!call(&[
|
| 92 |
+
"validate".as_ref(),
|
| 93 |
+
p.as_os_str(),
|
| 94 |
+
"--max-bytes".as_ref(),
|
| 95 |
+
"1".as_ref()
|
| 96 |
+
])
|
| 97 |
+
.unwrap_err()
|
| 98 |
+
.usage
|
| 99 |
+
);
|
| 100 |
+
assert!(
|
| 101 |
+
call(&[
|
| 102 |
+
"validate".as_ref(),
|
| 103 |
+
p.as_os_str(),
|
| 104 |
+
"--from".as_ref(),
|
| 105 |
+
"dat".as_ref()
|
| 106 |
+
])
|
| 107 |
+
.is_err()
|
| 108 |
+
);
|
| 109 |
+
}
|
| 110 |
+
#[test]
|
| 111 |
+
fn no_clobber_force_and_same_file_protection() {
|
| 112 |
+
let d = Dir::new();
|
| 113 |
+
let input = d.file("in", EMPTY);
|
| 114 |
+
let output = d.file("out", b"ORIGINAL");
|
| 115 |
+
assert!(save(&input, &output, b"NEW", false).is_err());
|
| 116 |
+
assert_eq!(fs::read(&output).unwrap(), b"ORIGINAL");
|
| 117 |
+
save(&input, &output, b"NEW", true).unwrap();
|
| 118 |
+
assert_eq!(fs::read(&output).unwrap(), b"NEW");
|
| 119 |
+
assert!(save(&input, &input, b"BAD", true).is_err());
|
| 120 |
+
let link = d.0.join("hardlink");
|
| 121 |
+
fs::hard_link(&input, &link).unwrap();
|
| 122 |
+
assert!(save(&input, &link, b"BAD", true).is_err());
|
| 123 |
+
assert_eq!(fs::read(input).unwrap(), EMPTY);
|
| 124 |
+
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 3);
|
| 125 |
+
}
|
| 126 |
+
#[test]
|
| 127 |
+
fn failed_validation_creates_no_output_or_temporary() {
|
| 128 |
+
let d = Dir::new();
|
| 129 |
+
let input = d.file(
|
| 130 |
+
"in",
|
| 131 |
+
b"world(computedFeatures false stringProperties[\"p\"])",
|
| 132 |
+
);
|
| 133 |
+
let output = d.0.join("out");
|
| 134 |
+
assert!(
|
| 135 |
+
call(&[
|
| 136 |
+
"convert".as_ref(),
|
| 137 |
+
input.as_os_str(),
|
| 138 |
+
output.as_os_str(),
|
| 139 |
+
"--to".as_ref(),
|
| 140 |
+
"dat-201501280".as_ref()
|
| 141 |
+
])
|
| 142 |
+
.is_err()
|
| 143 |
+
);
|
| 144 |
+
assert!(!output.exists());
|
| 145 |
+
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1);
|
| 146 |
+
}
|
| 147 |
+
#[cfg(unix)]
|
| 148 |
+
#[test]
|
| 149 |
+
fn symlink_destinations_rejected() {
|
| 150 |
+
let d = Dir::new();
|
| 151 |
+
let input = d.file("in", EMPTY);
|
| 152 |
+
let target = d.file("target", b"ORIGINAL");
|
| 153 |
+
let link = d.0.join("link");
|
| 154 |
+
std::os::unix::fs::symlink(&target, &link).unwrap();
|
| 155 |
+
assert!(save(&input, &link, b"BAD", true).is_err());
|
| 156 |
+
assert_eq!(fs::read(target).unwrap(), b"ORIGINAL");
|
| 157 |
+
}
|
| 158 |
+
#[test]
|
| 159 |
+
fn failed_publication_cleans_temporary_and_preserves_existing() {
|
| 160 |
+
let d = Dir::new();
|
| 161 |
+
let input = d.file("in", EMPTY);
|
| 162 |
+
let output = d.0.join("out");
|
| 163 |
+
fs::create_dir(&output).unwrap();
|
| 164 |
+
assert!(save(&input, &output, b"NEW", false).is_err());
|
| 165 |
+
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 2);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
#[test]
|
| 169 |
+
fn publication_race_is_no_clobber_and_owned_temp_is_cleaned() {
|
| 170 |
+
let d = Dir::new();
|
| 171 |
+
let output = d.file("winner", b"EXISTING");
|
| 172 |
+
let path = d.file("staged", b"REPLACEMENT");
|
| 173 |
+
assert!(publish(Temp(path.clone()), &output, false).is_err());
|
| 174 |
+
assert!(!path.exists());
|
| 175 |
+
assert_eq!(fs::read(output).unwrap(), b"EXISTING");
|
| 176 |
+
}
|
| 177 |
+
#[test]
|
| 178 |
+
fn invalid_conversion_with_force_keeps_existing_destination() {
|
| 179 |
+
let d = Dir::new();
|
| 180 |
+
let input = d.file(
|
| 181 |
+
"unicode.txt",
|
| 182 |
+
"world(computedFeatures false stringProperties[\"é\"])".as_bytes(),
|
| 183 |
+
);
|
| 184 |
+
let output = d.file("out.dat", b"KEEP");
|
| 185 |
+
assert!(
|
| 186 |
+
call(&[
|
| 187 |
+
"convert".as_ref(),
|
| 188 |
+
input.as_os_str(),
|
| 189 |
+
output.as_os_str(),
|
| 190 |
+
"--to".as_ref(),
|
| 191 |
+
"dat-201512200".as_ref(),
|
| 192 |
+
"--force".as_ref()
|
| 193 |
+
])
|
| 194 |
+
.is_err()
|
| 195 |
+
);
|
| 196 |
+
assert_eq!(fs::read(output).unwrap(), b"KEEP");
|
| 197 |
+
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 2);
|
| 198 |
+
}
|
crates/nanobrush-cli/tests/fixtures.rs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::{fs, path::PathBuf, process::Command};
|
| 2 |
+
struct Dir(PathBuf);
|
| 3 |
+
impl Dir {
|
| 4 |
+
fn new() -> Self {
|
| 5 |
+
let p = std::env::temp_dir().join(format!("nanobrush-cli-fixture-{}", std::process::id()));
|
| 6 |
+
fs::create_dir(&p).expect("unique process temp directory");
|
| 7 |
+
Self(p)
|
| 8 |
+
}
|
| 9 |
+
}
|
| 10 |
+
impl Drop for Dir {
|
| 11 |
+
fn drop(&mut self) {
|
| 12 |
+
let _ = fs::remove_dir_all(&self.0);
|
| 13 |
+
}
|
| 14 |
+
}
|
| 15 |
+
fn root() -> PathBuf {
|
| 16 |
+
std::env::var_os("NANOBRUSH_CONVERTER_FIXTURES")
|
| 17 |
+
.map(PathBuf::from)
|
| 18 |
+
.unwrap_or_else(|| {
|
| 19 |
+
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/analysis/converter/probes")
|
| 20 |
+
})
|
| 21 |
+
}
|
| 22 |
+
#[test]
|
| 23 |
+
fn process_cli_inspects_validates_and_roundtrips_all_dat_outputs() {
|
| 24 |
+
let d = Dir::new();
|
| 25 |
+
let mut n = 0;
|
| 26 |
+
for build in ["2015-10-03", "2016-03-10-c"] {
|
| 27 |
+
for case in fs::read_dir(root().join(build)).expect("hydrate E02 assets") {
|
| 28 |
+
let input = case.unwrap().path().join("output.mb3dat");
|
| 29 |
+
if !input.exists() {
|
| 30 |
+
continue;
|
| 31 |
+
}
|
| 32 |
+
let txt = d.0.join(format!("{n}.txt"));
|
| 33 |
+
let dat = d.0.join(format!("{n}.dat"));
|
| 34 |
+
let result = Command::new(env!("CARGO_BIN_EXE_nanobrush"))
|
| 35 |
+
.arg("inspect")
|
| 36 |
+
.arg(&input)
|
| 37 |
+
.output()
|
| 38 |
+
.unwrap();
|
| 39 |
+
assert!(result.status.success(), "{:?}", result.stderr);
|
| 40 |
+
assert!(
|
| 41 |
+
String::from_utf8(result.stdout)
|
| 42 |
+
.unwrap()
|
| 43 |
+
.contains("\"format\":\"mb3dat\"")
|
| 44 |
+
);
|
| 45 |
+
let result = Command::new(env!("CARGO_BIN_EXE_nanobrush"))
|
| 46 |
+
.arg("convert")
|
| 47 |
+
.arg(&input)
|
| 48 |
+
.arg(&txt)
|
| 49 |
+
.args(["--to", "txt"])
|
| 50 |
+
.output()
|
| 51 |
+
.unwrap();
|
| 52 |
+
assert!(result.status.success(), "{:?}", result.stderr);
|
| 53 |
+
let result = Command::new(env!("CARGO_BIN_EXE_nanobrush"))
|
| 54 |
+
.arg("validate")
|
| 55 |
+
.arg(&txt)
|
| 56 |
+
.output()
|
| 57 |
+
.unwrap();
|
| 58 |
+
assert!(result.status.success(), "{:?}", result.stderr);
|
| 59 |
+
let result = Command::new(env!("CARGO_BIN_EXE_nanobrush"))
|
| 60 |
+
.arg("convert")
|
| 61 |
+
.arg(&txt)
|
| 62 |
+
.arg(&dat)
|
| 63 |
+
.args([
|
| 64 |
+
"--to",
|
| 65 |
+
if build.starts_with("2015") {
|
| 66 |
+
"dat-201501280"
|
| 67 |
+
} else {
|
| 68 |
+
"dat-201512200"
|
| 69 |
+
},
|
| 70 |
+
])
|
| 71 |
+
.output()
|
| 72 |
+
.unwrap();
|
| 73 |
+
assert!(result.status.success(), "{:?}", result.stderr);
|
| 74 |
+
assert_eq!(fs::read(&input).unwrap(), fs::read(dat).unwrap());
|
| 75 |
+
n += 1;
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
assert_eq!(n, 58);
|
| 79 |
+
}
|
| 80 |
+
#[test]
|
| 81 |
+
fn process_exit_codes_help_usage_and_bad_data() {
|
| 82 |
+
for (args, code) in [(vec!["--help"], 0), (vec![], 2), (vec!["unknown"], 2)] {
|
| 83 |
+
let r = Command::new(env!("CARGO_BIN_EXE_nanobrush"))
|
| 84 |
+
.args(args)
|
| 85 |
+
.output()
|
| 86 |
+
.unwrap();
|
| 87 |
+
assert_eq!(r.status.code(), Some(code));
|
| 88 |
+
}
|
| 89 |
+
let path = root().join("2016-03-10-c/bad-syntax/input.mb3txt");
|
| 90 |
+
let r = Command::new(env!("CARGO_BIN_EXE_nanobrush"))
|
| 91 |
+
.arg("validate")
|
| 92 |
+
.arg(path)
|
| 93 |
+
.output()
|
| 94 |
+
.unwrap();
|
| 95 |
+
assert_eq!(r.status.code(), Some(1));
|
| 96 |
+
assert!(r.stdout.is_empty());
|
| 97 |
+
assert!(
|
| 98 |
+
String::from_utf8(r.stderr)
|
| 99 |
+
.unwrap()
|
| 100 |
+
.contains("MB3 TXT at byte")
|
| 101 |
+
);
|
| 102 |
+
}
|
docs/CLI.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Native inspection, validation and conversion — F31
|
| 2 |
+
|
| 3 |
+
The `nanobrush` executable is built from `crates/nanobrush-cli`, backed by the strict [TXT](RUST_TXT.md) and [DAT](RUST_DAT.md) libraries. It supports TXT and DAT versions 201501280/201512200. MAP/VMF/OBJ, geometry evaluation, user-interface replay and the editor application remain future work.
|
| 4 |
+
|
| 5 |
+
Run from the repository:
|
| 6 |
+
|
| 7 |
+
```sh
|
| 8 |
+
nix run .#nanobrush -- --help
|
| 9 |
+
nix run .#nanobrush -- inspect assets/analysis/converter/probes/2016-03-10-c/properties-to-dat/output.mb3dat
|
| 10 |
+
nix run .#nanobrush -- validate path/to/world.mb3txt
|
| 11 |
+
nix run .#nanobrush -- convert input.mb3txt output.mb3dat --to dat-201512200
|
| 12 |
+
nix run .#nanobrush -- convert input.mb3dat output.mb3txt --to txt
|
| 13 |
+
nix run .#nanobrush -- convert input.mb3txt old.mb3dat --to dat-201501280
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
Inside `nix develop`, `cargo run -p nanobrush-cli -- ...` uses the same commands. `cargo build -p nanobrush-cli --release --locked --offline` produces `target/release/nanobrush`; `nix build .#nanobrush --no-link` builds an asset-free native package. No original executable is invoked by these commands.
|
| 17 |
+
|
| 18 |
+
## Interface
|
| 19 |
+
|
| 20 |
+
```text
|
| 21 |
+
nanobrush inspect INPUT [--from auto|txt|dat] [LIMITS]
|
| 22 |
+
nanobrush validate INPUT [--from auto|txt|dat] [LIMITS]
|
| 23 |
+
nanobrush convert INPUT OUTPUT --to txt|dat-201501280|dat-201512200
|
| 24 |
+
[--from auto|txt|dat] [--force] [LIMITS]
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
`inspect` strictly parses the complete document and prints a single JSON summary: source format, DAT version (null for versionless TXT), computed flag, brush/plane/patch counts, name-table sizes, contour vertices, anchors, points and normals. It does not dump string contents or every coordinate. `validate` uses the same parser and prints `valid: mb3txt` or `valid: mb3dat`; “valid” means structural codec validity, not a convex/usable editor world.
|
| 28 |
+
|
| 29 |
+
`--from auto` checks the exact `Mb3World` magic; otherwise it attempts TXT. Suffixes do not dispatch formats. `--from dat` forces DAT header/version validation and `--from txt` forces the TXT parser. DAT output always requires its exact version token; there is no ambiguous `--to dat`. Format tokens are case-sensitive. Options may appear before/after positional paths; `--` ends option processing for paths beginning with a dash. Paths use OS strings, including spaces and Unicode; options/format tokens must be UTF-8. Unknown or repeated options fail.
|
| 30 |
+
|
| 31 |
+
Exit codes are **0** for success/help, **1** for invalid documents or I/O/conversion failures, and **2** for usage errors. Diagnostics go to stderr; inspect/validate/help results go to stdout; successful conversion is quiet. This deliberately differs from the legacy converter's exit/log conventions. There is no log file to become stale. Broken stdout writes also return an I/O failure.
|
| 32 |
+
|
| 33 |
+
Optional limits are nonnegative decimal platform `usize` values:
|
| 34 |
+
|
| 35 |
+
```text
|
| 36 |
+
--max-bytes N # default 67108864, complete input/output
|
| 37 |
+
--max-string-bytes N # default 1048576, per decoded string
|
| 38 |
+
--max-items N # default 1000000, aggregate elements
|
| 39 |
+
--max-owned-bytes N # default 134217728, codec ownership/scratch accounting
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
The same limits apply to input parsing and output encoding; verbose TXT output can exceed the byte cap even if its DAT source fit. Auto detection uses a small buffered prefix; byte limits constrain codec buffers/file size, not blocking-I/O duration or process RSS. See codec documents for precise memory accounting. Lowering limits can intentionally reject otherwise structurally valid files. No automatic property optimization, dropped arrays, Unicode replacement, texture conversion or geometry synthesis takes place.
|
| 43 |
+
|
| 44 |
+
## Publication and source protection
|
| 45 |
+
|
| 46 |
+
Conversion parses and fully encodes before opening any destination temporary. Existing outputs are refused by default. `--force` authorizes replacement of a separate regular file; **same-file paths and hard links to the input are always rejected on macOS/Linux**, and output symlinks/directories are rejected. The input path is not written by conversion. Tests use disposable destinations and never choose reference-corpus paths as outputs.
|
| 47 |
+
|
| 48 |
+
After validation, the CLI creates an exclusively owned temporary in the destination's existing parent directory, writes all bytes, syncs the file and closes it. New Unix files are private (mode 0600); replacement also takes that mode rather than inheriting old permissions. Default publication uses a same-directory hard link, atomically failing if the destination appeared concurrently. `--force` uses same-directory rename for atomic replacement. Temporary cleanup is best effort through an ownership guard, including error paths. Staged names are bounded to 100 exclusive-create attempts; no preexisting temporary is deleted.
|
| 49 |
+
|
| 50 |
+
A filesystem without same-directory hard-link support returns an error for no-clobber publication rather than falling back to a partial visible output. File synchronization plus publication provides atomic visibility; directory metadata is not separately synced, so full power-loss durability is not claimed. The workflow assumes the caller's parent directory is trusted against hostile concurrent directory/symlink replacement. It is not an openat-based sandbox. Filesystem interruption/disk-failure matrices remain future work.
|
| 51 |
+
|
| 52 |
+
Incompatible exports fail before publication: for example, Unicode TXT→ASCII DAT, property-bearing data→old DAT, DAT duplicate names→TXT, non-finite floats and inconsistent computed arrays. There is no silent drop or replacement option. Existing output bytes remain untouched on parser/encoder failure even with `--force`.
|
| 53 |
+
|
| 54 |
+
## Tests
|
| 55 |
+
|
| 56 |
+
Eight asset-free unit tests cover options, limits, inspection, both DAT directions, Unicode paths, overwrite/same-file/hard-link/symlink guards, validation before any output, preservation of existing destinations after incompatible conversion, exclusive publication races and temporary cleanup. Two process integration tests check documented exit streams/codes and invoke the real binary for inspect, DAT→TXT, validate and TXT→DAT against **all 58 DAT outputs** (232 successful commands), verifying exact final DAT bytes. Fixture tests fail when assets are missing. See [E04](experiments/txt-cli-2026-10-21.md).
|
docs/FINDINGS.md
CHANGED
|
@@ -157,3 +157,7 @@ The [plugin map](PLUGINS.md) accounts for 17 top-level plugins, five experimenta
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 160 |
+
|
| 161 |
+
## F30/F31 — TXT serialization and native CLI
|
| 162 |
+
|
| 163 |
+
The [TXT codec](RUST_TXT.md) implements the latest grammar and old property-free subset with bounded UTF-8 parsing, canonical BOM output, finite round-trip decimals and consistent computed arrays. The [CLI](CLI.md) inspects/validates and converts TXT plus both DAT versions, refusing silent loss and publishing validated outputs with explicit no-clobber/force behavior. [E04](experiments/txt-cli-2026-10-21.md) covers 58 exact DAT→TXT→DAT round trips through the real process, seven TXT outputs, 16 direct TXT→DAT pairs and nine invalid/lossy source rejections.
|
docs/FORMATS.md
CHANGED
|
@@ -80,3 +80,7 @@ Keymaps bind comma-separated key sequences to named or numeric commands; leading
|
|
| 80 |
`brushes.shader` and `patch9s.shader` have `@vertex330` and `@fragment330` sections and a std140 matrix block; `grid.shader` has the same stage names and many grid/camera/snap uniforms. Host binding, compilation defines and complete matrix conventions remain unknown.
|
| 81 |
|
| 82 |
`brushes.shader:92–106` uses face IDs 0/1/2/3 to distinguish unselected/main/set-1/set-2 visualization, including screen-space stripe patterns for auxiliary sets. `patch9s.shader:35–41` calculates a lighting value but writes the normal-color expression instead; translating the unused lighting expression as the visible shader would be incorrect. `brushes.shader:71–76` has a `WA_ATI` expression variant. Preserve observable output contracts separately from obsolete driver workarounds.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
`brushes.shader` and `patch9s.shader` have `@vertex330` and `@fragment330` sections and a std140 matrix block; `grid.shader` has the same stage names and many grid/camera/snap uniforms. Host binding, compilation defines and complete matrix conventions remain unknown.
|
| 81 |
|
| 82 |
`brushes.shader:92–106` uses face IDs 0/1/2/3 to distinguish unselected/main/set-1/set-2 visualization, including screen-space stripe patterns for auxiliary sets. `patch9s.shader:35–41` calculates a lighting value but writes the normal-color expression instead; translating the unused lighting expression as the visible shader would be incorrect. `brushes.shader:71–76` has a `WA_ATI` expression variant. Preserve observable output contracts separately from obsolete driver workarounds.
|
| 83 |
+
|
| 84 |
+
## Native implementation extension — F30/F31
|
| 85 |
+
|
| 86 |
+
The [strict TXT profile](RUST_TXT.md) and [native CLI](CLI.md) implement the recovered TXT and DAT structures with explicit compatibility differences and guarded publication. Source grammars and legacy converter behavior above remain historical evidence; native formatting, Unicode handling, computed consistency and loss refusal follow the documented F30/F31 profile. E04 adds fixture/process tests without claiming native-editor conformance.
|
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; safe Rust DAT readers/writers are implemented; other
|
| 6 |
|
| 7 |
| Document | Purpose |
|
| 8 |
| --- | --- |
|
|
@@ -20,6 +20,8 @@ The executed converter investigation adds [MB3 DAT layouts](MB3_DAT.md), [conver
|
|
| 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
|
|
|
|
| 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/TXT readers/writers and a native inspection/conversion CLI are implemented; the other codecs and editor application remain future work.
|
| 6 |
|
| 7 |
| Document | Purpose |
|
| 8 |
| --- | --- |
|
|
|
|
| 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 [TXT codec](RUST_TXT.md) and [native CLI](CLI.md) now add UTF-8 serialization, inspection/validation, explicit DAT version conversion and guarded atomic publication. See [E04](experiments/txt-cli-2026-10-21.md) for the fixture matrix and compatibility policy.
|
| 24 |
+
|
| 25 |
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.
|
| 26 |
|
| 27 |
## Status vocabulary
|
docs/RUST_DAT.md
CHANGED
|
@@ -81,4 +81,4 @@ Validation:
|
|
| 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`
|
|
|
|
| 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 DAT crate’s 24 unit tests and one doctest run inside the asset-free `checks.<system>.rust` workspace check, which now includes TXT/CLI tests (44 unit tests and two doctests in E04). Formatting and Clippy cover all targets. 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, full crash-durability workflows and the remaining MAP/VMF/OBJ codecs stay open. TXT serialization and the headless CLI are now implemented in [F30](RUST_TXT.md)/[F31](CLI.md). The declared Rust minimum is 1.85; the measured pinned toolchain is 1.99.0, not a separate minimum-version validation.
|
docs/RUST_TXT.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Rust MB3 TXT codec — F30
|
| 2 |
+
|
| 3 |
+
`crates/mb3-txt` implements the supplied latest TXT grammar and the older property-free subset, using the existing `mb3_dat::World` model. TXT has no format-version header; the reader does not guess a historical release. The [CLI](CLI.md) converts TXT to either explicit DAT version. The only Cargo dependency is the local DAT model/codec crate; no third-party runtime packages, original converter, graphics stack or .NET runtime are required.
|
| 4 |
+
|
| 5 |
+
## Public API
|
| 6 |
+
|
| 7 |
+
```rust
|
| 8 |
+
use mb3_dat::{Limits, World};
|
| 9 |
+
let world = World::default();
|
| 10 |
+
let bytes = mb3_txt::encode(&world, &Limits::default())?;
|
| 11 |
+
assert_eq!(mb3_txt::decode(&bytes, &Limits::default())?, world);
|
| 12 |
+
# Ok::<(), mb3_txt::Error>(())
|
| 13 |
+
```
|
| 14 |
+
|
| 15 |
+
`decode` / `encode` work on bytes; `read` / `write` support standard Rust I/O, short operations and `Interrupted`. Input must be UTF-8, with an optional leading BOM. Output is canonical **UTF-8 with a BOM**, LF line endings and shortest round-tripping finite f32 decimals. Invariant number formatting preserves finite f32 values, signed zero and subnormals. Comments, whitespace, original decimal spelling and brush/patch interleaving are not retained: the model stores brushes and patches separately, and the writer emits brushes first. Names, their original table order, unused names, property indices, empty versus absent values, and all explicit geometry arrays are retained.
|
| 16 |
+
|
| 17 |
+
The parser accepts line/block comments, exact case-sensitive keywords, signed decimal integers, decimal-point/exponent floats, ordinary strings and the six supplied escape forms (`\r`, `\n`, `\t`, `\'`, `\"`, `\\`). Single quotes inside strings must be escaped, as in the supplied grammar. Strings may contain Unicode directly; general `\u`, hex and unknown escapes are rejected. Non-nested block comments terminate at the first closing delimiter. Line comments may end at EOF. Unknown fields, trailing objects/text, malformed punctuation and duplicate property names/slots are errors. Sparse slots can arrive out of index order and are sorted after parsing; writer input requires unique increasing indices.
|
| 18 |
+
|
| 19 |
+
Plane tuples are P0/U/V, and UV tuples are axis/scale/shift pairs; the shared model names these fields to avoid confusing them with DAT's ten-float wire order. Dimension/slot integers must be nonnegative i32 values. Numeric tokens are capped at 128 bytes; integer tokens with leading zeroes are rejected rather than imitating possible token splitting by the historical lexer. Finite floats can have leading zeroes before a decimal point. No expression language, recursive nesting of brushes/worlds, general parser AST, geometry calculation or property optimization is introduced.
|
| 20 |
+
|
| 21 |
+
Computed features are a consistency contract: each plane must have a polygon iff the world flag is true, and each patch must have both points/normals iff true. Anchor lengths must equal the dimensions' product; point and normal lengths must equal the recovered grid formula. Missing arrays and optional computed arrays under a false flag are rejected, instead of being synthesized, ignored or serialized incoherently. This validates structure, not convexity, geometric degeneracy, normal lengths, patch smoothness or editor usability. Zero/even dimensions remain representable with matching arrays.
|
| 22 |
+
|
| 23 |
+
## Resource and error policy
|
| 24 |
+
|
| 25 |
+
The API shares `mb3_dat::Limits`: 64 MiB input/output, 1 MiB per decoded UTF-8 string, 1,000,000 aggregate logical elements and 128 MiB requested decoder-owned storage by default. Limits are caller policy. Element counts cover names, parent records, sparse entries and vector-array elements. Geometric vector/string capacity growth and string payloads are charged before fallible reservations. Name uniqueness uses a fallibly allocated, budgeted index scratch array and sorting; duplicate slots sort once rather than repeatedly scanning earlier entries.
|
| 26 |
+
|
| 27 |
+
Storage accounting includes decoder capacity and name-validation scratch requests, excluding the caller's input, allocator overhead and transient reallocations. `read` owns a separate bounded input buffer. Writer output has its own byte limit; writer-owned name-validation scratch uses the owned-storage limit, while the caller already owns the model. These are explicit accounting limits, not a precise RSS limit. There is no recursive input traversal. All numeric sums/products needed for array sizes are checked. String scanning never loops at EOF.
|
| 28 |
+
|
| 29 |
+
`Error` carries a byte offset and typed `ErrorKind`, including syntax, UTF-8, escape, number, limits, allocation, property, computed-data and I/O errors. Byte positions include an input BOM. `write` validates and buffers before writing; it does not flush or make file writes atomic. Later I/O failure can leave a prefix; use the CLI's publication workflow or implement the equivalent at the caller.
|
| 30 |
+
|
| 31 |
+
## Compatibility differences
|
| 32 |
+
|
| 33 |
+
| Area | Recovered converter | Native TXT codec |
|
| 34 |
+
| --- | --- | --- |
|
| 35 |
+
| Encoding | Guesses Latin-1 without an initial UTF-8 marker; its BOM-less UTF-8 output can drift on reread | Strict UTF-8 input; BOM output protects Unicode when handed to the latest converter's documented detection path |
|
| 36 |
+
| ASCII DAT conversion | Replaces non-ASCII with `?` | TXT retains Unicode; conversion to DAT fails explicitly, preserving source/destination |
|
| 37 |
+
| Property tables | Latest CLI drops unused names and reorders by usage | Retains table order/unused names and index meaning |
|
| 38 |
+
| Duplicate names | DAT can contain duplicate names, TXT loader rejects them | TXT reader/writer rejects them; DAT→TXT can therefore fail on an otherwise valid DAT document |
|
| 39 |
+
| Computed flag | False can discard optional arrays; true can crash if arrays omitted | Requires arrays to agree with flag; never silently drops supplied data |
|
| 40 |
+
| Patch lengths | TXT constructors can retain inconsistent arrays and emit invalid DAT | Checks exact array lengths with checked products |
|
| 41 |
+
| Numbers | Invariant f32 parsing; runtime-dependent default printing, no explicit finite policy | Finite f32 only, shortest round-tripping output, 128-byte token limit, explicit integer bounds |
|
| 42 |
+
| Historical syntax | 2015 grammar lacks names/strings/comments | Reader is the latest superset; property-free output omits tables and comments so it fits the old syntax |
|
| 43 |
+
| Formatting | Historical layout/newlines/decimal output | Stable canonical layout and BOM; semantic TXT comparison, not byte-identical text |
|
| 44 |
+
|
| 45 |
+
Native Unicode output follows the recovered grammar/encoding contract; a broad new Mono/Windows output-consumption campaign was not performed in this stage. The E02 Unicode inputs demonstrate the legacy encoding difference. There is no lossy conversion switch: callers must explicitly transform unsupported data before a DAT downgrade or Unicode-to-ASCII export.
|
| 46 |
+
|
| 47 |
+
## Fixture validation (E04)
|
| 48 |
+
|
| 49 |
+
- All **58 DAT output fixtures** decode, serialize to TXT, reparse and encode back to the same DAT version with **exact original bytes**. This includes UV/property ordering, computed brush/patch data, old/new hierarchy differences and compact widths.
|
| 50 |
+
- All **seven converter TXT outputs** parse and stabilize under native canonicalization; text bytes intentionally differ.
|
| 51 |
+
- **16 TXT-input/DAT-output pairs** match exact converter DAT bytes where the input did not undergo property optimization or character loss.
|
| 52 |
+
- **Nine** invalid or silently lossy TXT source fixtures are rejected, including duplicate names/slots, bad indices, missing computed arrays, syntax errors and ignored polygons.
|
| 53 |
+
- The property-optimization fixture retains `[unused, rare, common]`, while the original CLI emitted `[common, rare]`; Unicode source fixtures retain `é`, while DAT conversion refuses replacement and the historical BOM-less path drifted.
|
| 54 |
+
|
| 55 |
+
Twelve unit tests cover lexer errors, comments, exponent syntax, escapes/UTF-8, finite extremes, signed zero, proper-prefix truncations, all resource policies, array consistency, streaming I/O, 3,000 bounded deterministic mutations and rejection of recursive-looking syntax. Five integration tests require hydrated E02 fixtures. Coverage-guided fuzzing, large real TXT files, explicit legacy Latin-1 recoding, native-editor interoperability and cross-platform execution remain open. See [E04](experiments/txt-cli-2026-10-21.md) for reproduction.
|
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). The
|
| 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
|
|
@@ -35,7 +35,7 @@ Build outputs and disposable runtime copies remain ignored. Keep immutable refer
|
|
| 35 |
|
| 36 |
## Environment shape
|
| 37 |
|
| 38 |
-
Use `flake-parts` and `github:nmrshll/my-nix`, following the current local exports `flakeModuleGroups.essentials` and `flakeModules.rust`. Keep nixpkgs and flake-parts follows consistent.
|
| 39 |
|
| 40 |
Pin remote inputs in `flake.lock`. An optional developer `.envrc` helper may watch local `my-nix` / tools checkouts and pass overrides without rewriting the shared lock; the committed flake remains usable without those checkouts. Separate platform-specific graphics libraries from portable archive/source tooling.
|
| 41 |
|
|
@@ -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 and a converter-only Mono oracle are implemented. The
|
| 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 `mb3-dat` and `mb3-txt` crates plus `nanobrush-cli` now implement native TXT/DAT format handling ([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/ # native DAT/TXT libraries and nanobrush CLI
|
| 19 |
assets/references/ # every file is Xet-backed, including small text
|
| 20 |
archives/ # unchanged ZIPs
|
| 21 |
provenance/collection/ # original reports/manifest/checksum files
|
|
|
|
| 35 |
|
| 36 |
## Environment shape
|
| 37 |
|
| 38 |
+
Use `flake-parts` and `github:nmrshll/my-nix`, following the current local exports `flakeModuleGroups.essentials` and `flakeModules.rust`. Keep nixpkgs and flake-parts follows consistent. Rust checks and the CLI package use the same pinned toolchain and explicit asset-free source fileset.
|
| 39 |
|
| 40 |
Pin remote inputs in `flake.lock`. An optional developer `.envrc` helper may watch local `my-nix` / tools checkouts and pass overrides without rewriting the shared lock; the committed flake remains usable without those checkouts. Separate platform-specific graphics libraries from portable archive/source tooling.
|
| 41 |
|
|
|
|
| 43 |
|
| 44 |
## Planned commands and responsibilities
|
| 45 |
|
| 46 |
+
Setup/storage/hydration/reference commands and a converter-only Mono oracle are implemented. The native CLI supports inspect/validate/convert for TXT and both DAT versions, with a Nix package; other formats and editor/replay interfaces remain future work. The remaining asset-conversion, general oracle/replay and GUI-packaging interfaces below remain proposed.
|
| 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 |
|
|
@@ -130,8 +130,8 @@ Each task ID is stable. Split tasks into child IDs as evidence grows. Record dep
|
|
| 130 |
|
| 131 |
**Depends on:** P3 oracle where available, P5 document model. **Exit:** standalone format specifications, declared compatibility subsets, fixture corpus, and native codecs.
|
| 132 |
|
| 133 |
-
- [ ] P6.01 Specify common encoding, BOM/newline/comment/escape rules, number parsing/printing, path behavior, extension/type dispatch, size limits, and diagnostics. E02 establishes Latin-1/UTF-8 selection, ASCII DAT loss, invariant floats and CLI/log behavior;
|
| 134 |
-
- [
|
| 135 |
- [x] P6.03 Determine converter MB3 DAT magic/version/endian/width/string representation, record hierarchy, counts/references, optional computed data, truncation behavior, and recovered version differences. [MB3_DAT.md](MB3_DAT.md), E02; native editor validation and hostile-file coverage continue in P6.19/P6.20.
|
| 136 |
- [ ] P6.04 Specify supported HL1 MAP dialect, plane/UV conventions, material/entity handling, coordinate mapping, and input that parses but loses information.
|
| 137 |
- [ ] P6.05 Specify supported HL2 VMF subset, nested blocks/IDs, solids/sides, axes, entities, displacement/extra fields, and reference drop behavior; distinguish grammar acceptance from semantic preservation.
|
|
@@ -144,7 +144,7 @@ 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. Both DAT codecs and
|
| 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).
|
|
@@ -155,6 +155,8 @@ Each task ID is stable. Split tasks into child IDs as evidence grows. Record dep
|
|
| 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 |
|
| 160 |
**Depends on:** P4 dispatch and P5 geometry. **Exit:** complete interaction/state-machine specifications and reproducible replays.
|
|
@@ -314,4 +316,4 @@ Each task ID is stable. Split tasks into child IDs as evidence grows. Record dep
|
|
| 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.
|
|
|
|
| 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–F31 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 |
|
|
|
|
| 130 |
|
| 131 |
**Depends on:** P3 oracle where available, P5 document model. **Exit:** standalone format specifications, declared compatibility subsets, fixture corpus, and native codecs.
|
| 132 |
|
| 133 |
+
- [ ] P6.01 Specify common encoding, BOM/newline/comment/escape rules, number parsing/printing, path behavior, extension/type dispatch, size limits, and diagnostics. E02 establishes Latin-1/UTF-8 selection, ASCII DAT loss, invariant floats and CLI/log behavior; F30/F31 now define native TXT/DAT number/encoding limits and diagnostics; other legacy formats and platform measurements remain open.
|
| 134 |
+
- [x] P6.02 Specify the adopted MB3 TXT grammar/semantics: computed flags/arrays, named tables, P0/U/V, string/UV slots, patches, unknown fields and missing values. F30/E04 implements a strict UTF-8 profile and documents differences from legacy guessing, optimization and ignored arrays; broader historical/platform measurements remain in P6.20.
|
| 135 |
- [x] P6.03 Determine converter MB3 DAT magic/version/endian/width/string representation, record hierarchy, counts/references, optional computed data, truncation behavior, and recovered version differences. [MB3_DAT.md](MB3_DAT.md), E02; native editor validation and hostile-file coverage continue in P6.19/P6.20.
|
| 136 |
- [ ] P6.04 Specify supported HL1 MAP dialect, plane/UV conventions, material/entity handling, coordinate mapping, and input that parses but loses information.
|
| 137 |
- [ ] P6.05 Specify supported HL2 VMF subset, nested blocks/IDs, solids/sides, axes, entities, displacement/extra fields, and reference drop behavior; distinguish grammar acceptance from semantic preservation.
|
|
|
|
| 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 are implemented in P6.21; TXT and the native inspect/validate/convert CLI are implemented in P6.22. Remaining codecs and coverage-guided fuzzing stay 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).
|
|
|
|
| 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 |
+
- [x] P6.22 Implement bounded Rust TXT readers/writers and native `nanobrush inspect/validate/convert` for TXT and both DAT versions, with explicit Unicode/loss policies, structural validation, guarded atomic publication and Nix packaging. All 58 DAT outputs survive CLI DAT→TXT→DAT exactly; seven TXT outputs and 16 direct TXT→DAT pairs are covered; nine invalid/lossy inputs are rejected ([RUST_TXT.md](RUST_TXT.md), [CLI.md](CLI.md), E04/F30/F31).
|
| 159 |
+
|
| 160 |
## P7 — Input, cameras, grids, views, and editing workflows
|
| 161 |
|
| 162 |
**Depends on:** P4 dispatch and P5 geometry. **Exit:** complete interaction/state-machine specifications and reproducible replays.
|
|
|
|
| 316 |
| M6 Full reconstruction | All required P12/P13 modules and workflows pass parity and GPU checks |
|
| 317 |
| M7 Distributable release | P14–P16 regeneration, packaging, documentation, clean-machine acceptance |
|
| 318 |
|
| 319 |
+
Next work after E02: validate the Rust DAT codecs against native-editor output, extend fuzzing/platform coverage and implement the remaining MAP/VMF/OBJ/IXModel 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/txt-cli-2026-10-21.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# E04 — TXT codec and native conversion CLI
|
| 2 |
+
|
| 3 |
+
Date: 2026-10-21. Implements F30/F31: bounded TXT serialization and a native inspect/validate/convert workflow. Source anchors are the two supplied TXT grammars and E02 converter behavior, plus the shared F29 DAT model. No original binary was executed or modified by this stage; E02's existing Xet fixtures were reused.
|
| 4 |
+
|
| 5 |
+
## Reproduction
|
| 6 |
+
|
| 7 |
+
In the pinned Nix development environment (Rust/Cargo 1.99.0, macOS ARM64):
|
| 8 |
+
|
| 9 |
+
```sh
|
| 10 |
+
analyze-converter check
|
| 11 |
+
cargo fmt --all --check
|
| 12 |
+
cargo clippy --workspace --all-targets --locked --offline -- -D warnings
|
| 13 |
+
cargo test --workspace --locked --offline
|
| 14 |
+
cargo test --workspace --release --locked --offline
|
| 15 |
+
nix build .#checks.aarch64-darwin.rust .#checks.aarch64-darwin.setup .#nanobrush --no-link
|
| 16 |
+
nix run .#nanobrush -- --help
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
Full integration tests require the hydrated `assets/analysis/converter/probes` tree. `NANOBRUSH_CONVERTER_FIXTURES` can select an explicitly supplied alternative. E02's SHA-256-indexed report remains the identity anchor; `analyze-converter check` verifies input/output hashes and original converter identities. No golden fixture payload was moved into normal Git or embedded into a source file. Synthetic tests construct their own small grammar examples and boundary inputs.
|
| 20 |
+
|
| 21 |
+
## Evidence and acceptance
|
| 22 |
+
|
| 23 |
+
- 58 original DAT outputs survive native DAT→TXT→DAT with exact byte equality, both through libraries and through the real CLI process. Every successful CLI conversion also validates its text output; inspect JSON summaries identify the source format.
|
| 24 |
+
- All seven supplied converter TXT outputs parse and stabilize under native formatting. Sixteen non-lossy TXT→DAT fixture pairs reproduce exact reference output. Nine invalid or silently lossy TXT inputs are rejected.
|
| 25 |
+
- UTF-8 input preserves Unicode rather than reproducing Latin-1 guessing; TXT output includes a BOM. ASCII DAT export refuses character loss. Properties retain unused names and original index meaning, and down-conversion refuses to discard properties.
|
| 26 |
+
- Parser limits include UTF-8 string bytes, total logical elements, requested ownership/scratch and complete input/output bytes. Numbers are bounded to 128 token bytes and finite f32; integer fields are nonnegative i32. Arrays must agree with computed flags and dimensions. There is no recursive parse tree. Three thousand deterministic TXT mutations join the prior 5,000 DAT mutation smoke cases; neither is coverage-guided fuzzing.
|
| 27 |
+
- The CLI validates/encodes before destination staging, refuses accidental replacement, rejects source aliases/output symlinks, and atomically publishes through hard-link/no-clobber or explicit-force rename. A publication test exercises an existing destination after staging and verifies that existing bytes survive and the owned temp is removed.
|
| 28 |
+
|
| 29 |
+
The workspace has 44 unit tests (24 DAT, 12 TXT, eight CLI), 12 integration tests (five DAT, five TXT, two CLI) and two doctests: **58 Rust tests**. Code-only Nix tests intentionally run the 44 unit tests/two doctests and compile/lint integration targets without opening assets; full fixture tests run separately. The unchanged Python suite has 52 regressions. Final observed results are recorded in [validation](../validation.md).
|
| 30 |
+
|
| 31 |
+
## Limits
|
| 32 |
+
|
| 33 |
+
This establishes codec and CLI behavior for the supplied fixture domain and authored adversarial cases, not native editor interoperability, historical byte-identical TXT printing or every possible filesystem failure. The native codec's encoding, strict computed consistency, number limits, preserved properties and canonical layout differ intentionally from the legacy converters; see [F30](../RUST_TXT.md) and [F31](../CLI.md). MAP/VMF/OBJ, native document schema, general loss reports, coverage-guided fuzzing, legacy Latin-1 recoding, independent consumer checks, native-editor tests and Linux/Intel execution remain open. The Nix package contains the native CLI only; no Bevy/Solari/editor feature is claimed by this stage.
|
docs/parity.md
CHANGED
|
@@ -41,3 +41,7 @@ Keep the broad parent TODO tasks open until their promised behavioral evidence e
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 44 |
+
|
| 45 |
+
## TXT and CLI implementation (E04)
|
| 46 |
+
|
| 47 |
+
The native TXT codec and CLI cover all 58 E02 DAT outputs through exact DAT→TXT→DAT reconstruction, seven text outputs through canonical semantic round trips, 16 direct TXT-input/DAT-output pairs and nine rejected malformed/lossy text inputs. Original formatting, guessed encodings, ignored arrays and property optimization are explicit differences in F30. CLI no-clobber/force publication, alias/symlink guards, validation before I/O and exit streams are tested separately in F31. Legacy external formats, editor behavior and other-platform execution remain open.
|
docs/risks.md
CHANGED
|
@@ -20,3 +20,5 @@ Opened 2026-10-21. Owner: Nanobrush reference research. This register tracks con
|
|
| 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 |
|
|
|
|
|
|
|
|
|
| 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 |
|
| 23 |
+
| R17 | Native TXT uses strict UTF-8/BOM output, retained table indices and computed-array consistency (F30) | Latin-1 inputs or permissive/lossy historical cases need explicit recoding/diagnosis | Broader real-file/legacy interoperability and fuzzing, P6.20 |
|
| 24 |
+
| R18 | CLI publication uses hard-link/rename in a trusted parent; directory metadata not separately synced (F31) | Filesystem support, hostile directory races and power-loss durability differ by environment | Filesystem/platform failure matrix, P6.12/P15 |
|
docs/validation.md
CHANGED
|
@@ -25,3 +25,9 @@ E02 final checks: all 52 tests passed on the host and in the Apple Silicon code-
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 28 |
+
|
| 29 |
+
## E04 TXT and native CLI — 2026-10-21
|
| 30 |
+
|
| 31 |
+
[TXT compatibility](RUST_TXT.md), [CLI workflow](CLI.md) and [E04](experiments/txt-cli-2026-10-21.md) record the implementation. Full debug and release suites each pass **58 Rust tests**: 44 unit tests, 12 fixture/process integration tests and two doctests. All 58 DAT outputs survive exact DAT→TXT→DAT reconstruction through the real executable, seven converter TXT outputs canonicalize stably, 16 direct TXT→DAT pairs match and nine invalid/lossy TXT source fixtures are rejected. The TXT suite adds 3,000 bounded mutations and streaming/array/encoding limits; CLI tests cover publication races, aliases, symlinks, no-clobber and preserving existing destinations on encoder failure.
|
| 32 |
+
|
| 33 |
+
Formatting/Clippy pass. Both Apple Silicon Nix checks and the asset-free `nanobrush` package build pass; the Rust check runs 44 unit tests/two doctests, while setup retains all 52 Python regressions. The packaged executable’s help and JSON inspection of the property fixture ran successfully. Converter fixture identity/semantic checks and all 1,082 hydrated asset hashes pass; original captures are unchanged and tests/code remain ordinary Git. Other-platform execution, native-editor interoperability, crash-durability/failure matrices and coverage-guided fuzzing remain open.
|
flake.nix
CHANGED
|
@@ -152,9 +152,28 @@
|
|
| 152 |
cargo test --workspace --doc --locked --offline
|
| 153 |
touch $out
|
| 154 |
'';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
in
|
| 156 |
{
|
| 157 |
-
packages = scripts
|
|
|
|
|
|
|
| 158 |
rust.buildInputs = runtimeDeps;
|
| 159 |
rust.nativeBuildInputs = [ pkgs.pkg-config ];
|
| 160 |
rust.toolchain = pkgs.rust-bin.stable.latest.default.override {
|
|
|
|
| 152 |
cargo test --workspace --doc --locked --offline
|
| 153 |
touch $out
|
| 154 |
'';
|
| 155 |
+
cliPackage =
|
| 156 |
+
pkgs.runCommand "nanobrush-cli-0.1.0"
|
| 157 |
+
{
|
| 158 |
+
nativeBuildInputs = [
|
| 159 |
+
config.rust.toolchain
|
| 160 |
+
pkgs.stdenv.cc
|
| 161 |
+
];
|
| 162 |
+
meta.mainProgram = "nanobrush";
|
| 163 |
+
}
|
| 164 |
+
''
|
| 165 |
+
cp -R ${codeSource}/. .
|
| 166 |
+
chmod -R u+w .
|
| 167 |
+
export CARGO_HOME="$TMPDIR/nanobrush-cargo"
|
| 168 |
+
cargo build -p nanobrush-cli --release --locked --offline
|
| 169 |
+
mkdir -p "$out/bin"
|
| 170 |
+
cp target/release/nanobrush "$out/bin/nanobrush"
|
| 171 |
+
'';
|
| 172 |
in
|
| 173 |
{
|
| 174 |
+
packages = scripts // {
|
| 175 |
+
nanobrush = cliPackage;
|
| 176 |
+
};
|
| 177 |
rust.buildInputs = runtimeDeps;
|
| 178 |
rust.nativeBuildInputs = [ pkgs.pkg-config ];
|
| 179 |
rust.toolchain = pkgs.rust-bin.stable.latest.default.override {
|