feat(core): add compact durable world storage and verified CLI
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "shacraft-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
rusqlite = { version = "=0.40.2", features = ["bundled"] }
|
||||
fs2 = "0.4"
|
||||
sha2 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,68 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
pub(crate) struct SectionCache {
|
||||
entries: HashMap<i64, Vec<u8>>,
|
||||
recency: VecDeque<i64>,
|
||||
pub capacity: usize,
|
||||
pub hits: u64,
|
||||
pub misses: u64,
|
||||
pub evictions: u64,
|
||||
}
|
||||
|
||||
impl SectionCache {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
entries: HashMap::new(),
|
||||
recency: VecDeque::new(),
|
||||
capacity,
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
evictions: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&mut self, id: i64) -> Option<&[u8]> {
|
||||
if self.entries.contains_key(&id) {
|
||||
self.hits = self.hits.saturating_add(1);
|
||||
if let Some(index) = self.recency.iter().position(|entry| *entry == id) {
|
||||
self.recency.remove(index);
|
||||
}
|
||||
self.recency.push_back(id);
|
||||
self.entries.get(&id).map(Vec::as_slice)
|
||||
} else {
|
||||
self.misses = self.misses.saturating_add(1);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, id: i64, bytes: Vec<u8>) {
|
||||
if self.entries.contains_key(&id) {
|
||||
return;
|
||||
}
|
||||
while self.entries.len() >= self.capacity {
|
||||
if let Some(evicted) = self.recency.pop_front() {
|
||||
self.entries.remove(&evicted);
|
||||
self.evictions = self.evictions.saturating_add(1);
|
||||
}
|
||||
}
|
||||
self.entries.insert(id, bytes);
|
||||
self.recency.push_back(id);
|
||||
}
|
||||
|
||||
pub fn peek(&self, id: i64) -> Option<&[u8]> {
|
||||
self.entries.get(&id).map(Vec::as_slice)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn payload_bytes(&self) -> usize {
|
||||
self.entries.values().map(Vec::len).sum()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
self.recency.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use rusqlite::{Connection, TransactionBehavior};
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) const SQLITE_CACHE_KIB: i64 = 4096;
|
||||
const APPLICATION_ID: i64 = 0x5348_4352;
|
||||
const SCHEMA_VERSION: i64 = 1;
|
||||
|
||||
pub(crate) fn configure(conn: &mut Connection) -> Result<()> {
|
||||
conn.busy_timeout(Duration::from_secs(5))?;
|
||||
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
|
||||
let app: i64 = conn.pragma_query_value(None, "application_id", |row| row.get(0))?;
|
||||
let table_count: i64 = conn.query_row(
|
||||
"SELECT count(*) FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if version == 0 && app == 0 && table_count == 0 {
|
||||
// New database; schema is created below after durability settings.
|
||||
} else {
|
||||
ensure!(
|
||||
app == APPLICATION_ID,
|
||||
"storage_format: unexpected application_id"
|
||||
);
|
||||
ensure!(
|
||||
version == SCHEMA_VERSION,
|
||||
"storage_format: unsupported schema version {version}"
|
||||
);
|
||||
}
|
||||
|
||||
let mode: String = conn.query_row("PRAGMA journal_mode=WAL", [], |row| row.get(0))?;
|
||||
ensure!(
|
||||
mode.eq_ignore_ascii_case("wal"),
|
||||
"storage_config: WAL unavailable"
|
||||
);
|
||||
conn.pragma_update(None, "synchronous", "FULL")?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
conn.pragma_update(None, "cache_size", -SQLITE_CACHE_KIB)?;
|
||||
conn.pragma_update(None, "temp_store", "FILE")?;
|
||||
conn.pragma_update(None, "mmap_size", 0)?;
|
||||
conn.pragma_update(None, "wal_autocheckpoint", 256)?;
|
||||
conn.pragma_update(None, "journal_size_limit", 16 * 1024 * 1024)?;
|
||||
let synchronous: i64 = conn.pragma_query_value(None, "synchronous", |r| r.get(0))?;
|
||||
ensure!(
|
||||
synchronous == 2,
|
||||
"storage_config: synchronous FULL not active"
|
||||
);
|
||||
|
||||
if version == 0 {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
tx.execute_batch(
|
||||
"CREATE TABLE registry (
|
||||
id INTEGER PRIMARY KEY CHECK(id >= 0 AND id <= 4294967295),
|
||||
state TEXT NOT NULL UNIQUE
|
||||
);
|
||||
CREATE TABLE blobs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
digest BLOB NOT NULL UNIQUE CHECK(length(digest)=32),
|
||||
data BLOB NOT NULL
|
||||
);
|
||||
CREATE TABLE worlds (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
revision INTEGER NOT NULL DEFAULT 0 CHECK(revision >= 0),
|
||||
epoch INTEGER NOT NULL DEFAULT 0 CHECK(epoch >= 0),
|
||||
base_snapshot INTEGER REFERENCES snapshots(id),
|
||||
template_name TEXT
|
||||
);
|
||||
CREATE TABLE snapshots (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_world INTEGER NOT NULL REFERENCES worlds(id),
|
||||
source_revision INTEGER NOT NULL CHECK(source_revision >= 0),
|
||||
UNIQUE(source_world,source_revision)
|
||||
);
|
||||
CREATE TABLE snapshot_sections (
|
||||
snapshot_id INTEGER NOT NULL REFERENCES snapshots(id),
|
||||
x INTEGER NOT NULL, y INTEGER NOT NULL, z INTEGER NOT NULL,
|
||||
blob_id INTEGER NOT NULL REFERENCES blobs(id),
|
||||
PRIMARY KEY(snapshot_id,x,y,z)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX snapshot_blob_idx ON snapshot_sections(blob_id);
|
||||
CREATE TABLE world_sections (
|
||||
world_id INTEGER NOT NULL REFERENCES worlds(id),
|
||||
x INTEGER NOT NULL, y INTEGER NOT NULL, z INTEGER NOT NULL,
|
||||
blob_id INTEGER NOT NULL REFERENCES blobs(id),
|
||||
changed_cells INTEGER NOT NULL CHECK(changed_cells > 0 AND changed_cells <= 4096),
|
||||
PRIMARY KEY(world_id,x,y,z)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX world_blob_idx ON world_sections(blob_id);
|
||||
CREATE TABLE operations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
world_id INTEGER NOT NULL REFERENCES worlds(id),
|
||||
operation_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('edit','undo','reset')),
|
||||
fingerprint BLOB NOT NULL CHECK(length(fingerprint)=32),
|
||||
revision INTEGER NOT NULL CHECK(revision > 0),
|
||||
epoch INTEGER NOT NULL CHECK(epoch >= 0),
|
||||
changed INTEGER NOT NULL CHECK(changed >= 0),
|
||||
UNIQUE(world_id,operation_id)
|
||||
);
|
||||
CREATE TABLE operation_changes (
|
||||
operation INTEGER NOT NULL REFERENCES operations(id),
|
||||
x INTEGER NOT NULL, y INTEGER NOT NULL, z INTEGER NOT NULL,
|
||||
before_id INTEGER NOT NULL REFERENCES registry(id),
|
||||
after_id INTEGER NOT NULL REFERENCES registry(id),
|
||||
PRIMARY KEY(operation,x,y,z)
|
||||
) WITHOUT ROWID;
|
||||
INSERT INTO registry(id,state) VALUES(0,'minecraft:air');",
|
||||
)?;
|
||||
tx.pragma_update(None, "application_id", APPLICATION_ID)?;
|
||||
tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
|
||||
tx.commit().context("storage_commit: initialize schema")?;
|
||||
}
|
||||
let check: String = conn.query_row("PRAGMA quick_check(1)", [], |row| row.get(0))?;
|
||||
if check != "ok" {
|
||||
bail!("storage_corrupt: {check}");
|
||||
}
|
||||
let mut fk = conn.prepare("PRAGMA foreign_key_check")?;
|
||||
ensure!(
|
||||
fk.query([])?.next()?.is_none(),
|
||||
"storage_corrupt: foreign key violation"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Durable voxel storage with immutable snapshots and bounded section caching.
|
||||
//!
|
||||
//! `WorldStore::open` accepts a directory. Successful mutations commit with
|
||||
//! SQLite WAL + synchronous FULL before returning. Operation IDs are durable
|
||||
//! and scoped to a world; replay returns the original revision. Every new
|
||||
//! accepted edit (including a no-op), undo and reset increments the revision.
|
||||
//! Undo initially supports only the most recent edit's resulting revision.
|
||||
|
||||
mod cache;
|
||||
mod database;
|
||||
mod section;
|
||||
mod store;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use store::WorldStore;
|
||||
|
||||
pub type Pos = [i32; 3];
|
||||
pub type BlockId = u32;
|
||||
|
||||
pub const COORD_LIMIT: i32 = 30_000_000;
|
||||
pub const MAX_EDIT_CELLS: usize = 32_768;
|
||||
pub const MAX_READ_VOLUME: usize = 262_144;
|
||||
pub const MAX_OPERATION_ID_BYTES: usize = 128;
|
||||
pub const MAX_REGISTRY_STATES: usize = 262_144;
|
||||
pub const MAX_STATE_BYTES: usize = 512;
|
||||
pub const MAX_WORLDS: usize = 10_000;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub struct BlockChange {
|
||||
pub pos: Pos,
|
||||
pub block: BlockId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub struct EditResult {
|
||||
pub revision: u64,
|
||||
pub changed: usize,
|
||||
pub replayed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub struct WorldInfo {
|
||||
pub name: String,
|
||||
pub revision: u64,
|
||||
pub template: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
//! A versioned, bounded codec for a 16 × 16 × 16 block section.
|
||||
//!
|
||||
//! The 16-byte header is `SHSC`, version, mode, palette bit width, reserved
|
||||
//! byte, little-endian payload length, and little-endian CRC32. The checksum
|
||||
//! covers the first 12 header bytes followed by the payload. It detects
|
||||
//! accidental corruption; it does not authenticate untrusted data.
|
||||
//!
|
||||
//! Uniform sections contain one `u32`. Palette sections contain a `u16`
|
||||
//! palette length, its `u32` block IDs, and least-significant-bit-first packed
|
||||
//! indices. Dense sections contain 4096 `u32` block IDs. Integers are little
|
||||
//! endian; cell ordering belongs to the caller.
|
||||
|
||||
use anyhow::{Result, bail, ensure};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
pub(crate) const CELL_COUNT: usize = 4096;
|
||||
pub(crate) type Cells = [u32; CELL_COUNT];
|
||||
|
||||
const MAGIC: &[u8; 4] = b"SHSC";
|
||||
const VERSION: u8 = 1;
|
||||
const HEADER_LEN: usize = 16;
|
||||
const UNIFORM: u8 = 0;
|
||||
const PALETTE: u8 = 1;
|
||||
const DENSE: u8 = 2;
|
||||
const DENSE_PAYLOAD_LEN: usize = CELL_COUNT * 4;
|
||||
const MAX_PAYLOAD_LEN: usize = 2 + CELL_COUNT * 4 + CELL_COUNT / 8 * 12;
|
||||
// Absolute bound accepted by v1 decoding; the encoder's dense fallback emits
|
||||
// at most HEADER_LEN + DENSE_PAYLOAD_LEN bytes.
|
||||
pub(crate) const MAX_ENCODED_BYTES: usize = HEADER_LEN + MAX_PAYLOAD_LEN;
|
||||
|
||||
pub(crate) fn encode(cells: &Cells) -> Vec<u8> {
|
||||
if cells.iter().all(|&id| id == cells[0]) {
|
||||
return envelope(UNIFORM, 0, &cells[0].to_le_bytes());
|
||||
}
|
||||
|
||||
// First-occurrence ordering keeps the wire representation deterministic,
|
||||
// independent of the randomized iteration order of HashMap.
|
||||
let mut palette = Vec::new();
|
||||
let mut lookup = HashMap::new();
|
||||
let mut indices = Vec::with_capacity(CELL_COUNT);
|
||||
for &id in cells {
|
||||
let index = *lookup.entry(id).or_insert_with(|| {
|
||||
let index = palette.len() as u16;
|
||||
palette.push(id);
|
||||
index
|
||||
});
|
||||
indices.push(index);
|
||||
}
|
||||
|
||||
let bits = palette_bits(palette.len());
|
||||
let palette_payload_len = 2 + palette.len() * 4 + packed_len(bits);
|
||||
if palette_payload_len > DENSE_PAYLOAD_LEN {
|
||||
let mut payload = Vec::with_capacity(DENSE_PAYLOAD_LEN);
|
||||
for id in cells {
|
||||
payload.extend_from_slice(&id.to_le_bytes());
|
||||
}
|
||||
return envelope(DENSE, 32, &payload);
|
||||
}
|
||||
|
||||
let mut payload = Vec::with_capacity(palette_payload_len);
|
||||
payload.extend_from_slice(&(palette.len() as u16).to_le_bytes());
|
||||
for id in palette {
|
||||
payload.extend_from_slice(&id.to_le_bytes());
|
||||
}
|
||||
let packed_start = payload.len();
|
||||
payload.resize(palette_payload_len, 0);
|
||||
for (cell, index) in indices.into_iter().enumerate() {
|
||||
write_index(&mut payload[packed_start..], cell, bits, index);
|
||||
}
|
||||
envelope(PALETTE, bits, &payload)
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], registry_len: usize) -> Result<Box<Cells>> {
|
||||
ensure!(bytes.len() >= HEADER_LEN, "truncated section header");
|
||||
ensure!(&bytes[..4] == MAGIC, "invalid section magic");
|
||||
ensure!(
|
||||
bytes[4] == VERSION,
|
||||
"unsupported section version {}",
|
||||
bytes[4]
|
||||
);
|
||||
ensure!(bytes[7] == 0, "nonzero reserved section header byte");
|
||||
|
||||
let payload_len = read_u32(&bytes[8..12]) as usize;
|
||||
ensure!(
|
||||
payload_len <= MAX_PAYLOAD_LEN,
|
||||
"section payload exceeds maximum size"
|
||||
);
|
||||
let total_len = HEADER_LEN
|
||||
.checked_add(payload_len)
|
||||
.ok_or_else(|| anyhow::anyhow!("section length overflow"))?;
|
||||
ensure!(bytes.len() == total_len, "incorrect section payload length");
|
||||
let payload = &bytes[HEADER_LEN..];
|
||||
ensure!(
|
||||
read_u32(&bytes[12..16]) == crc32(&bytes[..12], payload),
|
||||
"section checksum mismatch"
|
||||
);
|
||||
|
||||
let bits = bytes[6];
|
||||
match bytes[5] {
|
||||
UNIFORM => {
|
||||
ensure!(bits == 0, "invalid uniform section bit width");
|
||||
ensure!(payload.len() == 4, "invalid uniform section length");
|
||||
let id = read_u32(payload);
|
||||
validate_id(id, registry_len)?;
|
||||
Ok(Box::new([id; CELL_COUNT]))
|
||||
}
|
||||
PALETTE => {
|
||||
ensure!(payload.len() >= 2, "truncated section palette length");
|
||||
let count = u16::from_le_bytes([payload[0], payload[1]]) as usize;
|
||||
ensure!(
|
||||
(2..=CELL_COUNT).contains(&count),
|
||||
"invalid section palette length"
|
||||
);
|
||||
ensure!(
|
||||
bits == palette_bits(count),
|
||||
"invalid section palette bit width"
|
||||
);
|
||||
let packed_start = 2 + count * 4;
|
||||
ensure!(
|
||||
payload.len() == packed_start + packed_len(bits),
|
||||
"incorrect palette section payload length"
|
||||
);
|
||||
|
||||
let mut palette = Vec::with_capacity(count);
|
||||
let mut unique = HashSet::with_capacity(count);
|
||||
for entry in payload[2..packed_start].chunks_exact(4) {
|
||||
let id = read_u32(entry);
|
||||
validate_id(id, registry_len)?;
|
||||
ensure!(unique.insert(id), "duplicate block ID in section palette");
|
||||
palette.push(id);
|
||||
}
|
||||
let packed = &payload[packed_start..];
|
||||
let mut cells = Box::new([0; CELL_COUNT]);
|
||||
for (cell, id) in cells.iter_mut().enumerate() {
|
||||
let index = read_index(packed, cell, bits);
|
||||
ensure!(index < count, "section palette index out of range");
|
||||
*id = palette[index];
|
||||
}
|
||||
Ok(cells)
|
||||
}
|
||||
DENSE => {
|
||||
ensure!(bits == 32, "invalid dense section bit width");
|
||||
ensure!(
|
||||
payload.len() == DENSE_PAYLOAD_LEN,
|
||||
"invalid dense section length"
|
||||
);
|
||||
let mut cells = Box::new([0; CELL_COUNT]);
|
||||
for (cell, entry) in cells.iter_mut().zip(payload.chunks_exact(4)) {
|
||||
let id = read_u32(entry);
|
||||
validate_id(id, registry_len)?;
|
||||
*cell = id;
|
||||
}
|
||||
Ok(cells)
|
||||
}
|
||||
mode => bail!("unsupported section mode {mode}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads one block directly from a validated section without allocation.
|
||||
///
|
||||
/// `bytes` must have passed `decode` and remained unchanged, or have been
|
||||
/// produced by `encode`. This intentionally skips validation and checksum
|
||||
/// computation so a compact cached section supports constant-time reads.
|
||||
pub(crate) fn read_block(bytes: &[u8], index: usize) -> u32 {
|
||||
assert!(index < CELL_COUNT, "section cell index out of range");
|
||||
let payload = &bytes[HEADER_LEN..];
|
||||
match bytes[5] {
|
||||
UNIFORM => read_u32(payload),
|
||||
PALETTE => {
|
||||
let count = u16::from_le_bytes([payload[0], payload[1]]) as usize;
|
||||
let packed_start = 2 + count * 4;
|
||||
let palette_index = read_index(&payload[packed_start..], index, bytes[6]);
|
||||
read_u32(&payload[2 + palette_index * 4..])
|
||||
}
|
||||
DENSE => read_u32(&payload[index * 4..]),
|
||||
mode => unreachable!("validated section has unsupported mode {mode}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_id(id: u32, registry_len: usize) -> Result<()> {
|
||||
ensure!(
|
||||
(id as usize) < registry_len,
|
||||
"section block ID {id} is outside the registry"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn palette_bits(count: usize) -> u8 {
|
||||
(u32::BITS - ((count - 1) as u32).leading_zeros()) as u8
|
||||
}
|
||||
|
||||
fn packed_len(bits: u8) -> usize {
|
||||
// 4096 is divisible by eight, so there are no unused trailing bits.
|
||||
CELL_COUNT / 8 * bits as usize
|
||||
}
|
||||
|
||||
fn write_index(packed: &mut [u8], cell: usize, bits: u8, index: u16) {
|
||||
let offset = cell * bits as usize;
|
||||
let byte = offset / 8;
|
||||
let shift = offset % 8;
|
||||
let value = (index as u32) << shift;
|
||||
packed[byte] |= value as u8;
|
||||
if shift + bits as usize > 8 {
|
||||
packed[byte + 1] |= (value >> 8) as u8;
|
||||
}
|
||||
if shift + bits as usize > 16 {
|
||||
packed[byte + 2] |= (value >> 16) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
fn read_index(packed: &[u8], cell: usize, bits: u8) -> usize {
|
||||
let offset = cell * bits as usize;
|
||||
let byte = offset / 8;
|
||||
let shift = offset % 8;
|
||||
let mut value = packed[byte] as u32;
|
||||
if shift + bits as usize > 8 {
|
||||
value |= (packed[byte + 1] as u32) << 8;
|
||||
}
|
||||
if shift + bits as usize > 16 {
|
||||
value |= (packed[byte + 2] as u32) << 16;
|
||||
}
|
||||
((value >> shift) & ((1 << bits) - 1)) as usize
|
||||
}
|
||||
|
||||
fn read_u32(bytes: &[u8]) -> u32 {
|
||||
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
|
||||
}
|
||||
|
||||
fn envelope(mode: u8, bits: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = Vec::with_capacity(HEADER_LEN + payload.len());
|
||||
bytes.extend_from_slice(MAGIC);
|
||||
bytes.extend_from_slice(&[VERSION, mode, bits, 0]);
|
||||
bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
|
||||
bytes.extend_from_slice(&[0; 4]);
|
||||
bytes.extend_from_slice(payload);
|
||||
let checksum = crc32(&bytes[..12], payload);
|
||||
bytes[12..16].copy_from_slice(&checksum.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
const CRC32_TABLE: [u32; 256] = {
|
||||
let mut table = [0; 256];
|
||||
let mut index = 0;
|
||||
while index < 256 {
|
||||
let mut value = index as u32;
|
||||
let mut bit = 0;
|
||||
while bit < 8 {
|
||||
value = if value & 1 != 0 {
|
||||
(value >> 1) ^ 0xedb8_8320
|
||||
} else {
|
||||
value >> 1
|
||||
};
|
||||
bit += 1;
|
||||
}
|
||||
table[index] = value;
|
||||
index += 1;
|
||||
}
|
||||
table
|
||||
};
|
||||
|
||||
fn crc32(header: &[u8], payload: &[u8]) -> u32 {
|
||||
let mut crc = u32::MAX;
|
||||
for &byte in header.iter().chain(payload) {
|
||||
crc = (crc >> 8) ^ CRC32_TABLE[((crc as u8) ^ byte) as usize];
|
||||
}
|
||||
!crc
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn varied_cells(count: usize) -> Cells {
|
||||
std::array::from_fn(|cell| (cell % count) as u32)
|
||||
}
|
||||
|
||||
fn repair_checksum(bytes: &mut [u8]) {
|
||||
let checksum = crc32(&bytes[..12], &bytes[HEADER_LEN..]);
|
||||
bytes[12..16].copy_from_slice(&checksum.to_le_bytes());
|
||||
}
|
||||
|
||||
fn assert_direct_reads(bytes: &[u8], cells: &Cells) {
|
||||
for (index, &id) in cells.iter().enumerate() {
|
||||
assert_eq!(read_block(bytes, index), id, "cell {index}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc32_matches_standard_check_value() {
|
||||
assert_eq!(crc32(b"1234", b"56789"), 0xcbf4_3926);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniform_round_trip_and_little_endian_layout() {
|
||||
let cells = [0x01_02_03_04; CELL_COUNT];
|
||||
let bytes = encode(&cells);
|
||||
assert_eq!(bytes.len(), 20);
|
||||
assert_eq!(&bytes[..12], b"SHSC\x01\x00\x00\x00\x04\x00\x00\x00");
|
||||
assert_eq!(&bytes[HEADER_LEN..], &[4, 3, 2, 1]);
|
||||
assert_eq!(*decode(&bytes, 0x01_02_03_05).unwrap(), cells);
|
||||
assert_direct_reads(&bytes, &cells);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_palette_width_round_trips() {
|
||||
for bits in 1..=12 {
|
||||
let count = (1usize << (bits - 1)) + 1;
|
||||
let cells = varied_cells(count);
|
||||
let bytes = encode(&cells);
|
||||
assert_eq!(bytes[5], PALETTE, "count {count}");
|
||||
assert_eq!(bytes[6], bits);
|
||||
assert_eq!(bytes.len(), HEADER_LEN + 2 + count * 4 + packed_len(bits));
|
||||
assert_eq!(*decode(&bytes, count).unwrap(), cells);
|
||||
assert_eq!(bytes, encode(&cells), "encoding is deterministic");
|
||||
assert_direct_reads(&bytes, &cells);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_width_changes_at_powers_of_two() {
|
||||
for bits in 1..=11 {
|
||||
let count = 1usize << bits;
|
||||
let cells = varied_cells(count);
|
||||
let bytes = encode(&cells);
|
||||
assert_eq!(bytes[6], bits);
|
||||
assert_eq!(*decode(&bytes, count).unwrap(), cells);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dense_round_trip_and_size_crossover() {
|
||||
for count in [2560, CELL_COUNT] {
|
||||
let cells = varied_cells(count);
|
||||
let bytes = encode(&cells);
|
||||
assert_eq!(bytes[5], DENSE);
|
||||
assert_eq!(bytes[6], 32);
|
||||
assert_eq!(bytes.len(), HEADER_LEN + DENSE_PAYLOAD_LEN);
|
||||
assert_eq!(*decode(&bytes, count).unwrap(), cells);
|
||||
assert_direct_reads(&bytes, &cells);
|
||||
}
|
||||
assert_eq!(encode(&varied_cells(2559))[5], PALETTE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "section cell index out of range")]
|
||||
fn direct_read_rejects_out_of_range_cell() {
|
||||
read_block(&encode(&[0; CELL_COUNT]), CELL_COUNT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_indices_match_cross_byte_wire_fixture() {
|
||||
let mut packed = [0; 3];
|
||||
for index in 0..8 {
|
||||
write_index(&mut packed, index, 3, index as u16);
|
||||
}
|
||||
assert_eq!(packed, [0x88, 0xc6, 0xfa]);
|
||||
for index in 0..8 {
|
||||
assert_eq!(read_index(&packed, index, 3), index);
|
||||
}
|
||||
|
||||
// Eleven-bit indices can span three bytes at some bit offsets.
|
||||
let mut packed = [0; 11];
|
||||
let indices = [0x7ff, 0x401, 0x255, 0x623, 0x456, 0x123, 0x321, 0x700];
|
||||
for (cell, &index) in indices.iter().enumerate() {
|
||||
write_index(&mut packed, cell, 11, index);
|
||||
}
|
||||
for (cell, &index) in indices.iter().enumerate() {
|
||||
assert_eq!(read_index(&packed, cell, 11), index as usize);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_extra_and_corrupted_bytes_are_rejected() {
|
||||
for cells in [[0; CELL_COUNT], varied_cells(3), varied_cells(CELL_COUNT)] {
|
||||
let bytes = encode(&cells);
|
||||
for length in 0..bytes.len() {
|
||||
assert!(decode(&bytes[..length], CELL_COUNT).is_err());
|
||||
}
|
||||
let mut extra = bytes.clone();
|
||||
extra.push(0);
|
||||
assert!(decode(&extra, CELL_COUNT).is_err());
|
||||
for offset in [0, 4, 5, 6, 7, 8, 12, HEADER_LEN, bytes.len() - 1] {
|
||||
let mut corrupted = bytes.clone();
|
||||
corrupted[offset] ^= 1;
|
||||
assert!(decode(&corrupted, CELL_COUNT).is_err(), "offset {offset}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structurally_invalid_headers_are_rejected_even_with_valid_checksum() {
|
||||
for (offset, value) in [(0, b'X'), (4, 2), (5, 3), (6, 1), (7, 1)] {
|
||||
let mut bytes = encode(&[0; CELL_COUNT]);
|
||||
bytes[offset] = value;
|
||||
repair_checksum(&mut bytes);
|
||||
assert!(decode(&bytes, 1).is_err());
|
||||
}
|
||||
assert!(decode(&envelope(UNIFORM, 0, &[0; 3]), 1).is_err());
|
||||
assert!(decode(&envelope(DENSE, 32, &[0; 4]), 1).is_err());
|
||||
assert!(decode(&envelope(DENSE, 0, &[0; DENSE_PAYLOAD_LEN]), 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_palettes_and_indices_are_rejected() {
|
||||
for count in [0u16, 1, 4097, u16::MAX] {
|
||||
assert!(decode(&envelope(PALETTE, 1, &count.to_le_bytes()), 10).is_err());
|
||||
}
|
||||
assert!(decode(&envelope(PALETTE, 1, &[]), 10).is_err());
|
||||
let original = encode(&varied_cells(3));
|
||||
|
||||
let mut duplicate = original.clone();
|
||||
duplicate[HEADER_LEN + 6..HEADER_LEN + 10].copy_from_slice(&0u32.to_le_bytes());
|
||||
repair_checksum(&mut duplicate);
|
||||
assert!(decode(&duplicate, 3).is_err());
|
||||
|
||||
let mut bad_width = original.clone();
|
||||
bad_width[6] = 3;
|
||||
repair_checksum(&mut bad_width);
|
||||
assert!(decode(&bad_width, 3).is_err());
|
||||
|
||||
let mut bad_index = original.clone();
|
||||
bad_index[HEADER_LEN + 2 + 3 * 4] |= 3;
|
||||
repair_checksum(&mut bad_index);
|
||||
assert!(decode(&bad_index, 3).is_err());
|
||||
|
||||
let mut short_payload = original[HEADER_LEN..].to_vec();
|
||||
short_payload.pop();
|
||||
assert!(decode(&envelope(PALETTE, 2, &short_payload), 3).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_storage_mode_checks_registry_bounds() {
|
||||
assert!(decode(&encode(&[0; CELL_COUNT]), 0).is_err());
|
||||
assert!(decode(&encode(&[1; CELL_COUNT]), 1).is_err());
|
||||
assert!(decode(&encode(&varied_cells(3)), 2).is_err());
|
||||
assert!(decode(&encode(&varied_cells(CELL_COUNT)), CELL_COUNT - 1).is_err());
|
||||
|
||||
// An unused palette entry must still refer to a known block ID.
|
||||
let mut bytes = encode(&varied_cells(3));
|
||||
bytes[HEADER_LEN + 2 + 3 * 4..].fill(0);
|
||||
repair_checksum(&mut bytes);
|
||||
assert!(decode(&bytes, 2).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,971 @@
|
||||
use crate::cache::SectionCache;
|
||||
use crate::database::{self, SQLITE_CACHE_KIB};
|
||||
use crate::section::{self, CELL_COUNT, Cells};
|
||||
use crate::{
|
||||
BlockChange, BlockId, COORD_LIMIT, EditResult, MAX_EDIT_CELLS, MAX_OPERATION_ID_BYTES,
|
||||
MAX_READ_VOLUME, MAX_REGISTRY_STATES, MAX_STATE_BYTES, MAX_WORLDS, Pos, WorldInfo,
|
||||
};
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use fs2::FileExt;
|
||||
use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
type SectionPos = [i32; 3];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct World {
|
||||
id: i64,
|
||||
revision: i64,
|
||||
epoch: i64,
|
||||
base: Option<i64>,
|
||||
}
|
||||
|
||||
/// Single-writer, disk-indexed world store. The lock is held until drop.
|
||||
pub struct WorldStore {
|
||||
conn: Connection,
|
||||
root: PathBuf,
|
||||
registry: Vec<String>,
|
||||
cache: SectionCache,
|
||||
commit_count: u64,
|
||||
last_commit_us: u64,
|
||||
transient_section_peak_bytes: usize,
|
||||
// Declare last: connection closes before the store's advisory lock releases.
|
||||
_lock: File,
|
||||
}
|
||||
|
||||
impl WorldStore {
|
||||
pub fn open(path: impl AsRef<Path>, cache_sections: usize) -> Result<Self> {
|
||||
ensure!(
|
||||
cache_sections > 0,
|
||||
"input_limit: cache_sections must be positive"
|
||||
);
|
||||
fs::create_dir_all(path.as_ref()).context("storage_open: create directory")?;
|
||||
let root = fs::canonicalize(path.as_ref()).context("storage_open: resolve directory")?;
|
||||
let lock = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(root.join("store.lock"))
|
||||
.context("storage_open: open lock file")?;
|
||||
lock.try_lock_exclusive()
|
||||
.context("storage_locked: another WorldStore owns this directory")?;
|
||||
let mut conn = Connection::open(root.join("worlds.sqlite3"))
|
||||
.context("storage_open: open SQLite database")?;
|
||||
database::configure(&mut conn).context("storage_open: initialize/recover database")?;
|
||||
let count: i64 = conn.query_row("SELECT count(*) FROM registry", [], |r| r.get(0))?;
|
||||
ensure!(
|
||||
count > 0 && count <= MAX_REGISTRY_STATES as i64,
|
||||
"storage_corrupt: registry cardinality"
|
||||
);
|
||||
let max_state_length: i64 = conn.query_row(
|
||||
"SELECT COALESCE(max(length(CAST(state AS BLOB))),0) FROM registry",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
ensure!(
|
||||
max_state_length <= MAX_STATE_BYTES as i64,
|
||||
"storage_corrupt: registry state length"
|
||||
);
|
||||
let mut registry = Vec::with_capacity(count as usize);
|
||||
{
|
||||
let mut stmt = conn.prepare("SELECT id,state FROM registry ORDER BY id")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let id: i64 = row.get(0)?;
|
||||
let state: String = row.get(1)?;
|
||||
ensure!(
|
||||
id == registry.len() as i64,
|
||||
"storage_corrupt: noncontiguous registry IDs"
|
||||
);
|
||||
ensure!(
|
||||
canonical_state(&state)? == state,
|
||||
"storage_corrupt: noncanonical registry state"
|
||||
);
|
||||
registry.push(state);
|
||||
}
|
||||
}
|
||||
ensure!(
|
||||
registry[0] == "minecraft:air",
|
||||
"storage_corrupt: air ID 0 was reassigned"
|
||||
);
|
||||
// Persist directory entries for a newly created database and store directory.
|
||||
File::open(&root)?.sync_all()?;
|
||||
if let Some(parent) = root.parent() {
|
||||
File::open(parent)?.sync_all()?;
|
||||
}
|
||||
Ok(Self {
|
||||
conn,
|
||||
root,
|
||||
registry,
|
||||
cache: SectionCache::new(cache_sections),
|
||||
commit_count: 0,
|
||||
last_commit_us: 0,
|
||||
transient_section_peak_bytes: 0,
|
||||
_lock: lock,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_worlds(&self) -> Result<Vec<WorldInfo>> {
|
||||
let mut statement = self
|
||||
.conn
|
||||
.prepare("SELECT name,revision,template_name FROM worlds ORDER BY name")?;
|
||||
let worlds = statement.query_map([], |row| {
|
||||
Ok(WorldInfo {
|
||||
name: row.get(0)?,
|
||||
revision: row.get::<_, i64>(1)? as u64,
|
||||
template: row.get(2)?,
|
||||
})
|
||||
})?;
|
||||
worlds.collect::<rusqlite::Result<_>>().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn create_world(&mut self, name: &str, template: Option<&str>) -> Result<()> {
|
||||
validate_world_name(name)?;
|
||||
if let Some(source) = template {
|
||||
validate_world_name(source)?;
|
||||
}
|
||||
let started = Instant::now();
|
||||
let tx = self
|
||||
.conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let count: i64 = tx.query_row("SELECT count(*) FROM worlds", [], |r| r.get(0))?;
|
||||
ensure!(
|
||||
count < MAX_WORLDS as i64,
|
||||
"input_limit: maximum world count reached"
|
||||
);
|
||||
let exists: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM worlds WHERE name=?1)",
|
||||
[name],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
ensure!(!exists, "world_exists: {name}");
|
||||
let snapshot = if let Some(source) = template {
|
||||
let world = lookup_world(&tx, source)?;
|
||||
let existing: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT id FROM snapshots WHERE source_world=?1 AND source_revision=?2",
|
||||
params![world.id, world.revision],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Some(if let Some(id) = existing {
|
||||
id
|
||||
} else {
|
||||
tx.execute(
|
||||
"INSERT INTO snapshots(source_world,source_revision) VALUES(?1,?2)",
|
||||
params![world.id, world.revision],
|
||||
)?;
|
||||
let id = tx.last_insert_rowid();
|
||||
if let Some(base) = world.base {
|
||||
tx.execute(
|
||||
"INSERT INTO snapshot_sections(snapshot_id,x,y,z,blob_id)
|
||||
SELECT ?1,x,y,z,blob_id FROM snapshot_sections WHERE snapshot_id=?2",
|
||||
params![id, base],
|
||||
)?;
|
||||
}
|
||||
// Indexed SQL copies references; no world-sized collection in Rust.
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO snapshot_sections(snapshot_id,x,y,z,blob_id)
|
||||
SELECT ?1,x,y,z,blob_id FROM world_sections WHERE world_id=?2",
|
||||
params![id, world.id],
|
||||
)?;
|
||||
id
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
tx.execute(
|
||||
"INSERT INTO worlds(name,base_snapshot,template_name) VALUES(?1,?2,?3)",
|
||||
params![name, snapshot, template],
|
||||
)?;
|
||||
tx.commit().context("storage_commit: create_world")?;
|
||||
self.record_commit(started);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn register_block(&mut self, state: &str) -> Result<BlockId> {
|
||||
let state = canonical_state(state)?;
|
||||
let existing: Option<u32> = self
|
||||
.conn
|
||||
.query_row("SELECT id FROM registry WHERE state=?1", [&state], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.optional()?;
|
||||
if let Some(id) = existing {
|
||||
return Ok(id);
|
||||
}
|
||||
ensure!(
|
||||
self.registry.len() < MAX_REGISTRY_STATES,
|
||||
"input_limit: registry is full"
|
||||
);
|
||||
let id = self.registry.len() as BlockId;
|
||||
let started = Instant::now();
|
||||
let tx = self
|
||||
.conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
tx.execute(
|
||||
"INSERT INTO registry(id,state) VALUES(?1,?2)",
|
||||
params![id, state],
|
||||
)?;
|
||||
tx.commit().context("storage_commit: register_block")?;
|
||||
self.registry.push(state);
|
||||
self.record_commit(started);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn registry(&self) -> &[String] {
|
||||
&self.registry
|
||||
}
|
||||
|
||||
pub fn revision(&self, world: &str) -> Result<u64> {
|
||||
validate_world_name(world)?;
|
||||
Ok(lookup_world(&self.conn, world)?.revision as u64)
|
||||
}
|
||||
|
||||
pub fn get_block(&mut self, name: &str, pos: Pos) -> Result<BlockId> {
|
||||
validate_world_name(name)?;
|
||||
validate_pos(pos)?;
|
||||
let world = lookup_world(&self.conn, name)?;
|
||||
let (section, index) = locate(pos);
|
||||
let result = match effective_blob(&self.conn, world, section)? {
|
||||
Some(id) => Ok(section::read_block(
|
||||
load_encoded(&self.conn, &mut self.cache, self.registry.len(), id)?,
|
||||
index,
|
||||
)),
|
||||
None => Ok(0),
|
||||
};
|
||||
self.transient_section_peak_bytes = self
|
||||
.transient_section_peak_bytes
|
||||
.max(CELL_COUNT * size_of::<u32>());
|
||||
result
|
||||
}
|
||||
|
||||
pub fn edit(
|
||||
&mut self,
|
||||
name: &str,
|
||||
expected_revision: u64,
|
||||
operation_id: &str,
|
||||
mut changes: Vec<BlockChange>,
|
||||
) -> Result<EditResult> {
|
||||
validate_world_name(name)?;
|
||||
validate_operation_id(operation_id)?;
|
||||
validate_revision(expected_revision)?;
|
||||
ensure!(
|
||||
changes.len() <= MAX_EDIT_CELLS,
|
||||
"input_limit: edit exceeds {MAX_EDIT_CELLS} cells"
|
||||
);
|
||||
for change in &changes {
|
||||
validate_pos(change.pos)?;
|
||||
ensure!(
|
||||
(change.block as usize) < self.registry.len(),
|
||||
"unknown_block: {}",
|
||||
change.block
|
||||
);
|
||||
}
|
||||
changes.sort_unstable_by_key(|change| change.pos);
|
||||
ensure!(
|
||||
changes.windows(2).all(|pair| pair[0].pos != pair[1].pos),
|
||||
"duplicate_position: edit contains the same cell twice"
|
||||
);
|
||||
let fingerprint = edit_fingerprint(expected_revision, &changes);
|
||||
let started = Instant::now();
|
||||
let tx = self
|
||||
.conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let world = lookup_world(&tx, name)?;
|
||||
if let Some(result) = replay(&tx, world.id, operation_id, &fingerprint)? {
|
||||
return Ok(result);
|
||||
}
|
||||
check_revision(world, expected_revision)?;
|
||||
let next = next_revision(world.revision)?;
|
||||
let op = insert_operation(&tx, world, operation_id, "edit", &fingerprint, next, 0)?;
|
||||
let changed = apply_changes(
|
||||
&tx,
|
||||
&mut self.cache,
|
||||
self.registry.len(),
|
||||
world,
|
||||
op,
|
||||
&changes,
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE operations SET changed=?1 WHERE id=?2",
|
||||
params![changed as i64, op],
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE worlds SET revision=?1 WHERE id=?2",
|
||||
params![next, world.id],
|
||||
)?;
|
||||
tx.commit().context("storage_commit: edit")?;
|
||||
self.transient_section_peak_bytes = self
|
||||
.transient_section_peak_bytes
|
||||
.max(2 * CELL_COUNT * size_of::<u32>());
|
||||
self.record_commit(started);
|
||||
Ok(EditResult {
|
||||
revision: next as u64,
|
||||
changed,
|
||||
replayed: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn undo(
|
||||
&mut self,
|
||||
name: &str,
|
||||
expected_revision: u64,
|
||||
operation_id: &str,
|
||||
target_operation: &str,
|
||||
) -> Result<EditResult> {
|
||||
validate_world_name(name)?;
|
||||
validate_operation_id(operation_id)?;
|
||||
validate_operation_id(target_operation)?;
|
||||
validate_revision(expected_revision)?;
|
||||
let fingerprint = named_fingerprint("undo", expected_revision, target_operation);
|
||||
let started = Instant::now();
|
||||
let tx = self
|
||||
.conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let world = lookup_world(&tx, name)?;
|
||||
if let Some(result) = replay(&tx, world.id, operation_id, &fingerprint)? {
|
||||
return Ok(result);
|
||||
}
|
||||
check_revision(world, expected_revision)?;
|
||||
let target: Option<(i64, String, i64, i64)> = tx.query_row(
|
||||
"SELECT id,kind,revision,epoch FROM operations WHERE world_id=?1 AND operation_id=?2",
|
||||
params![world.id, target_operation],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||
).optional()?;
|
||||
let (target_id, kind, revision, epoch) =
|
||||
target.context("unknown_operation: undo target does not exist in this world")?;
|
||||
ensure!(kind == "edit", "undo_unsupported: only edits can be undone");
|
||||
ensure!(
|
||||
epoch == world.epoch && revision == world.revision,
|
||||
"undo_conflict: target must be the last edit revision and precede no reset"
|
||||
);
|
||||
let changes = {
|
||||
let mut statement = tx.prepare(
|
||||
"SELECT x,y,z,before_id FROM operation_changes WHERE operation=?1 ORDER BY x,y,z LIMIT ?2",
|
||||
)?;
|
||||
let rows =
|
||||
statement.query_map(params![target_id, (MAX_EDIT_CELLS + 1) as i64], |row| {
|
||||
Ok(BlockChange {
|
||||
pos: [row.get(0)?, row.get(1)?, row.get(2)?],
|
||||
block: row.get(3)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
ensure!(
|
||||
changes.len() <= MAX_EDIT_CELLS,
|
||||
"storage_corrupt: undo history exceeds operation bound"
|
||||
);
|
||||
for change in &changes {
|
||||
validate_pos(change.pos).context("storage_corrupt: undo position")?;
|
||||
ensure!(
|
||||
(change.block as usize) < self.registry.len(),
|
||||
"storage_corrupt: undo block ID"
|
||||
);
|
||||
}
|
||||
let next = next_revision(world.revision)?;
|
||||
let op = insert_operation(&tx, world, operation_id, "undo", &fingerprint, next, 0)?;
|
||||
let changed = apply_changes(
|
||||
&tx,
|
||||
&mut self.cache,
|
||||
self.registry.len(),
|
||||
world,
|
||||
op,
|
||||
&changes,
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE operations SET changed=?1 WHERE id=?2",
|
||||
params![changed as i64, op],
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE worlds SET revision=?1 WHERE id=?2",
|
||||
params![next, world.id],
|
||||
)?;
|
||||
tx.commit().context("storage_commit: undo")?;
|
||||
self.transient_section_peak_bytes = self
|
||||
.transient_section_peak_bytes
|
||||
.max(2 * CELL_COUNT * size_of::<u32>());
|
||||
self.record_commit(started);
|
||||
Ok(EditResult {
|
||||
revision: next as u64,
|
||||
changed,
|
||||
replayed: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reset_world(
|
||||
&mut self,
|
||||
name: &str,
|
||||
expected_revision: u64,
|
||||
operation_id: &str,
|
||||
) -> Result<EditResult> {
|
||||
validate_world_name(name)?;
|
||||
validate_operation_id(operation_id)?;
|
||||
validate_revision(expected_revision)?;
|
||||
let fingerprint = named_fingerprint("reset", expected_revision, "");
|
||||
let started = Instant::now();
|
||||
let tx = self
|
||||
.conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let world = lookup_world(&tx, name)?;
|
||||
if let Some(result) = replay(&tx, world.id, operation_id, &fingerprint)? {
|
||||
return Ok(result);
|
||||
}
|
||||
check_revision(world, expected_revision)?;
|
||||
let next = next_revision(world.revision)?;
|
||||
let epoch = next_revision(world.epoch)?;
|
||||
let changed: i64 = tx.query_row(
|
||||
"SELECT COALESCE(sum(changed_cells),0) FROM world_sections WHERE world_id=?1",
|
||||
[world.id],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let changed = usize::try_from(changed).context("storage_corrupt: reset change count")?;
|
||||
insert_operation(
|
||||
&tx,
|
||||
World { epoch, ..world },
|
||||
operation_id,
|
||||
"reset",
|
||||
&fingerprint,
|
||||
next,
|
||||
changed,
|
||||
)?;
|
||||
tx.execute("DELETE FROM world_sections WHERE world_id=?1", [world.id])?;
|
||||
tx.execute(
|
||||
"UPDATE worlds SET revision=?1,epoch=?2 WHERE id=?3",
|
||||
params![next, epoch, world.id],
|
||||
)?;
|
||||
tx.commit().context("storage_commit: reset_world")?;
|
||||
self.record_commit(started);
|
||||
Ok(EditResult {
|
||||
revision: next as u64,
|
||||
changed,
|
||||
replayed: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_region(&mut self, name: &str, min: Pos, max: Pos) -> Result<Vec<BlockChange>> {
|
||||
validate_world_name(name)?;
|
||||
validate_pos(min)?;
|
||||
validate_pos(max)?;
|
||||
let mut volume = 1_u64;
|
||||
for axis in 0..3 {
|
||||
ensure!(min[axis] <= max[axis], "invalid_region: min exceeds max");
|
||||
let size = (i64::from(max[axis]) - i64::from(min[axis]) + 1) as u64;
|
||||
volume = volume
|
||||
.checked_mul(size)
|
||||
.context("input_limit: region volume overflow")?;
|
||||
ensure!(
|
||||
volume <= MAX_READ_VOLUME as u64,
|
||||
"input_limit: region exceeds {MAX_READ_VOLUME} cells"
|
||||
);
|
||||
}
|
||||
let world = lookup_world(&self.conn, name)?;
|
||||
let (lower, _) = locate(min);
|
||||
let (upper, _) = locate(max);
|
||||
let mut result = Vec::new();
|
||||
for sy in lower[1]..=upper[1] {
|
||||
for sz in lower[2]..=upper[2] {
|
||||
for sx in lower[0]..=upper[0] {
|
||||
let section = [sx, sy, sz];
|
||||
let Some(blob) = effective_blob(&self.conn, world, section)? else {
|
||||
continue;
|
||||
};
|
||||
let cells =
|
||||
load_decoded(&self.conn, &mut self.cache, self.registry.len(), blob)?;
|
||||
let start = [
|
||||
min[0].max(sx * 16),
|
||||
min[1].max(sy * 16),
|
||||
min[2].max(sz * 16),
|
||||
];
|
||||
let end = [
|
||||
max[0].min(sx * 16 + 15),
|
||||
max[1].min(sy * 16 + 15),
|
||||
max[2].min(sz * 16 + 15),
|
||||
];
|
||||
for y in start[1]..=end[1] {
|
||||
for z in start[2]..=end[2] {
|
||||
for x in start[0]..=end[0] {
|
||||
let pos = [x, y, z];
|
||||
let block = cells[locate(pos).1];
|
||||
if block != 0 {
|
||||
result.push(BlockChange { pos, block });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.transient_section_peak_bytes = self
|
||||
.transient_section_peak_bytes
|
||||
.max(CELL_COUNT * size_of::<u32>());
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Durably committed operations need no flush to survive process restart.
|
||||
/// This also checkpoints WAL, reporting busy/error instead of hiding it.
|
||||
pub fn flush(&mut self) -> Result<()> {
|
||||
let (busy, log, checkpointed): (i64, i64, i64) =
|
||||
self.conn
|
||||
.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
||||
})?;
|
||||
ensure!(
|
||||
busy == 0 && (log == checkpointed || log == -1),
|
||||
"storage_busy: WAL checkpoint incomplete"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete at most `max_blobs` unreachable immutable blobs. History is kept.
|
||||
/// This never runs automatically; a caller can bound maintenance work.
|
||||
pub fn collect_garbage(&mut self, max_blobs: usize) -> Result<usize> {
|
||||
ensure!(
|
||||
(1..=4096).contains(&max_blobs),
|
||||
"input_limit: GC batch must be 1..4096"
|
||||
);
|
||||
let started = Instant::now();
|
||||
let tx = self
|
||||
.conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let removed = tx.execute(
|
||||
"DELETE FROM blobs WHERE id IN (
|
||||
SELECT b.id FROM blobs b
|
||||
WHERE NOT EXISTS(SELECT 1 FROM world_sections w WHERE w.blob_id=b.id)
|
||||
AND NOT EXISTS(SELECT 1 FROM snapshot_sections s WHERE s.blob_id=b.id)
|
||||
LIMIT ?1)",
|
||||
[max_blobs as i64],
|
||||
)?;
|
||||
tx.commit().context("storage_commit: collect_garbage")?;
|
||||
// SQLite may reuse deleted rowids; no stale cached ID may survive GC.
|
||||
self.cache.clear();
|
||||
self.record_commit(started);
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Metrics schema v1. Payload estimates are not allocator usage or OS RSS.
|
||||
/// Persistent counts survive restart; cache/latency counters are per handle.
|
||||
/// File sizes include allocated SQLite pages, not only live logical records.
|
||||
pub fn stats(&self) -> Value {
|
||||
let count = |table: &str| -> Option<i64> {
|
||||
// Table names below are fixed program constants, never user inputs.
|
||||
self.conn
|
||||
.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
|
||||
.ok()
|
||||
};
|
||||
let file_size = |name: &str| -> Option<u64> {
|
||||
match fs::metadata(self.root.join(name)) {
|
||||
Ok(meta) => Some(meta.len()),
|
||||
Err(error)
|
||||
if error.kind() == std::io::ErrorKind::NotFound && name != "worlds.sqlite3" =>
|
||||
{
|
||||
Some(0)
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
};
|
||||
json!({
|
||||
"schema_version": 1,
|
||||
"cache_entries": self.cache.len(),
|
||||
"cache_capacity_sections": self.cache.capacity,
|
||||
"cache_payload_bytes": self.cache.payload_bytes(),
|
||||
"cache_representation": "encoded_uniform_palette_dense",
|
||||
"cache_hits": self.cache.hits,
|
||||
"cache_misses": self.cache.misses,
|
||||
"cache_evictions": self.cache.evictions,
|
||||
"sqlite_cache_budget_bytes": SQLITE_CACHE_KIB * 1024,
|
||||
"sqlite_journal_mode": "wal",
|
||||
"sqlite_synchronous": "FULL",
|
||||
"registry_count": self.registry.len(),
|
||||
"registry_limit_states": MAX_REGISTRY_STATES,
|
||||
"registry_text_bytes": self.registry.iter().map(String::len).sum::<usize>(),
|
||||
"world_count": count("worlds"),
|
||||
"world_limit": MAX_WORLDS,
|
||||
"snapshot_count": count("snapshots"),
|
||||
"immutable_blob_count": count("blobs"),
|
||||
"overlay_section_count": count("world_sections"),
|
||||
"operation_count": count("operations"),
|
||||
"history_change_count": count("operation_changes"),
|
||||
"database_bytes": file_size("worlds.sqlite3"),
|
||||
"wal_bytes": file_size("worlds.sqlite3-wal"),
|
||||
"shm_bytes": file_size("worlds.sqlite3-shm"),
|
||||
"commits_this_handle": self.commit_count,
|
||||
"last_mutation_duration_us": self.last_commit_us,
|
||||
"transient_decoded_payload_peak_estimate_bytes": self.transient_section_peak_bytes,
|
||||
"os_rss_bytes": Value::Null,
|
||||
"history_retention": "all_operations_until_explicit_future_retention_policy",
|
||||
"max_edit_cells": MAX_EDIT_CELLS,
|
||||
"max_read_volume": MAX_READ_VOLUME,
|
||||
"coordinate_limit": COORD_LIMIT
|
||||
})
|
||||
}
|
||||
|
||||
fn record_commit(&mut self, started: Instant) {
|
||||
self.commit_count = self.commit_count.saturating_add(1);
|
||||
self.last_commit_us = started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64;
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_world_name(name: &str) -> Result<()> {
|
||||
ensure!(
|
||||
!name.is_empty()
|
||||
&& name.len() <= 64
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'),
|
||||
"invalid_world_name: expected [A-Za-z0-9_-]{{1,64}}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_operation_id(id: &str) -> Result<()> {
|
||||
ensure!(
|
||||
!id.is_empty() && id.len() <= MAX_OPERATION_ID_BYTES && !id.chars().any(char::is_control),
|
||||
"invalid_operation_id: expected 1..{MAX_OPERATION_ID_BYTES} UTF-8 bytes without control characters"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_revision(revision: u64) -> Result<()> {
|
||||
ensure!(
|
||||
revision <= i64::MAX as u64,
|
||||
"revision_overflow: maximum is i64::MAX"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_pos(pos: Pos) -> Result<()> {
|
||||
ensure!(
|
||||
pos.into_iter()
|
||||
.all(|v| (-COORD_LIMIT..=COORD_LIMIT).contains(&v)),
|
||||
"invalid_position: coordinates must be within +/-{COORD_LIMIT}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn canonical_state(state: &str) -> Result<String> {
|
||||
ensure!(
|
||||
!state.is_empty() && state.len() <= MAX_STATE_BYTES && state.is_ascii(),
|
||||
"invalid_block_state: size or encoding"
|
||||
);
|
||||
let (identifier, properties) = if let Some(open) = state.find('[') {
|
||||
ensure!(
|
||||
state.ends_with(']'),
|
||||
"invalid_block_state: missing closing bracket"
|
||||
);
|
||||
(&state[..open], Some(&state[open + 1..state.len() - 1]))
|
||||
} else {
|
||||
(state, None)
|
||||
};
|
||||
let (namespace, path) = identifier
|
||||
.split_once(':')
|
||||
.context("invalid_block_state: namespace required")?;
|
||||
let valid_part = |part: &str, allow_slash: bool| {
|
||||
!part.is_empty()
|
||||
&& part.bytes().all(|b| {
|
||||
b.is_ascii_lowercase()
|
||||
|| b.is_ascii_digit()
|
||||
|| b"_.-".contains(&b)
|
||||
|| (allow_slash && b == b'/')
|
||||
})
|
||||
};
|
||||
ensure!(
|
||||
valid_part(namespace, false) && valid_part(path, true),
|
||||
"invalid_block_state: malformed identifier"
|
||||
);
|
||||
if let Some(properties) = properties {
|
||||
ensure!(
|
||||
!properties.is_empty(),
|
||||
"invalid_block_state: empty property list"
|
||||
);
|
||||
let mut sorted = BTreeMap::new();
|
||||
for property in properties.split(',') {
|
||||
let (key, value) = property
|
||||
.split_once('=')
|
||||
.context("invalid_block_state: malformed property")?;
|
||||
ensure!(
|
||||
valid_part(key, false) && valid_part(value, true),
|
||||
"invalid_block_state: malformed property"
|
||||
);
|
||||
ensure!(
|
||||
sorted.insert(key, value).is_none(),
|
||||
"invalid_block_state: duplicate property"
|
||||
);
|
||||
}
|
||||
let body = sorted
|
||||
.into_iter()
|
||||
.map(|(key, value)| format!("{key}={value}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
Ok(format!("{identifier}[{body}]"))
|
||||
} else {
|
||||
Ok(identifier.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn locate(pos: Pos) -> (SectionPos, usize) {
|
||||
let section = pos.map(|v| v.div_euclid(16));
|
||||
let [x, y, z] = pos.map(|v| v.rem_euclid(16) as usize);
|
||||
(section, x + 16 * z + 256 * y)
|
||||
}
|
||||
|
||||
fn lookup_world(conn: &Connection, name: &str) -> Result<World> {
|
||||
conn.query_row(
|
||||
"SELECT id,revision,epoch,base_snapshot FROM worlds WHERE name=?1",
|
||||
[name],
|
||||
|r| {
|
||||
Ok(World {
|
||||
id: r.get(0)?,
|
||||
revision: r.get(1)?,
|
||||
epoch: r.get(2)?,
|
||||
base: r.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()?
|
||||
.with_context(|| format!("unknown_world: {name}"))
|
||||
}
|
||||
|
||||
fn check_revision(world: World, expected: u64) -> Result<()> {
|
||||
ensure!(
|
||||
world.revision as u64 == expected,
|
||||
"revision_conflict: expected {expected}, current {}",
|
||||
world.revision
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next_revision(current: i64) -> Result<i64> {
|
||||
current
|
||||
.checked_add(1)
|
||||
.context("revision_overflow: maximum is i64::MAX")
|
||||
}
|
||||
|
||||
fn base_blob(conn: &Connection, base: Option<i64>, [x, y, z]: SectionPos) -> Result<Option<i64>> {
|
||||
if let Some(base) = base {
|
||||
Ok(conn.query_row(
|
||||
"SELECT blob_id FROM snapshot_sections WHERE snapshot_id=?1 AND x=?2 AND y=?3 AND z=?4",
|
||||
params![base, x, y, z], |r| r.get(0),
|
||||
).optional()?)
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_blob(
|
||||
conn: &Connection,
|
||||
world: World,
|
||||
pos @ [x, y, z]: SectionPos,
|
||||
) -> Result<Option<i64>> {
|
||||
let own = conn
|
||||
.query_row(
|
||||
"SELECT blob_id FROM world_sections WHERE world_id=?1 AND x=?2 AND y=?3 AND z=?4",
|
||||
params![world.id, x, y, z],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
match own {
|
||||
Some(id) => Ok(Some(id)),
|
||||
None => base_blob(conn, world.base, pos),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_encoded<'a>(
|
||||
conn: &Connection,
|
||||
cache: &'a mut SectionCache,
|
||||
registry_len: usize,
|
||||
id: i64,
|
||||
) -> Result<&'a [u8]> {
|
||||
if cache.get(id).is_some() {
|
||||
return Ok(cache.peek(id).expect("cached section exists"));
|
||||
}
|
||||
let (digest, bytes): (Vec<u8>, Vec<u8>) = conn
|
||||
.query_row(
|
||||
"SELECT digest,data FROM blobs WHERE id=?1 AND length(data)<=?2",
|
||||
params![id, section::MAX_ENCODED_BYTES as i64],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.with_context(|| format!("storage_corrupt: missing or oversized section blob {id}"))?;
|
||||
ensure!(
|
||||
digest.as_slice() == Sha256::digest(&bytes).as_slice(),
|
||||
"storage_corrupt: section blob {id} hash mismatch"
|
||||
);
|
||||
section::decode(&bytes, registry_len)
|
||||
.with_context(|| format!("storage_corrupt: section blob {id}"))?;
|
||||
cache.insert(id, bytes);
|
||||
Ok(cache.peek(id).expect("inserted section exists"))
|
||||
}
|
||||
|
||||
fn load_decoded(
|
||||
conn: &Connection,
|
||||
cache: &mut SectionCache,
|
||||
registry_len: usize,
|
||||
id: i64,
|
||||
) -> Result<Box<Cells>> {
|
||||
section::decode(load_encoded(conn, cache, registry_len, id)?, registry_len)
|
||||
}
|
||||
|
||||
fn intern_blob(tx: &Transaction<'_>, cells: &Cells) -> Result<i64> {
|
||||
let bytes = section::encode(cells);
|
||||
let digest = Sha256::digest(&bytes);
|
||||
tx.execute(
|
||||
"INSERT OR IGNORE INTO blobs(digest,data) VALUES(?1,?2)",
|
||||
params![digest.as_slice(), &bytes],
|
||||
)?;
|
||||
let (id, existing): (i64, Vec<u8>) = tx
|
||||
.query_row(
|
||||
"SELECT id,data FROM blobs WHERE digest=?1 AND length(data)<=?2",
|
||||
params![digest.as_slice(), section::MAX_ENCODED_BYTES as i64],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.context("storage_corrupt: missing or oversized interned blob")?;
|
||||
ensure!(
|
||||
existing == bytes,
|
||||
"storage_corrupt: blob digest collision or corrupted content"
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn replay(
|
||||
tx: &Transaction<'_>,
|
||||
world_id: i64,
|
||||
operation_id: &str,
|
||||
fingerprint: &[u8; 32],
|
||||
) -> Result<Option<EditResult>> {
|
||||
let stored: Option<(Vec<u8>, i64, i64)> = tx.query_row(
|
||||
"SELECT fingerprint,revision,changed FROM operations WHERE world_id=?1 AND operation_id=?2",
|
||||
params![world_id, operation_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
).optional()?;
|
||||
if let Some((stored_fingerprint, revision, changed)) = stored {
|
||||
ensure!(
|
||||
stored_fingerprint.as_slice() == fingerprint,
|
||||
"idempotency_conflict: operation_id already used for a different request"
|
||||
);
|
||||
Ok(Some(EditResult {
|
||||
revision: revision as u64,
|
||||
changed: usize::try_from(changed)?,
|
||||
replayed: true,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_operation(
|
||||
tx: &Transaction<'_>,
|
||||
world: World,
|
||||
operation_id: &str,
|
||||
kind: &str,
|
||||
fingerprint: &[u8; 32],
|
||||
revision: i64,
|
||||
changed: usize,
|
||||
) -> Result<i64> {
|
||||
tx.execute(
|
||||
"INSERT INTO operations(world_id,operation_id,kind,fingerprint,revision,epoch,changed) VALUES(?1,?2,?3,?4,?5,?6,?7)",
|
||||
params![world.id, operation_id, kind, fingerprint.as_slice(), revision, world.epoch, changed as i64],
|
||||
)?;
|
||||
Ok(tx.last_insert_rowid())
|
||||
}
|
||||
|
||||
fn apply_changes(
|
||||
tx: &Transaction<'_>,
|
||||
cache: &mut SectionCache,
|
||||
registry_len: usize,
|
||||
world: World,
|
||||
operation: i64,
|
||||
changes: &[BlockChange],
|
||||
) -> Result<usize> {
|
||||
let mut sections: BTreeMap<SectionPos, Vec<&BlockChange>> = BTreeMap::new();
|
||||
for change in changes {
|
||||
sections
|
||||
.entry(locate(change.pos).0)
|
||||
.or_default()
|
||||
.push(change);
|
||||
}
|
||||
let mut changed = 0;
|
||||
let mut history = tx.prepare_cached(
|
||||
"INSERT INTO operation_changes(operation,x,y,z,before_id,after_id) VALUES(?1,?2,?3,?4,?5,?6)",
|
||||
)?;
|
||||
for (pos @ [x, y, z], updates) in sections {
|
||||
let mut cells = if let Some(id) = effective_blob(tx, world, pos)? {
|
||||
load_decoded(tx, cache, registry_len, id)?
|
||||
} else {
|
||||
Box::new([0; CELL_COUNT])
|
||||
};
|
||||
let mut section_changed = false;
|
||||
for change in updates {
|
||||
let index = locate(change.pos).1;
|
||||
let before = cells[index];
|
||||
if before != change.block {
|
||||
history.execute(params![
|
||||
operation,
|
||||
change.pos[0],
|
||||
change.pos[1],
|
||||
change.pos[2],
|
||||
before,
|
||||
change.block
|
||||
])?;
|
||||
cells[index] = change.block;
|
||||
changed += 1;
|
||||
section_changed = true;
|
||||
}
|
||||
}
|
||||
if !section_changed {
|
||||
continue;
|
||||
}
|
||||
let baseline = if let Some(id) = base_blob(tx, world.base, pos)? {
|
||||
load_decoded(tx, cache, registry_len, id)?
|
||||
} else {
|
||||
Box::new([0; CELL_COUNT])
|
||||
};
|
||||
let difference = cells
|
||||
.iter()
|
||||
.zip(baseline.iter())
|
||||
.filter(|(a, b)| a != b)
|
||||
.count();
|
||||
if difference == 0 {
|
||||
tx.execute(
|
||||
"DELETE FROM world_sections WHERE world_id=?1 AND x=?2 AND y=?3 AND z=?4",
|
||||
params![world.id, x, y, z],
|
||||
)?;
|
||||
} else {
|
||||
let blob = intern_blob(tx, &cells)?;
|
||||
tx.execute(
|
||||
"INSERT INTO world_sections(world_id,x,y,z,blob_id,changed_cells) VALUES(?1,?2,?3,?4,?5,?6)
|
||||
ON CONFLICT(world_id,x,y,z) DO UPDATE SET blob_id=excluded.blob_id,changed_cells=excluded.changed_cells",
|
||||
params![world.id, x, y, z, blob, difference as i64],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn edit_fingerprint(expected_revision: u64, changes: &[BlockChange]) -> [u8; 32] {
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(b"shacraft:edit:v1");
|
||||
hash.update(expected_revision.to_le_bytes());
|
||||
for change in changes {
|
||||
for coordinate in change.pos {
|
||||
hash.update(coordinate.to_le_bytes());
|
||||
}
|
||||
hash.update(change.block.to_le_bytes());
|
||||
}
|
||||
hash.finalize().into()
|
||||
}
|
||||
|
||||
fn named_fingerprint(kind: &str, expected_revision: u64, target: &str) -> [u8; 32] {
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(b"shacraft:named:v1\0");
|
||||
hash.update(kind.as_bytes());
|
||||
hash.update([0]);
|
||||
hash.update(expected_revision.to_le_bytes());
|
||||
hash.update(target.as_bytes());
|
||||
hash.finalize().into()
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
//! Public API checks for docs/CONTRACT.md, Stage 1 storage semantics.
|
||||
//!
|
||||
//! These tests deliberately avoid SQLite tables, section encodings, and other
|
||||
//! implementation details: durable state is inspected only by reopening the API.
|
||||
|
||||
use shacraft_core::{BlockChange, BlockId, Pos, WorldStore};
|
||||
use std::collections::BTreeMap;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn change(pos: Pos, block: BlockId) -> BlockChange {
|
||||
BlockChange { pos, block }
|
||||
}
|
||||
|
||||
fn fixture() -> (TempDir, WorldStore, BlockId, BlockId) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut store = WorldStore::open(directory.path().join("data"), 2).unwrap();
|
||||
let stone = store.register_block("minecraft:stone").unwrap();
|
||||
let dirt = store.register_block("minecraft:dirt").unwrap();
|
||||
store.create_world("world", None).unwrap();
|
||||
(directory, store, stone, dirt)
|
||||
}
|
||||
|
||||
fn region(store: &mut WorldStore, world: &str) -> BTreeMap<Pos, BlockId> {
|
||||
store
|
||||
.read_region(world, [-16, 0, 0], [16, 0, 0])
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|cell| (cell.pos, cell.block))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_replay_precedes_revision_check_and_survives_reset_and_reopen() {
|
||||
let (directory, mut store, stone, dirt) = fixture();
|
||||
let initial_revision = store.revision("world").unwrap();
|
||||
let original = store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"original",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!original.replayed);
|
||||
assert_eq!(original.changed, 1);
|
||||
let reset = store
|
||||
.reset_world("world", original.revision, "clear")
|
||||
.unwrap();
|
||||
let latest = store
|
||||
.edit(
|
||||
"world",
|
||||
reset.revision,
|
||||
"after-reset",
|
||||
vec![change([0, 0, 0], dirt)],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Replaying a committed operation returns its original result, without
|
||||
// checking its now-stale expected revision or reapplying its old changes.
|
||||
let replay = store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"original",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(replay.replayed);
|
||||
assert_eq!(replay.revision, original.revision);
|
||||
assert_eq!(replay.changed, original.changed);
|
||||
assert_eq!(store.revision("world").unwrap(), latest.revision);
|
||||
assert_eq!(store.get_block("world", [0, 0, 0]).unwrap(), dirt);
|
||||
|
||||
// No flush is needed to make an acknowledged operation durable.
|
||||
drop(store);
|
||||
let mut reopened = WorldStore::open(directory.path().join("data"), 2).unwrap();
|
||||
let replay = reopened
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"original",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(replay.replayed);
|
||||
assert_eq!(replay.revision, original.revision);
|
||||
assert_eq!(replay.changed, original.changed);
|
||||
assert_eq!(reopened.revision("world").unwrap(), latest.revision);
|
||||
assert_eq!(reopened.get_block("world", [0, 0, 0]).unwrap(), dirt);
|
||||
assert_eq!(reopened.registry()[stone as usize], "minecraft:stone");
|
||||
assert_eq!(reopened.registry()[dirt as usize], "minecraft:dirt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_id_collision_rejects_changed_payload_across_restart_and_methods() {
|
||||
let (directory, mut store, stone, dirt) = fixture();
|
||||
let initial_revision = store.revision("world").unwrap();
|
||||
let committed = store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"shared-id",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"shared-id",
|
||||
vec![change([0, 0, 0], dirt)],
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.reset_world("world", committed.revision, "shared-id")
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.revision("world").unwrap(), committed.revision);
|
||||
assert_eq!(store.get_block("world", [0, 0, 0]).unwrap(), stone);
|
||||
|
||||
drop(store);
|
||||
let mut reopened = WorldStore::open(directory.path().join("data"), 2).unwrap();
|
||||
assert!(
|
||||
reopened
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"shared-id",
|
||||
vec![change([1, 0, 0], stone)],
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(reopened.get_block("world", [1, 0, 0]).unwrap(), 0);
|
||||
assert_eq!(reopened.revision("world").unwrap(), committed.revision);
|
||||
|
||||
// IDs are scoped to their world, rather than reserved across the store.
|
||||
reopened.create_world("other", None).unwrap();
|
||||
let other_revision = reopened.revision("other").unwrap();
|
||||
let other = reopened
|
||||
.edit(
|
||||
"other",
|
||||
other_revision,
|
||||
"shared-id",
|
||||
vec![change([0, 0, 0], dirt)],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!other.replayed);
|
||||
assert_eq!(reopened.get_block("other", [0, 0, 0]).unwrap(), dirt);
|
||||
assert_eq!(reopened.get_block("world", [0, 0, 0]).unwrap(), stone);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_batch_never_partially_writes_or_advances_revision() {
|
||||
let (directory, mut store, stone, dirt) = fixture();
|
||||
let initial_revision = store.revision("world").unwrap();
|
||||
let seeded = store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"seed",
|
||||
vec![change([0, 0, 0], stone), change([-1, 0, 0], dirt)],
|
||||
)
|
||||
.unwrap();
|
||||
let original = region(&mut store, "world");
|
||||
let cases = [
|
||||
(
|
||||
"unknown-block",
|
||||
vec![change([0, 0, 0], dirt), change([1, 0, 0], u32::MAX)],
|
||||
),
|
||||
(
|
||||
"duplicate-position",
|
||||
vec![change([0, 0, 0], dirt), change([0, 0, 0], 0)],
|
||||
),
|
||||
(
|
||||
"positive-overflow",
|
||||
vec![change([0, 0, 0], dirt), change([30_000_001, 0, 0], stone)],
|
||||
),
|
||||
(
|
||||
"negative-overflow",
|
||||
vec![change([0, 0, 0], dirt), change([0, -30_000_001, 0], stone)],
|
||||
),
|
||||
(
|
||||
"integer-extreme",
|
||||
vec![change([0, 0, 0], dirt), change([0, 0, i32::MIN], stone)],
|
||||
),
|
||||
];
|
||||
for (operation, changes) in cases {
|
||||
assert!(
|
||||
store
|
||||
.edit("world", seeded.revision, operation, changes)
|
||||
.is_err(),
|
||||
"invalid batch {operation} was accepted"
|
||||
);
|
||||
assert_eq!(store.revision("world").unwrap(), seeded.revision);
|
||||
assert_eq!(region(&mut store, "world"), original);
|
||||
}
|
||||
|
||||
assert!(
|
||||
store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"stale-revision",
|
||||
vec![change([0, 0, 0], dirt)],
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.edit(
|
||||
"missing",
|
||||
seeded.revision,
|
||||
"missing-world",
|
||||
vec![change([0, 0, 0], dirt)],
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.list_worlds().unwrap().len(), 1);
|
||||
drop(store);
|
||||
let mut reopened = WorldStore::open(directory.path().join("data"), 2).unwrap();
|
||||
assert_eq!(reopened.revision("world").unwrap(), seeded.revision);
|
||||
assert_eq!(region(&mut reopened, "world"), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshots_pin_effective_source_contents_and_air_overrides() {
|
||||
let (directory, mut store, stone, dirt) = fixture();
|
||||
let initial_revision = store.revision("world").unwrap();
|
||||
let original = store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"seed",
|
||||
vec![change([-16, 0, 0], stone), change([16, 0, 0], dirt)],
|
||||
)
|
||||
.unwrap();
|
||||
store.create_world("first", Some("world")).unwrap();
|
||||
let changed_source = store
|
||||
.edit(
|
||||
"world",
|
||||
original.revision,
|
||||
"change-source",
|
||||
vec![change([-16, 0, 0], 0), change([16, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
store.create_world("second", Some("world")).unwrap();
|
||||
|
||||
let first_revision = store.revision("first").unwrap();
|
||||
store
|
||||
.edit(
|
||||
"first",
|
||||
first_revision,
|
||||
"override-inherited",
|
||||
vec![change([-16, 0, 0], 0), change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
store.create_world("grandchild", Some("first")).unwrap();
|
||||
|
||||
store
|
||||
.reset_world("world", changed_source.revision, "reset-source")
|
||||
.unwrap();
|
||||
let changed_first_revision = store.revision("first").unwrap();
|
||||
store
|
||||
.reset_world("first", changed_first_revision, "reset-first")
|
||||
.unwrap();
|
||||
drop(store);
|
||||
|
||||
let mut reopened = WorldStore::open(directory.path().join("data"), 1).unwrap();
|
||||
assert!(region(&mut reopened, "world").is_empty());
|
||||
assert_eq!(
|
||||
region(&mut reopened, "first"),
|
||||
BTreeMap::from([([-16, 0, 0], stone), ([16, 0, 0], dirt)])
|
||||
);
|
||||
assert_eq!(
|
||||
region(&mut reopened, "second"),
|
||||
BTreeMap::from([([16, 0, 0], stone)])
|
||||
);
|
||||
assert_eq!(
|
||||
region(&mut reopened, "grandchild"),
|
||||
BTreeMap::from([([0, 0, 0], stone), ([16, 0, 0], dirt)])
|
||||
);
|
||||
// Repeated cross-section reads exercise eviction without exposing the codec.
|
||||
for _ in 0..3 {
|
||||
assert_eq!(reopened.get_block("grandchild", [-16, 0, 0]).unwrap(), 0);
|
||||
assert_eq!(reopened.get_block("first", [-16, 0, 0]).unwrap(), stone);
|
||||
assert_eq!(reopened.get_block("grandchild", [16, 0, 0]).unwrap(), dirt);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_is_monotonic_idempotent_durable_and_an_undo_barrier() {
|
||||
let (directory, mut store, stone, dirt) = fixture();
|
||||
let initial_revision = store.revision("world").unwrap();
|
||||
store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"seed",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
store.create_world("arena", Some("world")).unwrap();
|
||||
let arena_initial = store.revision("arena").unwrap();
|
||||
let edited = store
|
||||
.edit(
|
||||
"arena",
|
||||
arena_initial,
|
||||
"remove-floor",
|
||||
vec![change([0, 0, 0], 0), change([1, 0, 0], dirt)],
|
||||
)
|
||||
.unwrap();
|
||||
let reset = store
|
||||
.reset_world("arena", edited.revision, "reset-round")
|
||||
.unwrap();
|
||||
assert!(!reset.replayed);
|
||||
assert!(reset.revision > edited.revision);
|
||||
assert_eq!(store.get_block("arena", [0, 0, 0]).unwrap(), stone);
|
||||
assert_eq!(store.get_block("arena", [1, 0, 0]).unwrap(), 0);
|
||||
assert!(
|
||||
store
|
||||
.undo("arena", reset.revision, "past-barrier", "remove-floor")
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let latest = store
|
||||
.edit(
|
||||
"arena",
|
||||
reset.revision,
|
||||
"next-round",
|
||||
vec![change([0, 0, 0], dirt)],
|
||||
)
|
||||
.unwrap();
|
||||
let replay = store
|
||||
.reset_world("arena", edited.revision, "reset-round")
|
||||
.unwrap();
|
||||
assert!(replay.replayed);
|
||||
assert_eq!(replay.revision, reset.revision);
|
||||
assert_eq!(replay.changed, reset.changed);
|
||||
assert_eq!(store.revision("arena").unwrap(), latest.revision);
|
||||
assert_eq!(store.get_block("arena", [0, 0, 0]).unwrap(), dirt);
|
||||
assert!(
|
||||
store
|
||||
.reset_world("arena", edited.revision, "stale-new-reset")
|
||||
.is_err()
|
||||
);
|
||||
|
||||
drop(store);
|
||||
let mut reopened = WorldStore::open(directory.path().join("data"), 2).unwrap();
|
||||
let replay = reopened
|
||||
.reset_world("arena", edited.revision, "reset-round")
|
||||
.unwrap();
|
||||
assert!(replay.replayed);
|
||||
assert_eq!(replay.revision, reset.revision);
|
||||
assert_eq!(reopened.revision("arena").unwrap(), latest.revision);
|
||||
assert_eq!(reopened.get_block("arena", [0, 0, 0]).unwrap(), dirt);
|
||||
let next_reset = reopened
|
||||
.reset_world("arena", latest.revision, "second-reset")
|
||||
.unwrap();
|
||||
assert!(next_reset.revision > latest.revision);
|
||||
assert_eq!(reopened.get_block("arena", [0, 0, 0]).unwrap(), stone);
|
||||
let already_at_base = reopened
|
||||
.reset_world("arena", next_reset.revision, "third-reset")
|
||||
.unwrap();
|
||||
assert!(already_at_base.revision > next_reset.revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_restores_inherited_content_and_replays_without_mutating_newer_state() {
|
||||
let (directory, mut store, stone, dirt) = fixture();
|
||||
let initial_revision = store.revision("world").unwrap();
|
||||
store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"seed",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
store.create_world("child", Some("world")).unwrap();
|
||||
let child_revision = store.revision("child").unwrap();
|
||||
let edited = store
|
||||
.edit(
|
||||
"child",
|
||||
child_revision,
|
||||
"break-and-add",
|
||||
vec![change([0, 0, 0], 0), change([1, 0, 0], dirt)],
|
||||
)
|
||||
.unwrap();
|
||||
let undone = store
|
||||
.undo("child", edited.revision, "undo-edit", "break-and-add")
|
||||
.unwrap();
|
||||
assert!(!undone.replayed);
|
||||
assert!(undone.revision > edited.revision);
|
||||
assert_eq!(store.get_block("child", [0, 0, 0]).unwrap(), stone);
|
||||
assert_eq!(store.get_block("child", [1, 0, 0]).unwrap(), 0);
|
||||
assert!(
|
||||
store
|
||||
.undo("child", undone.revision, "undo-an-undo", "undo-edit")
|
||||
.is_err()
|
||||
);
|
||||
let later = store
|
||||
.edit(
|
||||
"child",
|
||||
undone.revision,
|
||||
"later",
|
||||
vec![change([0, 0, 0], dirt)],
|
||||
)
|
||||
.unwrap();
|
||||
drop(store);
|
||||
|
||||
let mut reopened = WorldStore::open(directory.path().join("data"), 2).unwrap();
|
||||
let replay = reopened
|
||||
.undo("child", edited.revision, "undo-edit", "break-and-add")
|
||||
.unwrap();
|
||||
assert!(replay.replayed);
|
||||
assert_eq!(replay.revision, undone.revision);
|
||||
assert_eq!(replay.changed, undone.changed);
|
||||
assert_eq!(reopened.revision("child").unwrap(), later.revision);
|
||||
assert_eq!(reopened.get_block("child", [0, 0, 0]).unwrap(), dirt);
|
||||
assert!(
|
||||
reopened
|
||||
.undo("child", edited.revision, "undo-edit", "later")
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(reopened.revision("child").unwrap(), later.revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_undo_rejects_later_nonoverlapping_edits_and_aba_cycles() {
|
||||
let (_directory, mut store, stone, dirt) = fixture();
|
||||
let initial_revision = store.revision("world").unwrap();
|
||||
let target = store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"target",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
let nonoverlapping = store
|
||||
.edit(
|
||||
"world",
|
||||
target.revision,
|
||||
"nonoverlapping",
|
||||
vec![change([1, 0, 0], dirt)],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.undo("world", nonoverlapping.revision, "strict-undo", "target",)
|
||||
.is_err()
|
||||
);
|
||||
let to_b = store
|
||||
.edit(
|
||||
"world",
|
||||
nonoverlapping.revision,
|
||||
"to-b",
|
||||
vec![change([0, 0, 0], dirt)],
|
||||
)
|
||||
.unwrap();
|
||||
let back_to_a = store
|
||||
.edit(
|
||||
"world",
|
||||
to_b.revision,
|
||||
"back-to-a",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.undo("world", back_to_a.revision, "aba-undo", "target")
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.revision("world").unwrap(), back_to_a.revision);
|
||||
assert_eq!(store.get_block("world", [0, 0, 0]).unwrap(), stone);
|
||||
assert_eq!(store.get_block("world", [1, 0, 0]).unwrap(), dirt);
|
||||
|
||||
// The immediately preceding edit can still be undone after a rejected undo.
|
||||
let valid = store
|
||||
.undo("world", back_to_a.revision, "undo-latest", "back-to-a")
|
||||
.unwrap();
|
||||
assert!(valid.revision > back_to_a.revision);
|
||||
assert_eq!(store.get_block("world", [0, 0, 0]).unwrap(), dirt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_noop_advances_revision_and_prevents_undo_of_earlier_edit() {
|
||||
let (_directory, mut store, stone, _dirt) = fixture();
|
||||
let initial_revision = store.revision("world").unwrap();
|
||||
let target = store
|
||||
.edit(
|
||||
"world",
|
||||
initial_revision,
|
||||
"target",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
let noop = store
|
||||
.edit(
|
||||
"world",
|
||||
target.revision,
|
||||
"same-value",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(noop.changed, 0);
|
||||
assert!(!noop.replayed);
|
||||
assert!(noop.revision > target.revision);
|
||||
assert!(
|
||||
store
|
||||
.undo("world", noop.revision, "undo-past-noop", "target")
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.revision("world").unwrap(), noop.revision);
|
||||
assert_eq!(store.get_block("world", [0, 0, 0]).unwrap(), stone);
|
||||
let replay = store
|
||||
.edit(
|
||||
"world",
|
||||
target.revision,
|
||||
"same-value",
|
||||
vec![change([0, 0, 0], stone)],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(replay.replayed);
|
||||
assert_eq!(replay.changed, 0);
|
||||
assert_eq!(replay.revision, noop.revision);
|
||||
assert_eq!(store.revision("world").unwrap(), noop.revision);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use shacraft_core::{BlockChange, COORD_LIMIT, MAX_READ_VOLUME, WorldStore};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn change(pos: [i32; 3], block: u32) -> BlockChange {
|
||||
BlockChange { pos, block }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_cache_evicts_distinct_blobs_and_shares_identical_sections() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let mut store = WorldStore::open(dir.path(), 2)?;
|
||||
let stone = store.register_block("minecraft:stone")?;
|
||||
store.create_world("main", None)?;
|
||||
// Different local positions create 6 distinct immutable section payloads.
|
||||
let changes = (0..6).map(|i| change([i * 16 + i, 0, 0], stone)).collect();
|
||||
store.edit("main", 0, "distinct", changes)?;
|
||||
assert_eq!(store.stats()["immutable_blob_count"], 6);
|
||||
for i in 0..6 {
|
||||
assert_eq!(store.get_block("main", [i * 16 + i, 0, 0])?, stone);
|
||||
assert_eq!(store.get_block("main", [i * 16 + (i + 1) % 16, 0, 0])?, 0);
|
||||
let stats = store.stats();
|
||||
assert!(stats["cache_entries"].as_u64().unwrap() <= 2);
|
||||
assert!(stats["cache_payload_bytes"].as_u64().unwrap() < 2 * 16384);
|
||||
}
|
||||
assert_eq!(store.stats()["cache_entries"], 2);
|
||||
assert!(store.stats()["cache_evictions"].as_u64().unwrap() >= 4);
|
||||
store.create_world("child", Some("main"))?;
|
||||
let count = store.stats()["immutable_blob_count"].clone();
|
||||
assert_eq!(count, 6);
|
||||
let before_hits = store.stats()["cache_hits"].as_u64().unwrap();
|
||||
assert_eq!(store.get_block("child", [85, 0, 0])?, stone);
|
||||
assert!(store.stats()["cache_hits"].as_u64().unwrap() > before_hits);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniform_section_remains_twenty_bytes_in_resident_cache() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let mut store = WorldStore::open(dir.path(), 1)?;
|
||||
let stone = store.register_block("minecraft:stone")?;
|
||||
store.create_world("main", None)?;
|
||||
let mut changes = Vec::new();
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
changes.push(change([x, y, z], stone));
|
||||
}
|
||||
}
|
||||
}
|
||||
store.edit("main", 0, "fill", changes)?;
|
||||
assert_eq!(store.get_block("main", [15, 15, 15])?, stone);
|
||||
assert_eq!(store.stats()["cache_payload_bytes"], 20);
|
||||
assert_eq!(store.stats()["cache_entries"], 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinate_boundaries_and_inclusive_read_limits() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let mut store = WorldStore::open(dir.path(), 1)?;
|
||||
let stone = store.register_block("minecraft:stone")?;
|
||||
store.create_world("main", None)?;
|
||||
let positions = [
|
||||
[-COORD_LIMIT, -COORD_LIMIT, -COORD_LIMIT],
|
||||
[COORD_LIMIT, COORD_LIMIT, COORD_LIMIT],
|
||||
[-17, -16, -1],
|
||||
[-16, -1, 0],
|
||||
[-1, 0, 15],
|
||||
[0, 15, 16],
|
||||
[15, 16, 17],
|
||||
];
|
||||
store.edit(
|
||||
"main",
|
||||
0,
|
||||
"edges",
|
||||
positions
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|p| change(p, stone))
|
||||
.collect(),
|
||||
)?;
|
||||
for pos in positions {
|
||||
assert_eq!(store.get_block("main", pos)?, stone);
|
||||
assert_eq!(
|
||||
store.read_region("main", pos, pos)?,
|
||||
vec![change(pos, stone)]
|
||||
);
|
||||
}
|
||||
assert!(store.get_block("main", [i32::MIN, 0, 0]).is_err());
|
||||
assert!(store.get_block("main", [0, i32::MAX, 0]).is_err());
|
||||
assert!(store.read_region("main", [0, 0, 0], [63, 63, 63]).is_ok());
|
||||
assert!(store.read_region("main", [0, 0, 0], [64, 63, 63]).is_err());
|
||||
assert!(store.read_region("main", [1, 0, 0], [0, 0, 0]).is_err());
|
||||
assert!(
|
||||
store
|
||||
.read_region("main", [-COORD_LIMIT; 3], [COORD_LIMIT; 3])
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(MAX_READ_VOLUME, 64 * 64 * 64);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_is_exclusive_and_released_on_drop() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
assert!(WorldStore::open(dir.path(), 0).is_err());
|
||||
let store = WorldStore::open(dir.path(), 1)?;
|
||||
assert!(WorldStore::open(dir.path(), 1).is_err());
|
||||
drop(store);
|
||||
let _reopened = WorldStore::open(dir.path(), 1)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_failure_mid_edit_rolls_back_blobs_history_and_revision() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let mut store = WorldStore::open(dir.path(), 1)?;
|
||||
let stone = store.register_block("minecraft:stone")?;
|
||||
store.create_world("main", None)?;
|
||||
drop(store);
|
||||
let db = Connection::open(dir.path().join("worlds.sqlite3"))?;
|
||||
db.execute_batch(
|
||||
"CREATE TRIGGER injected_failure BEFORE INSERT ON world_sections
|
||||
WHEN NEW.x=1 BEGIN SELECT RAISE(FAIL,'injected write failure'); END;",
|
||||
)?;
|
||||
drop(db);
|
||||
let mut store = WorldStore::open(dir.path(), 1)?;
|
||||
assert!(
|
||||
store
|
||||
.edit(
|
||||
"main",
|
||||
0,
|
||||
"fail",
|
||||
vec![change([0, 0, 0], stone), change([16, 0, 0], stone)]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.revision("main")?, 0);
|
||||
assert_eq!(store.get_block("main", [0, 0, 0])?, 0);
|
||||
assert_eq!(store.get_block("main", [16, 0, 0])?, 0);
|
||||
assert_eq!(store.stats()["operation_count"], 0);
|
||||
assert_eq!(store.stats()["history_change_count"], 0);
|
||||
assert_eq!(store.stats()["immutable_blob_count"], 0);
|
||||
// A failed operation ID was never consumed.
|
||||
assert!(
|
||||
!store
|
||||
.edit("main", 0, "fail", vec![change([0, 0, 0], stone)])?
|
||||
.replayed
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_gc_preserves_snapshots_history_and_cache_correctness() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let mut store = WorldStore::open(dir.path(), 2)?;
|
||||
let stone = store.register_block("minecraft:stone")?;
|
||||
let glass = store.register_block("minecraft:glass")?;
|
||||
store.create_world("main", None)?;
|
||||
store.edit("main", 0, "stone", vec![change([0, 0, 0], stone)])?;
|
||||
store.create_world("child", Some("main"))?;
|
||||
store.edit("main", 1, "glass", vec![change([0, 0, 0], glass)])?;
|
||||
assert_eq!(store.get_block("main", [0, 0, 0])?, glass);
|
||||
store.reset_world("main", 2, "reset")?;
|
||||
assert_eq!(store.collect_garbage(1)?, 1);
|
||||
assert_eq!(store.get_block("child", [0, 0, 0])?, stone);
|
||||
assert_eq!(store.get_block("main", [0, 0, 0])?, 0);
|
||||
let original = store.edit("main", 0, "stone", vec![change([0, 0, 0], stone)])?;
|
||||
assert!(original.replayed);
|
||||
assert_eq!(original.revision, 1);
|
||||
// New blob rowid can reuse the deleted ID; cache must not serve old data.
|
||||
store.edit("main", 3, "new", vec![change([1, 0, 0], glass)])?;
|
||||
assert_eq!(store.get_block("main", [0, 0, 0])?, 0);
|
||||
assert_eq!(store.get_block("main", [1, 0, 0])?, glass);
|
||||
assert!(store.collect_garbage(0).is_err());
|
||||
assert!(store.collect_garbage(4097).is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_is_canonical_durable_and_rejects_malformed_identifiers() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let mut store = WorldStore::open(dir.path(), 1)?;
|
||||
assert_eq!(store.register_block("minecraft:air")?, 0);
|
||||
let id = store.register_block("example:machine[z=true,a=7]")?;
|
||||
assert_eq!(store.registry()[id as usize], "example:machine[a=7,z=true]");
|
||||
assert_eq!(store.register_block("example:machine[a=7,z=true]")?, id);
|
||||
for bad in [
|
||||
"stone",
|
||||
":stone",
|
||||
"Minecraft:stone",
|
||||
"minecraft:stone[]",
|
||||
"minecraft:stone[a=x,a=y]",
|
||||
"minecraft:stone[a=x",
|
||||
"minecraft:stone[a=x]=y",
|
||||
] {
|
||||
assert!(store.register_block(bad).is_err(), "accepted {bad}");
|
||||
}
|
||||
let registry = store.registry().to_vec();
|
||||
drop(store);
|
||||
let reopened = WorldStore::open(dir.path(), 1)?;
|
||||
assert_eq!(reopened.registry(), registry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupted_blob_is_reported_instead_of_air() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let mut store = WorldStore::open(dir.path(), 1)?;
|
||||
let stone = store.register_block("minecraft:stone")?;
|
||||
store.create_world("main", None)?;
|
||||
store.edit("main", 0, "edit", vec![change([0, 0, 0], stone)])?;
|
||||
drop(store);
|
||||
let db = Connection::open(dir.path().join("worlds.sqlite3"))?;
|
||||
db.execute("UPDATE blobs SET data=?1", [vec![0_u8; 20]])?;
|
||||
drop(db);
|
||||
let mut reopened = WorldStore::open(dir.path(), 1)?;
|
||||
let error = reopened.get_block("main", [0, 0, 0]).unwrap_err();
|
||||
assert!(error.to_string().contains("storage_corrupt"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_unreachable_blob_is_rejected_before_reinterning() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let mut store = WorldStore::open(dir.path(), 1)?;
|
||||
let stone = store.register_block("minecraft:stone")?;
|
||||
store.create_world("main", None)?;
|
||||
store.edit("main", 0, "stone", vec![change([0, 0, 0], stone)])?;
|
||||
store.edit("main", 1, "air", vec![change([0, 0, 0], 0)])?;
|
||||
drop(store);
|
||||
// Keep the old digest but corrupt a now-unreachable blob's contents. A
|
||||
// later intern must not fetch this BLOB into a Vec before checking length.
|
||||
let db = Connection::open(dir.path().join("worlds.sqlite3"))?;
|
||||
db.execute("UPDATE blobs SET data=zeroblob(1000000)", [])?;
|
||||
drop(db);
|
||||
let mut store = WorldStore::open(dir.path(), 1)?;
|
||||
let error = store
|
||||
.edit("main", 2, "again", vec![change([0, 0, 0], stone)])
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("storage_corrupt"));
|
||||
assert_eq!(store.revision("main")?, 2);
|
||||
assert_eq!(store.get_block("main", [0, 0, 0])?, 0);
|
||||
assert_eq!(store.stats()["operation_count"], 2);
|
||||
assert_eq!(store.stats()["cache_entries"], 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revision_overflow_rejects_mutation_but_keeps_original_replay() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let mut store = WorldStore::open(dir.path(), 1)?;
|
||||
let stone = store.register_block("minecraft:stone")?;
|
||||
store.create_world("main", None)?;
|
||||
store.edit("main", 0, "edit", vec![change([0, 0, 0], stone)])?;
|
||||
drop(store);
|
||||
let db = Connection::open(dir.path().join("worlds.sqlite3"))?;
|
||||
db.execute("UPDATE worlds SET revision=?1", params![i64::MAX])?;
|
||||
drop(db);
|
||||
let mut store = WorldStore::open(dir.path(), 1)?;
|
||||
assert!(
|
||||
store
|
||||
.edit(
|
||||
"main",
|
||||
i64::MAX as u64,
|
||||
"overflow",
|
||||
vec![change([0, 0, 0], 0)]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.revision("main")?, i64::MAX as u64);
|
||||
assert_eq!(store.get_block("main", [0, 0, 0])?, stone);
|
||||
assert!(
|
||||
store
|
||||
.edit("main", 0, "edit", vec![change([0, 0, 0], stone)])?
|
||||
.replayed
|
||||
);
|
||||
assert_eq!(store.stats()["operation_count"], 1);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "shacraft-tools"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
shacraft-core.workspace = true
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Local storage probe. JSON-lines session is a test/CLI interface, not MCP.
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::{Parser, Subcommand};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use shacraft_core::{BlockChange, Pos, WorldStore};
|
||||
use std::{
|
||||
io::{self, BufRead, Write},
|
||||
path::PathBuf,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about = "Shacraft Core storage tools (stage 1)")]
|
||||
struct Args {
|
||||
#[arg(long, default_value = "data")]
|
||||
data: PathBuf,
|
||||
#[arg(long, default_value_t = 64)]
|
||||
cache: usize,
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Read JSON requests, one per line. Flush each reply after the API returns.
|
||||
Session,
|
||||
/// Show storage metrics; estimated payload and OS RSS remain separate.
|
||||
Stats,
|
||||
/// Exercise shared snapshots, independent edits, undo and reset.
|
||||
Demo,
|
||||
/// Reproducible storage workload. Requires an empty world store.
|
||||
Benchmark {
|
||||
#[arg(long, default_value_t = 256)]
|
||||
sections: usize,
|
||||
#[arg(long, default_value_t = 100)]
|
||||
worlds: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
|
||||
enum Request {
|
||||
List,
|
||||
Register {
|
||||
state: String,
|
||||
},
|
||||
Registry,
|
||||
Create {
|
||||
name: String,
|
||||
template: Option<String>,
|
||||
},
|
||||
Revision {
|
||||
world: String,
|
||||
},
|
||||
Get {
|
||||
world: String,
|
||||
pos: Pos,
|
||||
},
|
||||
Read {
|
||||
world: String,
|
||||
min: Pos,
|
||||
max: Pos,
|
||||
},
|
||||
Edit {
|
||||
world: String,
|
||||
expected_revision: u64,
|
||||
operation_id: String,
|
||||
changes: Vec<BlockChange>,
|
||||
},
|
||||
Undo {
|
||||
world: String,
|
||||
expected_revision: u64,
|
||||
operation_id: String,
|
||||
target_operation: String,
|
||||
},
|
||||
Reset {
|
||||
world: String,
|
||||
expected_revision: u64,
|
||||
operation_id: String,
|
||||
},
|
||||
Stats,
|
||||
Flush,
|
||||
}
|
||||
|
||||
fn execute(store: &mut WorldStore, request: Request) -> Result<Value> {
|
||||
Ok(match request {
|
||||
Request::List => json!(store.list_worlds()?),
|
||||
Request::Register { state } => json!(store.register_block(&state)?),
|
||||
Request::Registry => json!(store.registry()),
|
||||
Request::Create { name, template } => {
|
||||
store.create_world(&name, template.as_deref())?;
|
||||
json!({"name":name, "revision":store.revision(&name)?})
|
||||
}
|
||||
Request::Revision { world } => json!(store.revision(&world)?),
|
||||
Request::Get { world, pos } => json!(store.get_block(&world, pos)?),
|
||||
Request::Read { world, min, max } => json!(store.read_region(&world, min, max)?),
|
||||
Request::Edit {
|
||||
world,
|
||||
expected_revision,
|
||||
operation_id,
|
||||
changes,
|
||||
} => json!(store.edit(&world, expected_revision, &operation_id, changes)?),
|
||||
Request::Undo {
|
||||
world,
|
||||
expected_revision,
|
||||
operation_id,
|
||||
target_operation,
|
||||
} => json!(store.undo(&world, expected_revision, &operation_id, &target_operation)?),
|
||||
Request::Reset {
|
||||
world,
|
||||
expected_revision,
|
||||
operation_id,
|
||||
} => json!(store.reset_world(&world, expected_revision, &operation_id)?),
|
||||
Request::Stats => metrics(store),
|
||||
Request::Flush => {
|
||||
store.flush()?;
|
||||
json!({"flushed":true})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn os_memory() -> Value {
|
||||
// Linux measurements. Unavailable on other platforms is null, never zero.
|
||||
let read = std::fs::read_to_string("/proc/self/status").ok();
|
||||
let field = |key: &str| -> Option<u64> {
|
||||
read.as_ref()?
|
||||
.lines()
|
||||
.find(|line| line.starts_with(key))?
|
||||
.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse::<u64>()
|
||||
.ok()?
|
||||
.checked_mul(1024)
|
||||
};
|
||||
json!({"rss_bytes":field("VmRSS:"), "peak_rss_bytes":field("VmHWM:"),
|
||||
"source":"/proc/self/status (null when unavailable)"})
|
||||
}
|
||||
|
||||
fn metrics(store: &WorldStore) -> Value {
|
||||
json!({"storage":store.stats(), "process":os_memory()})
|
||||
}
|
||||
|
||||
fn session(store: &mut WorldStore) -> Result<()> {
|
||||
let stdin = io::stdin();
|
||||
let mut input = stdin.lock();
|
||||
let stdout = io::stdout();
|
||||
let mut output = stdout.lock();
|
||||
const MAX_LINE: usize = 8 * 1024 * 1024;
|
||||
loop {
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
let available = input.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
break;
|
||||
}
|
||||
let n = available
|
||||
.iter()
|
||||
.position(|&c| c == b'\n')
|
||||
.map_or(available.len(), |n| n + 1);
|
||||
if line.len().saturating_add(n) > MAX_LINE {
|
||||
bail!("request exceeds 8 MiB");
|
||||
}
|
||||
line.extend_from_slice(&available[..n]);
|
||||
input.consume(n);
|
||||
if line.last() == Some(&b'\n') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
let result = serde_json::from_slice::<Request>(&line)
|
||||
.context("invalid request")
|
||||
.and_then(|request| execute(store, request));
|
||||
let reply = match result {
|
||||
Ok(result) => json!({"ok":true, "result":result}),
|
||||
Err(error) => json!({"ok":false, "error":format!("{error:#}")}),
|
||||
};
|
||||
serde_json::to_writer(&mut output, &reply)?;
|
||||
writeln!(output)?;
|
||||
output.flush()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_empty(store: &WorldStore) -> Result<()> {
|
||||
if !store.list_worlds()?.is_empty() {
|
||||
bail!("demo/benchmark requires an empty store; use a fresh --data directory");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn demo(store: &mut WorldStore) -> Result<Value> {
|
||||
require_empty(store)?;
|
||||
let stone = store.register_block("shacraft:stone")?;
|
||||
store.create_world("template", None)?;
|
||||
let changes = (-16..16)
|
||||
.flat_map(|x| {
|
||||
(-16..16).map(move |z| BlockChange {
|
||||
pos: [x, 0, z],
|
||||
block: stone,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
store.edit("template", 0, "build-floor", changes)?;
|
||||
store.create_world("arena_a", Some("template"))?;
|
||||
store.create_world("arena_b", Some("template"))?;
|
||||
let edited = store.edit(
|
||||
"arena_a",
|
||||
0,
|
||||
"break-floor",
|
||||
vec![BlockChange {
|
||||
pos: [0, 0, 0],
|
||||
block: 0,
|
||||
}],
|
||||
)?;
|
||||
anyhow::ensure!(
|
||||
store.get_block("arena_b", [0, 0, 0])? == stone,
|
||||
"fork isolation failed"
|
||||
);
|
||||
let undo = store.undo("arena_a", edited.revision, "restore-floor", "break-floor")?;
|
||||
let reset = store.reset_world("arena_a", undo.revision, "reset-match")?;
|
||||
store.flush()?;
|
||||
Ok(
|
||||
json!({"scenario":"shared floor -> isolated edit -> undo -> reset", "worlds":store.list_worlds()?,
|
||||
"edit":edited,"undo":undo,"reset":reset,"metrics":metrics(store)}),
|
||||
)
|
||||
}
|
||||
|
||||
fn benchmark(store: &mut WorldStore, sections: usize, worlds: usize) -> Result<Value> {
|
||||
require_empty(store)?;
|
||||
anyhow::ensure!((1..=4096).contains(§ions), "sections must be 1..4096");
|
||||
anyhow::ensure!((1..=1000).contains(&worlds), "worlds must be 1..1000");
|
||||
let start = Instant::now();
|
||||
let stone = store.register_block("shacraft:stone")?;
|
||||
store.create_world("template", None)?;
|
||||
// Each section differs, so this probes eviction as well as content sharing.
|
||||
for section in 0..sections {
|
||||
let sx = (section % 64) as i32 * 16;
|
||||
let sz = (section / 64) as i32 * 16;
|
||||
let changes = (0..256)
|
||||
.map(|cell| BlockChange {
|
||||
pos: [sx + cell % 16, 0, sz + cell / 16],
|
||||
block: if cell as usize == section % 256 {
|
||||
0
|
||||
} else {
|
||||
stone
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
store.edit(
|
||||
"template",
|
||||
store.revision("template")?,
|
||||
&format!("seed-{section}"),
|
||||
changes,
|
||||
)?;
|
||||
}
|
||||
let seeded_ms = start.elapsed().as_millis();
|
||||
let after_template = metrics(store);
|
||||
let fork_start = Instant::now();
|
||||
for world in 0..worlds {
|
||||
store.create_world(&format!("arena_{world}"), Some("template"))?;
|
||||
}
|
||||
let fork_ms = fork_start.elapsed().as_millis();
|
||||
let after_forks = metrics(store);
|
||||
for section in 0..sections {
|
||||
let pos = [(section % 64) as i32 * 16, 0, (section / 64) as i32 * 16];
|
||||
store.get_block("template", pos)?;
|
||||
}
|
||||
for world in 0..worlds {
|
||||
let name = format!("arena_{world}");
|
||||
store.edit(
|
||||
&name,
|
||||
0,
|
||||
"isolated",
|
||||
vec![BlockChange {
|
||||
pos: [1, 1, 1],
|
||||
block: stone,
|
||||
}],
|
||||
)?;
|
||||
store.reset_world(&name, 1, "reset")?;
|
||||
anyhow::ensure!(store.get_block(&name, [1, 1, 1])? == 0, "reset failed");
|
||||
}
|
||||
store.flush()?;
|
||||
Ok(
|
||||
json!({"scenario":"storage-only; no players, rendering or tick simulation",
|
||||
"comparison":"no Paper baseline measured", "sections":sections, "forked_worlds":worlds,
|
||||
"seed_ms":seeded_ms, "fork_ms":fork_ms, "elapsed_ms":start.elapsed().as_millis(),
|
||||
"after_template":after_template,"after_forks":after_forks,"after_access_edit_reset":metrics(store)}),
|
||||
)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
let mut store = WorldStore::open(&args.data, args.cache)?;
|
||||
let result = match args.command {
|
||||
Command::Session => return session(&mut store),
|
||||
Command::Stats => metrics(&store),
|
||||
Command::Demo => demo(&mut store)?,
|
||||
Command::Benchmark { sections, worlds } => benchmark(&mut store, sections, worlds)?,
|
||||
};
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user