Skip to main content

delvewright_dsl/
blocks.rs

1//! The pinned 1.21.11 **block-state registry**, and the check every emitter of a
2//! structure template owes it.
3//!
4//! # Why this exists
5//!
6//! CLAUDE.md, on the `delvec prefab` finding: *an EMITTED command is
7//! checked against the pinned command tree by the emitter, not by a test, because
8//! the operator running the tool does not run `cargo test`.* Blocks had no such
9//! rule. The consequence is measured, not hypothetical: 1.21.11 renamed
10//! `minecraft:chain` to `minecraft:iron_chain`, and
11//! `prefabs/tidal-keep-generator` kept placing the old id — eight cells of
12//! bell-rope in `tk-bell-tower.nbt` that do not exist. A structure template
13//! carrying an unknown block loads it as **air**: the generator exits 0, the
14//! `.nbt` is well-formed, the byte-identity gate passes, the tower simply has no
15//! ropes, and nothing anywhere says so. The same class is in the shipped
16//! library: `hero-temple-ruin-arch.nbt` carries `minecraft:chain` at `[4, 9, 13]`
17//! (1 of 36 prefabs, measured 2026-08-11 with `delvec prefab audit`).
18//!
19//! So this module is the block half of the command rule. It lives here, beside
20//! the other pinned registries, because the registry is a fact about the pinned
21//! game rather than about any one reader of it: the structure-template writer
22//! (`delvec::schem`, which re-exports this module), the grammar back end,
23//! the admission audit and the compiler's own render surface all check against
24//! this one table. And no crate is the only site that turns a palette into
25//! `.nbt` bytes — reasoning as though one were is what left the sixth emitter
26//! unguarded. The seven `prefabs/*-generator` packages may not depend on
27//! `delvec` and reach the same rule through `prefab-invariants`, which depends
28//! on this crate. Which sites owe it is therefore not remembered: it
29//! is discovered from the ingredient by `tools/ci/check-structure-emitters.py`,
30//! which treats every tracked file that calls the NBT serialiser as a
31//! candidate, and requires each to name the rule or declare why it need not.
32//!
33//! # What it validates
34//!
35//! A full block state: the id, every property name, and every property value,
36//! against `crates/dsl/data/blocks-1.21.11.json` (see that directory's
37//! `PROVENANCE.md`). Nothing here is a heuristic and nothing is a warning —
38//! either 1.21.11 has that state or it does not.
39//!
40//! It deliberately does **not** validate that a state is *sensible* (a floating
41//! stair, a waterlogged block in the sky). That is craft, and craft is judged by
42//! the gates over the expanded model; this is spelling.
43
44use std::collections::BTreeMap;
45
46use crate::diagnostic::{DwCode, ExitTier};
47use std::fmt;
48use std::sync::OnceLock;
49
50/// The pinned registry's JSON text, vendored beside the other 1.21.11 data.
51///
52/// One authority, shared by this crate, the grammar back end and the
53/// out-of-workspace prefab generators, which read the same file
54/// (`prefabs/invariants/src/invariants.rs`) because none of them may depend on
55/// `delvec`. A moved data file is a compile error, which is the loud failure.
56const REGISTRY_JSON: &str = include_str!("../data/blocks-1.21.11.json");
57
58/// The shape-carrying properties per block: the properties named by `multipart`
59/// selectors in the block's own blockstate definition, derived from the 1.21.11
60/// client jar by `tools/maintenance/extract-shape-properties.py` (see
61/// `crates/delvec/data/PROVENANCE.md`). A `variants` property picks one
62/// complete model, so omitting it renders the author's default; a `multipart`
63/// property *assembles* the model, so omitting it drops geometry — wall arms,
64/// pane connections, vine faces. That is the class line `DW0735` fires on.
65const SHAPE_JSON: &str = include_str!("../data/blockstate-shape-props-1.21.11.json");
66
67/// The pinned **default state** of every block: the value the game resolves each
68/// unwritten property to.
69///
70/// A third table because it answers a third question. The registry says which
71/// properties are legal; the shape table says which of them the model is
72/// assembled from; this says what a palette entry that leaves one out actually
73/// MEANS. A structure template may leave properties out — vanilla fills them on
74/// load, so the file is legal and the server places the right block — and every
75/// reader that is not a running server then has to work it out. Guessing is not
76/// close: a bare `minecraft:cobblestone_wall` is a wall POST (`up=true`, every
77/// side `none`), and "the first legal value" yields `up=false` with `east=low`,
78/// which is a different block.
79const DEFAULTS_JSON: &str = include_str!("../data/block-defaults-1.21.11.json");
80
81/// The block-id **renames** the game's DataFixerUpper applies on load: an id the
82/// pin does not have -> the id it becomes, with the greatest `DataVersion` at
83/// which the old id still existed.
84///
85/// A fourth table because there is a fourth question, and the repo was answering
86/// it twice with two different answers. The registry says *whether the pin has
87/// this id*; it cannot say *what the pin will hold instead* — and a check that
88/// judges a pre-pin template's id AS WRITTEN is judging a name in the wrong
89/// vocabulary. `delvec prefab audit` did exactly that: the spelling rule passed
90/// `minecraft:chain` in a DataVersion-2975 template because the fixer migrates
91/// it, and the palette allowlist refused the same cell in the next breath
92/// because `minecraft:chain` is not a name at the pin.
93///
94/// Derived from Mojang's own published data by
95/// `tools/maintenance/extract-block-renames.py` (see `crates/delvec/data/PROVENANCE.md`):
96/// which ids left the registry and when, from the per-version block registries;
97/// what each became, from the crafting recipe whose ingredient side is
98/// unchanged across the version step. A removal the recipe graph cannot pair is
99/// deliberately ABSENT rather than guessed, and absence fails closed — the
100/// audit still refuses the id, so an incomplete table costs a false red and
101/// never a false pass.
102const RENAMES_JSON: &str = include_str!("../data/block-renames-1.21.11.json");
103
104/// The Minecraft version this registry describes (ADR-0009).
105pub const MC_VERSION: &str = "1.21.11";
106
107/// The pinned `DataVersion` (ADR-0009), duplicated from `convert::DATA_VERSION`
108/// deliberately — a drift between the two is a compile-time-checkable bug, and
109/// `judge_at`'s tests pin them equal.
110pub const PIN_DATA_VERSION: i32 = 4671;
111
112// ---------------------------------------------------------------------------
113// The blockstate diagnostic family (one model, five rules). Codes are defined
114// here — the crate every emitter and auditor of a block state already depends
115// on — so the next consumer reuses the rule instead of rewriting the unchecked
116// version. A rule that lives, correct, inside ONE caller leaves the next one
117// nothing to reuse (CLAUDE.md).
118// Documented in docs/reference/compiler.md §diagnostics.
119// ---------------------------------------------------------------------------
120
121crate::dw_code! {
122    /// A pre-pin structure template carries a block state the pin does not know:
123    /// the game's DataFixerUpper is expected to migrate it on load (warning).
124    pub const DW_STATE_PRE_PIN: &str = "DW0734";
125}
126crate::dw_code! {
127    /// A block state omits a shape-carrying (multipart) property (error).
128    pub const DW_SHAPE_OMITTED: &str = "DW0735";
129}
130crate::dw_code! {
131    /// A grammar fill wrote an orientation-sensitive block state into a reoriented
132    /// scope with no `orientation` guard pinning it (error).
133    pub const DW_ORIENTED_FILL_UNGUARDED: &str = "DW0736";
134}
135crate::dw_code! {
136    /// An authored block state omits a property the block has, so its geometry is
137    /// whatever a 1.21.11 server derives and no other reader can know it (error).
138    pub const DW_STATE_UNDER_SPECIFIED: &str = "DW0737";
139}
140crate::dw_code! {
141    /// A block state written in a scope's own axis names carries a property the
142    /// pinned vocabulary cannot map onto the world frame the scope was given
143    /// (error).
144    pub const DW_LOCAL_FRAME_UNRESOLVABLE: &str = "DW0738";
145}
146crate::dw_code! {
147    /// A world-frame fill of a frame-sensitive block state stood under a
148    /// reorientation that this region resolved to the identity, so `DW0736` had no
149    /// frame to judge it against. **Undecided**, neither pass nor fail.
150    pub const DW_ORIENTED_FILL_UNDECIDED: &str = "DW0742";
151}
152
153/// The verdict on one block state, judged against the pin **and** the
154/// `DataVersion` of the file that carries it.
155///
156/// Minecraft datafixes every structure `.nbt` it loads, against the
157/// `DataVersion` the file declares — so "this id does not exist at the pin" is
158/// only a defect when no datafix will run. A file declaring the pinned
159/// `DataVersion` (or later) gets no fixes at all: its unknown block really does
160/// load as AIR, which is how `tk-bell-tower.nbt` shipped a bell tower with no
161/// bell ropes. A file declaring an older `DataVersion` is DataFixerUpper's
162/// business: `prefabs/hero-temple-ruin-arch.nbt` (DataVersion 2975) carries
163/// `minecraft:chain`, which schema 4541 renames `iron_chain`, and loads
164/// correctly — refusing it is a false positive, not rigor.
165///
166/// The rule is deliberately conservative in the one direction that stays
167/// sound: an invalid id in a file whose `DataVersion` sits *between* the
168/// responsible fixer's schema and the pin would also load as air, but the
169/// fixer schedule lives inside the proprietary jar and nothing in this repo
170/// can read it — so pre-pin invalidity is a **warning** (`DW0734`), loud
171/// enough to catch a typo that no fixer will ever map, and never a refusal of
172/// a file the game loads fine.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub enum StateJudgement {
175    /// The pin has this exact state.
176    Valid,
177    /// Not a pinned state, and the file claims the pin (or later): no datafix
178    /// will run, so the block loads as air. An error.
179    InvalidAtPin(BlockError),
180    /// Not a pinned state, but the file pre-dates the pin: load-time
181    /// datafixing is expected to migrate it. A warning (`DW_STATE_PRE_PIN`).
182    PrePin(BlockError),
183}
184
185/// Why a block state is not a 1.21.11 block state.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub enum BlockError {
188    /// No block with this id exists.
189    UnknownBlock {
190        /// The id as written.
191        name: String,
192        /// The registry ids closest to it, best first — usually the rename.
193        suggestions: Vec<String>,
194    },
195    /// The block exists but has no such property.
196    UnknownProperty {
197        /// The block id.
198        name: String,
199        /// The property as written.
200        property: String,
201        /// Every property the block does have.
202        known: Vec<String>,
203    },
204    /// The property exists but not with this value.
205    BadValue {
206        /// The block id.
207        name: String,
208        /// The property.
209        property: String,
210        /// The value as written.
211        value: String,
212        /// Every legal value.
213        legal: Vec<String>,
214    },
215}
216
217impl fmt::Display for BlockError {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        match self {
220            BlockError::UnknownBlock { name, suggestions } => {
221                write!(f, "{name} is not a block in Minecraft {MC_VERSION}")?;
222                if !suggestions.is_empty() {
223                    write!(f, " — did you mean {}?", suggestions.join(", "))?;
224                }
225                write!(
226                    f,
227                    " (a structure template loads an unknown block as AIR, so this would ship a \
228                     hole rather than fail)"
229                )
230            }
231            BlockError::UnknownProperty {
232                name,
233                property,
234                known,
235            } => write!(
236                f,
237                "{name} has no property {property:?} in Minecraft {MC_VERSION}; it has {}",
238                if known.is_empty() {
239                    "none".to_string()
240                } else {
241                    known.join(", ")
242                }
243            ),
244            BlockError::BadValue {
245                name,
246                property,
247                value,
248                legal,
249            } => write!(
250                f,
251                "{name}[{property}={value}] is not legal in Minecraft {MC_VERSION}; {property} is \
252                 one of {}",
253                legal.join(", ")
254            ),
255        }
256    }
257}
258
259impl std::error::Error for BlockError {}
260
261/// One entry of the vendored rename table.
262#[derive(Debug, Clone, serde::Deserialize)]
263pub struct BlockRename {
264    /// The id the pinned version holds instead.
265    pub to: String,
266    /// The greatest `DataVersion` at which the OLD id still existed — a lower
267    /// bound on the schema that performs the rename, because the fix landed
268    /// somewhere inside the development cycle that follows and the fixer
269    /// schedule lives in the game jar. A file at or below this certainly
270    /// pre-dates the fix; above it, this table says nothing.
271    pub valid_through: i32,
272}
273
274/// Where a written block id ends up once the game has datafixed a file that
275/// declares `data_version` — the question an allowlist, a palette screen or a
276/// render surface has to ask before it judges a NAME.
277#[derive(Debug, Clone, PartialEq, Eq)]
278pub enum LoadedId<'a> {
279    /// The pin holds this id as written. Also the answer for a non-`minecraft:`
280    /// namespace, which is a datapack's own block and none of this registry's
281    /// business.
282    AsWritten,
283    /// The pin does not have the id, and a derived rename reaches it from this
284    /// file's `DataVersion`: the game loads `to`.
285    Renamed {
286        /// The id the pin will hold.
287        to: &'a str,
288        /// The bound the resolution turned on.
289        valid_through: i32,
290    },
291    /// The pin does not have the id and no rename in the vendored table reaches
292    /// it from this `DataVersion`. **Not** a claim that the game loads nothing —
293    /// a claim that this repo cannot say what it loads, which is why every
294    /// caller treats it as the id as written and refuses rather than passes.
295    Unresolved,
296}
297
298/// Every block id in the pinned version, with every property's legal values —
299/// plus, per block, which of those properties are shape-carrying.
300pub struct BlockRegistry {
301    blocks: BTreeMap<String, BTreeMap<String, Vec<String>>>,
302    shape: BTreeMap<String, Vec<String>>,
303    defaults: BTreeMap<String, BTreeMap<String, String>>,
304    renames: BTreeMap<String, BlockRename>,
305}
306
307impl BlockRegistry {
308    /// The pinned 1.21.11 registry, parsed once per process.
309    pub fn v1_21_11() -> &'static BlockRegistry {
310        static REGISTRY: OnceLock<BlockRegistry> = OnceLock::new();
311        REGISTRY.get_or_init(|| BlockRegistry {
312            blocks: serde_json::from_str(REGISTRY_JSON)
313                .expect("the vendored block registry is valid JSON"),
314            shape: serde_json::from_str(SHAPE_JSON)
315                .expect("the vendored shape-property table is valid JSON"),
316            defaults: serde_json::from_str(DEFAULTS_JSON)
317                .expect("the vendored block default-state table is valid JSON"),
318            renames: serde_json::from_str(RENAMES_JSON)
319                .expect("the vendored block rename table is valid JSON"),
320        })
321    }
322
323    /// The derived rename table, keyed by the id that no longer exists.
324    pub fn renames(&self) -> &BTreeMap<String, BlockRename> {
325        &self.renames
326    }
327
328    /// **Which id the game will actually load**, for a template that declares
329    /// `data_version`.
330    ///
331    /// Minecraft datafixes every structure `.nbt` it loads against the file's
332    /// own `DataVersion`, so a pre-pin template's id is written in an older
333    /// vocabulary and nothing may judge it as a name at the pin without first
334    /// resolving it. This is that resolution, and it is deliberately
335    /// conservative: a rename applies only where the file is at or below the
336    /// last `DataVersion` the old id existed at, so a file in the gap between
337    /// that bound and the pin resolves to [`LoadedId::Unresolved`] and is
338    /// refused rather than waved through.
339    pub fn loaded_id_at(&self, name: &str, data_version: i32) -> LoadedId<'_> {
340        let namespaced = namespace(name).into_owned();
341        if !namespaced.starts_with("minecraft:") || self.blocks.contains_key(&namespaced) {
342            return LoadedId::AsWritten;
343        }
344        match self.renames.get(&namespaced) {
345            Some(r) if data_version <= r.valid_through => LoadedId::Renamed {
346                to: &r.to,
347                valid_through: r.valid_through,
348            },
349            _ => LoadedId::Unresolved,
350        }
351    }
352
353    /// How many blocks the pinned version has. A binding count: a check that
354    /// reports zero here examined nothing.
355    pub fn len(&self) -> usize {
356        self.blocks.len()
357    }
358
359    /// Every block id in the pinned version, namespaced, in sorted order.
360    pub fn ids(&self) -> impl Iterator<Item = &str> {
361        self.blocks.keys().map(String::as_str)
362    }
363
364    /// Never true for the pinned registry; present because `len` is public.
365    pub fn is_empty(&self) -> bool {
366        self.blocks.is_empty()
367    }
368
369    /// True if `name` (namespaced, e.g. `minecraft:stone`) is a block.
370    pub fn has(&self, name: &str) -> bool {
371        self.blocks.contains_key(name)
372    }
373
374    /// Check an id plus its properties.
375    ///
376    /// A non-`minecraft:` namespace is accepted without inspection: a datapack
377    /// may legitimately define its own blocks, and this registry has nothing to
378    /// say about them. A bare id is read as `minecraft:`-namespaced, which is
379    /// how every emitter in this repo writes one.
380    pub fn validate(
381        &self,
382        name: &str,
383        properties: &BTreeMap<String, String>,
384    ) -> Result<(), BlockError> {
385        let namespaced = namespace(name).into_owned();
386        if !namespaced.starts_with("minecraft:") {
387            return Ok(());
388        }
389        let Some(known) = self.blocks.get(&namespaced) else {
390            return Err(BlockError::UnknownBlock {
391                suggestions: self.suggest(&namespaced),
392                name: namespaced,
393            });
394        };
395        for (property, value) in properties {
396            let Some(legal) = known.get(property) else {
397                return Err(BlockError::UnknownProperty {
398                    name: namespaced,
399                    property: property.clone(),
400                    known: known.keys().cloned().collect(),
401                });
402            };
403            if !legal.contains(value) {
404                return Err(BlockError::BadValue {
405                    name: namespaced,
406                    property: property.clone(),
407                    value: value.clone(),
408                    legal: legal.clone(),
409                });
410            }
411        }
412        Ok(())
413    }
414
415    /// Check a `name[k=v,...]` state string.
416    pub fn validate_state_string(&self, state: &str) -> Result<(), BlockError> {
417        let (name, properties) = parse_state(state);
418        self.validate(name, &properties)
419    }
420
421    /// Judge a state against the pin **and** the carrying file's `DataVersion`.
422    /// See [`StateJudgement`] for the rule and its derivation.
423    pub fn judge_at(
424        &self,
425        name: &str,
426        properties: &BTreeMap<String, String>,
427        data_version: i32,
428    ) -> StateJudgement {
429        match self.validate(name, properties) {
430            Ok(()) => StateJudgement::Valid,
431            Err(e) if data_version >= PIN_DATA_VERSION => StateJudgement::InvalidAtPin(e),
432            Err(e) => StateJudgement::PrePin(e),
433        }
434    }
435
436    /// Every property of `name` with its legal values, or `None` for an id the
437    /// pinned version does not have.
438    pub fn properties(&self, name: &str) -> Option<&BTreeMap<String, Vec<String>>> {
439        self.blocks.get(namespace(name).as_ref())
440    }
441
442    /// The block's default state — what the game resolves each unwritten
443    /// property to. `None` for an id the pinned version does not have; an empty
444    /// map for a block that has no properties at all.
445    ///
446    /// **This table says what a reader must ASSUME, never what an emitter may
447    /// WRITE.** A reader that is not a running server — the review page, a
448    /// diff, a walk — has to fill an omitted property from somewhere, and this
449    /// is where. An emitter filling one from here is a different act: it turns
450    /// a state that said nothing into a state that explicitly asserts the
451    /// default, and for every connection property the default is
452    /// *disconnected*. So a completion pass over a library would answer
453    /// [`Self::omitted_shape_carrying`] with the empty set for every block,
454    /// forever — the shape rule (`DW0735`) and its whole library sweep would go
455    /// green by ceasing to bind, over a library whose walls are still isolated
456    /// posts. What an emitter owes instead is the connection derived from the
457    /// blocks beside the cell (`prefabs/invariants/src/connections.rs`), and
458    /// `tools/ci/check-structure-emitters.py` is what holds every emitter to it.
459    pub fn default_state(&self, name: &str) -> Option<&BTreeMap<String, String>> {
460        self.defaults.get(namespace(name).as_ref())
461    }
462
463    /// The properties `written` leaves out, each with the value the game would
464    /// fill it with. Empty when the state is complete — and empty, too, for an
465    /// id this registry does not know, which has no defaults to offer and is
466    /// already an [`BlockError::UnknownBlock`] to [`Self::validate`].
467    ///
468    /// Broader than [`Self::omitted_shape_carrying`] on purpose, and the two
469    /// answer different questions: that one asks whether the block's *model* is
470    /// assembled from parts the property selects, this one asks what a reader
471    /// that is not a running server would have to fill in to know what the file
472    /// means at all. Being broader is exactly why it is not the repair for the
473    /// narrower one — see [`Self::default_state`], whose table this reads.
474    pub fn unwritten(
475        &self,
476        name: &str,
477        written: &BTreeMap<String, String>,
478    ) -> BTreeMap<String, String> {
479        let Some(default) = self.defaults.get(namespace(name).as_ref()) else {
480            return BTreeMap::new();
481        };
482        default
483            .iter()
484            .filter(|(k, _)| !written.contains_key(*k))
485            .map(|(k, v)| (k.clone(), v.clone()))
486            .collect()
487    }
488
489    /// The shape-carrying properties of `name` — the properties its blockstate
490    /// definition's `multipart` selectors test. Empty for a block whose model
491    /// is not assembled from parts, for a foreign namespace, and for an unknown
492    /// id (the unknown-block diagnostic owns that case).
493    pub fn shape_carrying(&self, name: &str) -> &[String] {
494        let namespaced = namespace(name);
495        self.shape
496            .get(namespaced.as_ref())
497            .map(Vec::as_slice)
498            .unwrap_or(&[])
499    }
500
501    /// The legal values of one property of one block, empty when either is
502    /// unknown or the namespace is foreign.
503    pub fn values(&self, name: &str, property: &str) -> &[String] {
504        let namespaced = namespace(name);
505        self.blocks
506            .get(namespaced.as_ref())
507            .and_then(|props| props.get(property))
508            .map(Vec::as_slice)
509            .unwrap_or(&[])
510    }
511
512    /// **True when `name` is a stair block**, derived from the pinned registry
513    /// rather than from a list: a stair is the block whose `shape` property
514    /// takes vanilla's five stair values, and nothing else in the game has one.
515    ///
516    /// The derivation matters because the property it feeds
517    /// (`delvec::schem::stairs::derive_shape`) tests *any* stair against *any* other
518    /// — an oak stair mitres against a stone-brick one — so a hand-kept list
519    /// would be wrong the day a version adds a stair, in the silent direction.
520    pub fn is_stairs(&self, name: &str) -> bool {
521        let values = self.values(name, "shape");
522        values.len() == 5
523            && [
524                "straight",
525                "inner_left",
526                "inner_right",
527                "outer_left",
528                "outer_right",
529            ]
530            .iter()
531            .all(|v| values.iter().any(|has| has == v))
532    }
533
534    /// **Every** property of `name` the state omits, sorted — the `DW0737`
535    /// predicate, and a superset of [`Self::omitted_shape_carrying`].
536    ///
537    /// Vanilla's `BlockState` codec fills an omitted property from the block's
538    /// default state, so a partial state is a legal thing to write and the game
539    /// resolves it correctly. Nothing else can: a renderer, a review image, a
540    /// navigation walk or a diff has to guess, and the guesses disagree with
541    /// each other and with the server. The shape half of that (`DW0735`) drops
542    /// geometry outright and is the harder error; this is the whole class, and
543    /// it is the rule an AUTHORED program is held to — a state whose meaning
544    /// only a running server knows cannot be reviewed before it runs.
545    ///
546    /// Empty for a propertyless block, for a foreign namespace and for an id
547    /// the pin does not know (the unknown-block diagnostics own that case).
548    pub fn omitted_properties(
549        &self,
550        name: &str,
551        properties: &BTreeMap<String, String>,
552    ) -> Vec<String> {
553        let namespaced = namespace(name);
554        let Some(known) = self.blocks.get(namespaced.as_ref()) else {
555            return Vec::new();
556        };
557        known
558            .keys()
559            .filter(|p| !properties.contains_key(*p))
560            .cloned()
561            .collect()
562    }
563
564    /// The shape-carrying properties a state omits, sorted. Empty when the
565    /// state is complete, when the block has none, and when the id is foreign
566    /// or unknown at the pin.
567    pub fn omitted_shape_carrying(
568        &self,
569        name: &str,
570        properties: &BTreeMap<String, String>,
571    ) -> Vec<String> {
572        self.shape_carrying(name)
573            .iter()
574            .filter(|p| !properties.contains_key(*p))
575            .cloned()
576            .collect()
577    }
578
579    /// The first property of a state that lands wrong when the state is written
580    /// under the frame `local_to_world` / `reflected` without being rewritten —
581    /// the `DW0736` predicate.
582    ///
583    /// A frame has two halves and the check needs both. `local_to_world[i]` is
584    /// the world axis index (0 = X, 1 = Y, 2 = Z) that a scope's local axis `i`
585    /// names; `reflected[i]` says local axis `i` runs *backwards* along it. A
586    /// grammar frame permutes and reflects the *geometry* a rule describes and
587    /// never touches block-state properties
588    /// (`crates/delvec/src/grammar/orient.rs`), so a literal `facing`/`axis`/
589    /// connection property is correct only if the frame fixes the direction it
590    /// names. The check transforms the state through the frame — mapping
591    /// direction-valued properties, axis-valued properties, direction-*named*
592    /// connection flags and two-direction `orientation` values, all derived
593    /// from the registry's own value vocabulary — and reports the first
594    /// property whose transform differs from its literal, in key order
595    /// (deterministic, ADR-0006).
596    ///
597    /// A reflection is a *sign* on the axis, so it is exactly what the existing
598    /// `(axis, sign)` vocabulary already speaks: local `north` under a
599    /// reflected local Z is world `south`. An axis-valued property carries no
600    /// sign and is therefore untouched by a reflection — `axis=x` means the
601    /// same pillar either way. `rotation` (the 16-step yaw of signs, skulls and
602    /// banners), `hinge` and a non-`straight` stair `shape` are facing-relative
603    /// or sub-cardinal and cannot be transformed by axis vocabulary; they are
604    /// the minimal, documented residue and count as mismatched whenever the
605    /// frame moves **or reflects** a horizontal axis. A reflection is the case
606    /// that matters most for `hinge` and a corner `shape`: those are chiral,
607    /// and a mirror is what a rotation cannot reproduce.
608    ///
609    /// `None` for the identity frame (nothing moves and nothing reflects), for
610    /// a foreign namespace, and for an id or property the pin does not know
611    /// (the unknown-state diagnostics own those).
612    pub fn oriented_mismatch(
613        &self,
614        name: &str,
615        properties: &BTreeMap<String, String>,
616        local_to_world: [usize; 3],
617        reflected: [bool; 3],
618    ) -> Option<String> {
619        if local_to_world == [0, 1, 2] && reflected == [false; 3] {
620            return None;
621        }
622        let namespaced = namespace(name);
623        let known = self.blocks.get(namespaced.as_ref())?;
624
625        for (k, v) in properties {
626            if known.get(k).is_none() {
627                continue; // unknown property: `validate` owns it
628            }
629            match property_image(known, k, v, local_to_world, reflected) {
630                // The frame provably leaves this property alone.
631                PropertyImage::Fixed => {}
632                // It moves. A moved KEY is still satisfied when the state
633                // already gives the destination key the same value (a
634                // symmetric run of bars): the frame maps the state onto
635                // itself.
636                PropertyImage::Moved { key, value } => {
637                    if key != *k {
638                        if properties.get(&key) != Some(v) {
639                            return Some(format!("{k}={v}"));
640                        }
641                    } else if value != *v {
642                        return Some(format!("{k}={v}"));
643                    }
644                }
645                PropertyImage::Undetermined => return Some(format!("{k}={v}")),
646            }
647        }
648        None
649    }
650
651    /// **The same question asked of a set of frames instead of one**: which
652    /// properties of this state ANY of `frames` would land wrong.
653    ///
654    /// [`Self::oriented_mismatch`] answers "does this state survive THIS
655    /// frame", and its first act is to return `None` for the identity. That is
656    /// correct, and it is also why a `None` from it is two different facts
657    /// wearing one answer: *judged against a frame and found sound*, or *never
658    /// judged at all*. Telling those apart is not a question about the state
659    /// alone either — it is a question about which frames the scope could have
660    /// stood in, which is why the caller supplies them
661    /// (`delvec::grammar::orient::FrameSet`).
662    ///
663    /// The answer is the union of what `oriented_mismatch` reports over
664    /// `frames`, so the two can never disagree about what frame-sensitivity
665    /// means. That matters more than the cost of asking 48 times: a hand-kept
666    /// list of sensitive property NAMES would call a symmetric run of bars
667    /// sensitive (its `east` moves to `south`, and the state already gives
668    /// `south` the same value, so no frame lands it wrong), would call an
669    /// `axis=y` pillar sensitive under a request that pins the vertical, and
670    /// would go stale the moment the pin adds a block. Asking the judge is what
671    /// keeps the two definitions one definition.
672    ///
673    /// Each frame is `(local_to_world, reflected)`, exactly as
674    /// `oriented_mismatch` reads them. Sorted `key=value`, deterministic
675    /// (ADR-0006). Empty for a state none of these frames disturbs — a plain
676    /// `minecraft:stone`, a `waterlogged` slab, a symmetric pane — for a foreign
677    /// namespace and for an id the pin does not know.
678    pub fn frame_sensitive(
679        &self,
680        name: &str,
681        properties: &BTreeMap<String, String>,
682        frames: impl IntoIterator<Item = ([usize; 3], [bool; 3])>,
683    ) -> Vec<String> {
684        let mut found: std::collections::BTreeSet<String> = Default::default();
685        for (perm, reflected) in frames {
686            if let Some(hit) = self.oriented_mismatch(name, properties, perm, reflected) {
687                found.insert(hit);
688            }
689        }
690        found.into_iter().collect()
691    }
692
693    /// **The same transform, applied instead of judged**: the image of
694    /// `properties` when the state was written in a scope's own axis names and
695    /// the scope's frame is `local_to_world` / `reflected`.
696    ///
697    /// [`Self::oriented_mismatch`] asks whether a state written for the world
698    /// frame survives this one; it computes the intended state to answer, and
699    /// throws it away. This returns it. Both go through one classifier, so a
700    /// property either has an image both of them agree on or has none, and
701    /// there is no state the judge calls wrong that the resolver quietly writes
702    /// anyway. That is the whole reason the classifier is a single function:
703    /// a judge and a rewriter derived from two tables would disagree exactly
704    /// where it matters, and the disagreement would be invisible.
705    ///
706    /// `Err` names the first property (as `key=value`, in key order) whose
707    /// image the pinned vocabulary does not determine — a yaw or a chirality
708    /// under any frame but a pure turn about the vertical, a `top`/`bottom`
709    /// half under a frame that moves or reverses the vertical, a direction
710    /// whose image is not a legal value of the block, a rail's
711    /// direction-composed shape. **A refusal, never a best guess**: a
712    /// local-frame state that cannot be resolved has no correct block to write.
713    ///
714    /// Unchanged for the identity frame, for a foreign namespace and for an id
715    /// the pin does not know (the unknown-block diagnostics own that).
716    pub fn permuted_properties(
717        &self,
718        name: &str,
719        properties: &BTreeMap<String, String>,
720        local_to_world: [usize; 3],
721        reflected: [bool; 3],
722    ) -> Result<BTreeMap<String, String>, String> {
723        if local_to_world == [0, 1, 2] && reflected == [false; 3] {
724            return Ok(properties.clone());
725        }
726        let namespaced = namespace(name);
727        let Some(known) = self.blocks.get(namespaced.as_ref()) else {
728            return Ok(properties.clone());
729        };
730        let mut out = BTreeMap::new();
731        for (k, v) in properties {
732            if known.get(k).is_none() {
733                out.insert(k.clone(), v.clone()); // `validate` owns it
734                continue;
735            }
736            match property_image(known, k, v, local_to_world, reflected) {
737                PropertyImage::Fixed => {
738                    out.insert(k.clone(), v.clone());
739                }
740                PropertyImage::Moved { key, value } => {
741                    out.insert(key, value);
742                }
743                PropertyImage::Undetermined => return Err(format!("{k}={v}")),
744            }
745        }
746        Ok(out)
747    }
748
749    /// Registry ids most likely to be what an unknown id meant.
750    ///
751    /// The rename that motivated this module (`chain` → `iron_chain`) is a
752    /// **qualification**, not an edit-distance neighbour: the new id is the old
753    /// one with a material word put in front of it. So candidates are ranked by
754    /// how a rename actually looks — the unknown id as a whole-word *suffix*
755    /// first (`iron_chain`), then as a prefix, then anywhere inside — and within
756    /// a rank by how few words were added, then alphabetically so the answer is
757    /// deterministic (ADR-0006). A wrong suggestion costs nothing; the error is
758    /// already fatal.
759    fn suggest(&self, name: &str) -> Vec<String> {
760        let path = name.split_once(':').map(|(_, p)| p).unwrap_or(name);
761        let words: Vec<&str> = path.split('_').collect();
762
763        let mut ranked: Vec<(u8, usize, &String)> = Vec::new();
764        for id in self.blocks.keys() {
765            let candidate = id.split_once(':').map(|(_, p)| p).unwrap_or(id);
766            if candidate == path {
767                continue;
768            }
769            let candidate_words: Vec<&str> = candidate.split('_').collect();
770            if candidate_words.len() <= words.len() {
771                continue;
772            }
773            let extra = candidate_words.len() - words.len();
774            let rank = if candidate_words.ends_with(words.as_slice()) {
775                0
776            } else if candidate_words.starts_with(words.as_slice()) {
777                1
778            } else if candidate_words
779                .windows(words.len())
780                .any(|w| w == words.as_slice())
781            {
782                2
783            } else {
784                continue;
785            };
786            ranked.push((rank, extra, id));
787        }
788        ranked.sort();
789        ranked
790            .into_iter()
791            .take(3)
792            .map(|(_, _, id)| id.clone())
793            .collect()
794    }
795}
796
797/// What a frame does to one block-state property.
798///
799/// The one classifier behind both [`BlockRegistry::oriented_mismatch`] and
800/// [`BlockRegistry::permuted_properties`]. Keeping it single is the point: a
801/// judge and a rewriter derived from different tables would disagree exactly
802/// where it matters, and the disagreement would be invisible — the judge would
803/// pass a state the rewriter mangled, or refuse one it wrote correctly.
804enum PropertyImage {
805    /// The frame provably leaves this property where it is.
806    Fixed,
807    /// It becomes this key and this value.
808    Moved {
809        /// The destination key.
810        key: String,
811        /// The destination value.
812        value: String,
813    },
814    /// The pinned vocabulary does not determine an image.
815    Undetermined,
816}
817
818/// The image of `key=value` on `known` under the frame `local_to_world` /
819/// `reflected`, which is never the identity here (both callers short-circuit
820/// it).
821///
822/// A frame has two halves. `local_to_world[i]` is the world axis a scope's
823/// local axis `i` names; `reflected[i]` says local axis `i` runs *backwards*
824/// along it. A reflection is a **sign**, which is what the `(axis, sign)`
825/// direction vocabulary already speaks, so the first four classes below carry
826/// it exactly: local `north` under a reflected local Z is world `south`. An
827/// axis carries no sign, so the reflection half cannot disturb it.
828///
829/// The classes are tried in order and each is decided from the block's **own**
830/// legal-value vocabulary rather than from a list of block ids, so a block the
831/// pin adds is classified without touching this function.
832///
833/// The last three classes are *frame-relative*: a 16-step yaw, a chirality
834/// (`left`/`right`) and a vertical position (`top`/`bottom`, `upper`/`lower`)
835/// are stated against a fixed up-axis and, for the first two, a fixed
836/// handedness. A yaw and a chirality therefore have an image only under a
837/// **pure turn about the vertical** — a frame that reflects nothing and keeps
838/// the local `Y` on the world `Y`, which is the identity (short-circuited by
839/// both callers) or the horizontal transposition `x↔z`. That transposition is
840/// itself a reflection of the horizontal plane: a yaw θ becomes 270° − θ, and
841/// left becomes right. Reflect any axis and the frame leaves that vocabulary,
842/// which is exactly the residue `DW0736` counts as mismatched whenever the
843/// frame moves *or reflects* a horizontal axis — so the answer is
844/// [`PropertyImage::Undetermined`], a refusal in the resolver and a mismatch in
845/// the judge, rather than a guess that would make the two disagree.
846///
847/// A vertical position survives any purely horizontal frame untouched, and has
848/// no image at all once the vertical moves or runs backwards: `half=top` cannot
849/// mean "the top half" of a horizontal axis, and a frame whose local `Y` counts
850/// down the world's has no `top` to name.
851fn property_image(
852    known: &BTreeMap<String, Vec<String>>,
853    key: &str,
854    value: &str,
855    local_to_world: [usize; 3],
856    reflected: [bool; 3],
857) -> PropertyImage {
858    let legal = match known.get(key) {
859        Some(l) => l,
860        None => return PropertyImage::Fixed,
861    };
862    // The frame's image of one local direction: the world axis the local one
863    // names, with the sign flipped when that axis runs backwards.
864    let image = |axis: usize, sign: i8| {
865        let sign = if reflected[axis] { -sign } else { sign };
866        axis_sign_direction(local_to_world[axis], sign)
867    };
868    // A direction-*named* property: the connection flags of fences, walls,
869    // panes and vines. The frame moves the KEY.
870    if let Some((axis, sign)) = direction_axis_sign(key) {
871        let moved = image(axis, sign);
872        if moved == key {
873            return PropertyImage::Fixed;
874        }
875        // A pane has no `up`/`down` flag: turning a horizontal connection onto
876        // the vertical has nowhere to land.
877        return match known.get(moved) {
878            Some(l) if l.iter().any(|v| v == value) => PropertyImage::Moved {
879                key: moved.to_string(),
880                value: value.to_string(),
881            },
882            _ => PropertyImage::Undetermined,
883        };
884    }
885    // A direction-valued property (`facing`, `vertical_direction`, …).
886    if !legal.is_empty() && legal.iter().all(|l| direction_axis_sign(l).is_some()) {
887        let Some((axis, sign)) = direction_axis_sign(value) else {
888            return PropertyImage::Undetermined;
889        };
890        return image_of_value(key, value, image(axis, sign), legal);
891    }
892    // An axis-valued property (`axis` of logs, pillars, chains). An axis has no
893    // sign, so only the permutation half can disturb it.
894    if !legal.is_empty() && legal.iter().all(|l| axis_index(l).is_some()) {
895        let Some(axis) = axis_index(value) else {
896            return PropertyImage::Undetermined;
897        };
898        let moved = ["x", "y", "z"][local_to_world[axis]];
899        return image_of_value(key, value, moved, legal);
900    }
901    // A two-direction value (`orientation` of jigsaws and crafters).
902    if !legal.is_empty() && legal.iter().all(|l| is_direction_pair(l)) {
903        let Some((a, b)) = value.split_once('_') else {
904            return PropertyImage::Undetermined;
905        };
906        let (Some((aa, asig)), Some((ba, bsig))) = (direction_axis_sign(a), direction_axis_sign(b))
907        else {
908            return PropertyImage::Undetermined;
909        };
910        let moved = format!("{}_{}", image(aa, asig), image(ba, bsig));
911        return image_of_value(key, value, &moved, legal);
912    }
913
914    // Everything below is stated against a fixed vertical; the first two are
915    // stated against a fixed handedness as well.
916    let turn_about_the_vertical = local_to_world[1] == 1 && reflected == [false; 3];
917    let vertical_kept = local_to_world[1] == 1 && !reflected[1];
918
919    // A 16-step yaw (signs, banners, skulls): `rotation` 0 is south and the
920    // segments run with the yaw, so a reflection sends r to (12 - r) mod 16.
921    if key == "rotation" && legal.iter().all(|l| l.parse::<u8>().is_ok()) {
922        let (true, Ok(r)) = (turn_about_the_vertical, value.parse::<u32>()) else {
923            return PropertyImage::Undetermined;
924        };
925        let moved = ((28 - r % 16) % 16).to_string();
926        return image_of_value(key, value, &moved, legal);
927    }
928    // A chirality: a door's `hinge`, a stair's `shape`, a double chest's
929    // `type`. Handedness is what a reflection swaps — but a value that names
930    // no handedness (`straight`, `single`) is its own image under EVERY frame,
931    // and that case is settled before the frame is consulted at all. Deciding
932    // it the other way round would refuse every straight stair in a mirrored
933    // body.
934    if legal
935        .iter()
936        .any(|l| l.split('_').any(|w| w == "left" || w == "right"))
937    {
938        let moved: String = value
939            .split('_')
940            .map(|w| match w {
941                "left" => "right",
942                "right" => "left",
943                other => other,
944            })
945            .collect::<Vec<_>>()
946            .join("_");
947        if moved == value {
948            return PropertyImage::Fixed;
949        }
950        if !turn_about_the_vertical {
951            return PropertyImage::Undetermined;
952        }
953        return image_of_value(key, value, &moved, legal);
954    }
955    // A vertical position: a slab's `type`, a stair's or a door's `half`. A
956    // `double` slab has no vertical half to lose, so it too is settled before
957    // the frame is consulted.
958    if !legal.is_empty()
959        && legal
960            .iter()
961            .all(|l| matches!(l.as_str(), "top" | "bottom" | "double" | "upper" | "lower"))
962    {
963        return if value == "double" || vertical_kept {
964            PropertyImage::Fixed
965        } else {
966            PropertyImage::Undetermined
967        };
968    }
969    // A value that spells a direction or an axis inside a compound word — a
970    // rail's `shape=ascending_north`, and anything the pin adds in that shape.
971    // The vocabulary says it carries a direction and does not say how to map
972    // it, which is exactly the case to refuse rather than to pass through.
973    if legal.iter().any(|l| {
974        l.split('_')
975            .any(|w| direction_axis_sign(w).is_some() || axis_index(w).is_some())
976    }) {
977        return PropertyImage::Undetermined;
978    }
979    PropertyImage::Fixed
980}
981
982/// A moved value, or `Fixed` when the frame sent it to itself, or
983/// `Undetermined` when the destination is not a legal value of the property.
984fn image_of_value(key: &str, value: &str, moved: &str, legal: &[String]) -> PropertyImage {
985    if moved == value {
986        PropertyImage::Fixed
987    } else if legal.iter().any(|l| l == moved) {
988        PropertyImage::Moved {
989            key: key.to_string(),
990            value: moved.to_string(),
991        }
992    } else {
993        PropertyImage::Undetermined
994    }
995}
996
997/// `name` as a namespaced id: a bare id is read as `minecraft:`-namespaced,
998/// which is how every emitter in this repo writes one.
999fn namespace(name: &str) -> std::borrow::Cow<'_, str> {
1000    if name.contains(':') {
1001        std::borrow::Cow::Borrowed(name)
1002    } else {
1003        std::borrow::Cow::Owned(format!("minecraft:{name}"))
1004    }
1005}
1006
1007/// A cardinal/vertical direction word as `(axis index, sign)`, with the vanilla
1008/// convention: north = −Z, south = +Z, west = −X, east = +X, down = −Y,
1009/// up = +Y.
1010fn direction_axis_sign(word: &str) -> Option<(usize, i8)> {
1011    match word {
1012        "west" => Some((0, -1)),
1013        "east" => Some((0, 1)),
1014        "down" => Some((1, -1)),
1015        "up" => Some((1, 1)),
1016        "north" => Some((2, -1)),
1017        "south" => Some((2, 1)),
1018        _ => None,
1019    }
1020}
1021
1022/// The inverse of [`direction_axis_sign`].
1023fn axis_sign_direction(axis: usize, sign: i8) -> &'static str {
1024    match (axis, sign) {
1025        (0, -1) => "west",
1026        (0, 1) => "east",
1027        (1, -1) => "down",
1028        (1, 1) => "up",
1029        (2, -1) => "north",
1030        (2, 1) => "south",
1031        _ => unreachable!("axis index is always 0..3 and sign ±1"),
1032    }
1033}
1034
1035/// An axis word (`x`/`y`/`z`) as its index.
1036fn axis_index(word: &str) -> Option<usize> {
1037    match word {
1038        "x" => Some(0),
1039        "y" => Some(1),
1040        "z" => Some(2),
1041        _ => None,
1042    }
1043}
1044
1045/// True for a `<direction>_<direction>` value (jigsaw/crafter `orientation`).
1046fn is_direction_pair(value: &str) -> bool {
1047    value
1048        .split_once('_')
1049        .is_some_and(|(a, b)| direction_axis_sign(a).is_some() && direction_axis_sign(b).is_some())
1050}
1051
1052/// Split `name[k=v,k=v]` into its id and its properties.
1053///
1054/// Tolerant on purpose: this is a *validator's* front door, so a malformed
1055/// state must reach the registry and be reported as an unknown block rather
1056/// than be rejected by a parser with a different vocabulary.
1057pub(crate) fn parse_state(state: &str) -> (&str, BTreeMap<String, String>) {
1058    let Some(open) = state.find('[') else {
1059        return (state.trim(), BTreeMap::new());
1060    };
1061    let name = state[..open].trim();
1062    let inner = state[open + 1..].trim_end().trim_end_matches(']');
1063    let mut properties = BTreeMap::new();
1064    for pair in inner.split(',') {
1065        if let Some((k, v)) = pair.split_once('=') {
1066            properties.insert(k.trim().to_string(), v.trim().to_string());
1067        }
1068    }
1069    (name, properties)
1070}
1071
1072// ---------------------------------------------------------------------------
1073// The sculk family at rest (spec-0100 §3.2, §3.3, §4.1)
1074// ---------------------------------------------------------------------------
1075
1076crate::dw_code! {
1077    /// `DW0998`: a sculk shrieker is declared with `can_summon=true` (spec-0100
1078    /// §3.2) — it would summon a warden no wave declares and apply Darkness.
1079    pub const DW_SHRIEKER_CAN_SUMMON: DwCode = DwCode::new("DW0998", ExitTier::Build);
1080}
1081crate::dw_code! {
1082    /// `DW0999`: a sculk block is not at rest (spec-0100 §3.3) — a property or a
1083    /// block-entity field holds a value the game would act on at load.
1084    pub const DW_SCULK_NOT_AT_REST: DwCode = DwCode::new("DW0999", ExitTier::Build);
1085}
1086
1087/// The four sculk blocks that act at runtime, namespaced. `sculk` and
1088/// `sculk_vein` are inert and never judged.
1089pub const ACTING_SCULK_1_21_11: [&str; 4] = [
1090    "minecraft:sculk_sensor",
1091    "minecraft:calibrated_sculk_sensor",
1092    "minecraft:sculk_shrieker",
1093    "minecraft:sculk_catalyst",
1094];
1095
1096/// The block-entity fields of a sculk block the rest rule reads (spec-0100
1097/// §2.2, §2.4, §2.5), extracted by whoever holds the NBT. A field the NBT does
1098/// not carry is its rest value.
1099#[derive(Debug, Clone, Default, PartialEq, Eq)]
1100pub struct SculkNbt {
1101    /// Whether `listener` carries a pending vibration (`event`).
1102    pub listener_event: bool,
1103    /// A sensor's `last_vibration_frequency`.
1104    pub last_vibration_frequency: i64,
1105    /// A shrieker's `warning_level`.
1106    pub warning_level: i64,
1107    /// The number of a catalyst's `cursors`.
1108    pub cursors: usize,
1109}
1110
1111/// Why a sculk block state is refused.
1112#[derive(Debug, Clone, PartialEq, Eq)]
1113pub enum SculkFault {
1114    /// `sculk_shrieker[can_summon=true]` (`DW0998`).
1115    CanSummon {
1116        /// The block state as written.
1117        state: String,
1118    },
1119    /// A consequential property or block-entity field is not its rest value
1120    /// (`DW0999`).
1121    NotAtRest {
1122        /// The block state as written.
1123        state: String,
1124        /// The property or NBT field (`nbt.listener.event` for the NBT).
1125        field: String,
1126        /// The value it holds (`<unknown>` when neither written nor defaulted).
1127        value: String,
1128        /// Its rest value.
1129        rest: String,
1130    },
1131}
1132
1133impl SculkFault {
1134    /// The code this fault is raised under.
1135    pub fn code(&self) -> DwCode {
1136        match self {
1137            SculkFault::CanSummon { .. } => DW_SHRIEKER_CAN_SUMMON,
1138            SculkFault::NotAtRest { .. } => DW_SCULK_NOT_AT_REST,
1139        }
1140    }
1141}
1142
1143impl fmt::Display for SculkFault {
1144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1145        match self {
1146            SculkFault::CanSummon { state } => write!(
1147                f,
1148                "`{state}` can summon: a shrieker with `can_summon=true` answers a player's \
1149                 vibration by summoning a warden no wave declares, placed by the game's random up \
1150                 to 6 cells away, with a warning level the game keeps per player across sessions, \
1151                 and applies Darkness to every player nearby — nothing a proof can seat, count or \
1152                 make deterministic. Write `can_summon=false` (or the bare id, whose default is \
1153                 false): the shrieker still shrieks, with sound and particles and nothing else"
1154            ),
1155            SculkFault::NotAtRest {
1156                state,
1157                field,
1158                value,
1159                rest,
1160            } => write!(
1161                f,
1162                "`{state}` is not at rest: `{field}` is `{value}`, and a sculk block enters the \
1163                 world at rest (`{field}` = `{rest}`) — a block saved mid-click replays the click on \
1164                 load, and a painted `bloom`/`shrieking`/`active` state is one the game never \
1165                 schedules back. Write the rest value, or the bare id, whose defaults are the rest \
1166                 state"
1167            ),
1168        }
1169    }
1170}
1171
1172/// **The one rest rule** (spec-0100 §4.1): judge a full sculk block state —
1173/// every property the state omits is read at the block's pinned default
1174/// ([`BlockRegistry::default_state`]) — and, given one, its block-entity NBT.
1175///
1176/// `Ok` for every block that is not one of [`ACTING_SCULK_1_21_11`] (`sculk`
1177/// and `sculk_vein` included). A consequential property neither written nor
1178/// defaulted is refused as `<unknown>`: the rule never assumes rest.
1179pub fn sculk_rest(state: &str, nbt: Option<&SculkNbt>) -> Result<(), SculkFault> {
1180    let (name, written) = parse_state(state);
1181    let id = namespace(name);
1182    let Some(kind) = ACTING_SCULK_1_21_11.iter().position(|a| *a == id.as_ref()) else {
1183        return Ok(());
1184    };
1185    let defaults = BlockRegistry::v1_21_11().default_state(&id);
1186    let value = |k: &str| -> Option<String> {
1187        written
1188            .get(k)
1189            .cloned()
1190            .or_else(|| defaults.and_then(|d| d.get(k)).cloned())
1191    };
1192    let not_at_rest = |field: &str, value: Option<String>, rest: &str| SculkFault::NotAtRest {
1193        state: state.to_string(),
1194        field: field.to_string(),
1195        value: value.unwrap_or_else(|| "<unknown>".to_string()),
1196        rest: rest.to_string(),
1197    };
1198    // (property, rest value) per block, in the order the message names them.
1199    let props: &[(&str, &str)] = match kind {
1200        0 | 1 => &[("sculk_sensor_phase", "inactive"), ("power", "0")],
1201        2 => &[("shrieking", "false")],
1202        _ => &[("bloom", "false")],
1203    };
1204    if kind == 2 {
1205        match value("can_summon").as_deref() {
1206            Some("false") => {}
1207            Some("true") => {
1208                return Err(SculkFault::CanSummon {
1209                    state: state.to_string(),
1210                });
1211            }
1212            other => {
1213                return Err(not_at_rest(
1214                    "can_summon",
1215                    other.map(str::to_string),
1216                    "false",
1217                ));
1218            }
1219        }
1220    }
1221    for (k, rest) in props {
1222        let v = value(k);
1223        if v.as_deref() != Some(*rest) {
1224            return Err(not_at_rest(k, v, rest));
1225        }
1226    }
1227    if let Some(n) = nbt {
1228        if n.listener_event {
1229            return Err(not_at_rest(
1230                "nbt.listener.event",
1231                Some("a pending vibration".to_string()),
1232                "none",
1233            ));
1234        }
1235        if n.last_vibration_frequency != 0 {
1236            return Err(not_at_rest(
1237                "nbt.last_vibration_frequency",
1238                Some(n.last_vibration_frequency.to_string()),
1239                "0",
1240            ));
1241        }
1242        if n.warning_level != 0 {
1243            return Err(not_at_rest(
1244                "nbt.warning_level",
1245                Some(n.warning_level.to_string()),
1246                "0",
1247            ));
1248        }
1249        if n.cursors != 0 {
1250            return Err(not_at_rest(
1251                "nbt.cursors",
1252                Some(format!("{} cursor(s)", n.cursors)),
1253                "[]",
1254            ));
1255        }
1256    }
1257    Ok(())
1258}
1259
1260#[cfg(test)]
1261mod tests {
1262    use super::*;
1263
1264    fn props(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
1265        pairs
1266            .iter()
1267            .map(|(k, v)| (k.to_string(), v.to_string()))
1268            .collect()
1269    }
1270
1271    /// The default-state table is only useful if it covers the same blocks the
1272    /// property registry does and agrees with it on every value. A binding count
1273    /// is asserted, because a table that failed to cover anything would make
1274    /// every completion a silent no-op.
1275    #[test]
1276    fn every_block_has_a_legal_default_state() {
1277        let reg = BlockRegistry::v1_21_11();
1278        let mut with_properties = 0usize;
1279        for (name, properties) in &reg.blocks {
1280            let default = reg
1281                .default_state(name)
1282                .unwrap_or_else(|| panic!("{name} has no default state"));
1283            assert_eq!(
1284                default.keys().collect::<Vec<_>>(),
1285                properties.keys().collect::<Vec<_>>(),
1286                "{name}: the default state and the property list name different properties"
1287            );
1288            if !properties.is_empty() {
1289                with_properties += 1;
1290            }
1291            for (property, value) in default {
1292                assert!(
1293                    properties[property].contains(value),
1294                    "{name}[{property}={value}] is not one of that property's legal values"
1295                );
1296            }
1297        }
1298        assert_eq!(reg.defaults.len(), reg.blocks.len());
1299        assert_eq!(
1300            with_properties, 777,
1301            "1.21.11 has 777 blocks with at least one property"
1302        );
1303    }
1304
1305    /// The two completion questions live on one registry and must not disagree.
1306    /// `omitted_shape_carrying` names the properties that change the MODEL;
1307    /// `unwritten` names every property with the value the game fills it with.
1308    /// The first is a subset of the second on every block at the pin — a
1309    /// shape-carrying property the defaults table has no value for would let
1310    /// `DW0735` name a property `DW0791` cannot say anything about, and the two
1311    /// tables come from different sources (the client jar's blockstate
1312    /// definitions, and Mojang's generated block report), so nothing but this
1313    /// ties them.
1314    #[test]
1315    fn every_shape_carrying_property_has_a_default_to_complete_it() {
1316        let reg = BlockRegistry::v1_21_11();
1317        let mut bound = 0usize;
1318        for name in reg.shape.keys() {
1319            let omitted = reg.omitted_shape_carrying(name, &BTreeMap::new());
1320            let unwritten = reg.unwritten(name, &BTreeMap::new());
1321            assert!(
1322                !omitted.is_empty(),
1323                "{name} is in the shape table with no properties"
1324            );
1325            for property in &omitted {
1326                assert!(
1327                    unwritten.contains_key(property),
1328                    "{name}[{property}] carries shape but the default-state table cannot complete it"
1329                );
1330                bound += 1;
1331            }
1332        }
1333        assert_eq!(
1334            reg.shape.len(),
1335            95,
1336            "1.21.11 has 95 blocks whose model is assembled from parts"
1337        );
1338        assert!(
1339            bound >= 95,
1340            "only {bound} property pairs examined — the scan has come unbound"
1341        );
1342    }
1343
1344    /// The instance that makes this table worth vendoring: a bare
1345    /// `cobblestone_wall` is a POST, and the guess a reader would otherwise make
1346    /// — the first legal value of each property — is a different block.
1347    #[test]
1348    fn a_bare_wall_completes_to_a_post_not_to_the_first_legal_value() {
1349        let reg = BlockRegistry::v1_21_11();
1350        let unwritten = reg.unwritten("minecraft:cobblestone_wall", &BTreeMap::new());
1351        assert_eq!(unwritten["up"], "true");
1352        assert_eq!(unwritten["east"], "none");
1353        // The alphabetically-first legal value disagrees on both.
1354        let properties = reg.properties("minecraft:cobblestone_wall").unwrap();
1355        assert_eq!(properties["up"].first().unwrap(), "false");
1356        assert_eq!(properties["east"].first().unwrap(), "low");
1357        // A property the palette DID write is never reported as unwritten.
1358        let written = props(&[("up", "false")]);
1359        assert!(
1360            !reg.unwritten("minecraft:cobblestone_wall", &written)
1361                .contains_key("up")
1362        );
1363        // A block with no properties is complete the moment it is named.
1364        assert!(
1365            reg.unwritten("minecraft:stone", &BTreeMap::new())
1366                .is_empty()
1367        );
1368    }
1369
1370    #[test]
1371    fn the_registry_binds_to_the_whole_pinned_version() {
1372        let reg = BlockRegistry::v1_21_11();
1373        assert_eq!(reg.len(), 1166, "1.21.11 has 1166 blocks");
1374        for id in [
1375            "minecraft:air",
1376            "minecraft:stone",
1377            "minecraft:water",
1378            "minecraft:oak_stairs",
1379        ] {
1380            assert!(reg.has(id), "{id} missing from the pinned registry");
1381        }
1382    }
1383
1384    /// The finding this module exists for: 1.21.11 renamed `chain`, and eight
1385    /// cells of the old id shipped inside `tk-bell-tower.nbt`.
1386    #[test]
1387    fn the_renamed_chain_is_caught_and_the_rename_is_suggested() {
1388        let reg = BlockRegistry::v1_21_11();
1389        assert!(!reg.has("minecraft:chain"));
1390        assert!(reg.has("minecraft:iron_chain"));
1391        let err = reg
1392            .validate(
1393                "minecraft:chain",
1394                &props(&[("axis", "y"), ("waterlogged", "false")]),
1395            )
1396            .unwrap_err();
1397        let BlockError::UnknownBlock { suggestions, .. } = &err else {
1398            panic!("expected an unknown-block refusal, got {err}");
1399        };
1400        assert!(
1401            suggestions.contains(&"minecraft:iron_chain".to_string()),
1402            "the rename was not suggested: {suggestions:?}"
1403        );
1404        assert!(err.to_string().contains("loads an unknown block as AIR"));
1405    }
1406
1407    #[test]
1408    fn properties_and_values_are_checked_too() {
1409        let reg = BlockRegistry::v1_21_11();
1410        assert!(
1411            reg.validate(
1412                "minecraft:oak_stairs",
1413                &props(&[("facing", "east"), ("half", "top")])
1414            )
1415            .is_ok()
1416        );
1417        assert_eq!(
1418            reg.validate("minecraft:oak_stairs", &props(&[("orientation", "east")]))
1419                .unwrap_err(),
1420            BlockError::UnknownProperty {
1421                name: "minecraft:oak_stairs".into(),
1422                property: "orientation".into(),
1423                known: vec![
1424                    "facing".into(),
1425                    "half".into(),
1426                    "shape".into(),
1427                    "waterlogged".into()
1428                ],
1429            }
1430        );
1431        let err = reg
1432            .validate("minecraft:oak_stairs", &props(&[("facing", "up")]))
1433            .unwrap_err();
1434        assert!(matches!(err, BlockError::BadValue { .. }), "{err}");
1435        assert!(err.to_string().contains("north"), "{err}");
1436    }
1437
1438    #[test]
1439    fn a_state_string_validates_the_same_way_as_its_parts() {
1440        let reg = BlockRegistry::v1_21_11();
1441        assert!(reg.validate_state_string("minecraft:stone").is_ok());
1442        assert!(reg.validate_state_string("stone").is_ok());
1443        assert!(
1444            reg.validate_state_string("minecraft:iron_chain[axis=y,waterlogged=false]")
1445                .is_ok()
1446        );
1447        assert!(
1448            reg.validate_state_string("minecraft:chain[axis=y,waterlogged=false]")
1449                .is_err()
1450        );
1451    }
1452
1453    /// A datapack's own block is not this registry's business, and refusing it
1454    /// would make the check a wall instead of a spell-checker.
1455    #[test]
1456    fn a_foreign_namespace_is_left_alone() {
1457        let reg = BlockRegistry::v1_21_11();
1458        assert!(
1459            reg.validate("delvewright:nonesuch", &BTreeMap::new())
1460                .is_ok()
1461        );
1462    }
1463
1464    /// The DataVersion-aware rule: `minecraft:chain` at the pin is the
1465    /// tk-bell-tower defect (loads as air, error); the same id at DataVersion
1466    /// 2975 is `hero-temple-ruin-arch.nbt`, which the game datafixes on load
1467    /// (`chain` → `iron_chain`) — a warning, never a refusal.
1468    #[test]
1469    fn judge_at_separates_the_bell_tower_defect_from_the_ruin_arch_false_positive() {
1470        let reg = BlockRegistry::v1_21_11();
1471        let chain = props(&[("axis", "y")]);
1472        assert!(matches!(
1473            reg.judge_at("minecraft:chain", &chain, PIN_DATA_VERSION),
1474            StateJudgement::InvalidAtPin(_)
1475        ));
1476        assert!(matches!(
1477            reg.judge_at("minecraft:chain", &chain, 2975),
1478            StateJudgement::PrePin(_)
1479        ));
1480        // A post-pin DataVersion gets no fixes from the pinned game either.
1481        assert!(matches!(
1482            reg.judge_at("minecraft:chain", &chain, PIN_DATA_VERSION + 1),
1483            StateJudgement::InvalidAtPin(_)
1484        ));
1485        assert_eq!(
1486            reg.judge_at("minecraft:iron_chain", &chain, PIN_DATA_VERSION),
1487            StateJudgement::Valid
1488        );
1489    }
1490
1491    /// **Which id the game will hold** — the question `judge_at` deliberately
1492    /// does not answer and every name-judging check downstream of it needs.
1493    #[test]
1494    fn loaded_id_at_resolves_a_pre_pin_rename_and_refuses_to_guess_past_its_bound() {
1495        let reg = BlockRegistry::v1_21_11();
1496        // hero-temple-ruin-arch.nbt: below the old id's last DataVersion, so
1497        // the fixer certainly runs.
1498        assert_eq!(
1499            reg.loaded_id_at("minecraft:chain", 2975),
1500            LoadedId::Renamed {
1501                to: "minecraft:iron_chain",
1502                valid_through: 4440,
1503            }
1504        );
1505        assert_eq!(
1506            reg.loaded_id_at("chain", 4440),
1507            LoadedId::Renamed {
1508                to: "minecraft:iron_chain",
1509                valid_through: 4440,
1510            }
1511        );
1512        // Above the bound the schedule is unknown to this repo, so the table
1513        // says nothing rather than guessing — which is what makes a caller
1514        // REFUSE instead of pass.
1515        assert_eq!(
1516            reg.loaded_id_at("minecraft:chain", 4441),
1517            LoadedId::Unresolved
1518        );
1519        assert_eq!(
1520            reg.loaded_id_at("minecraft:chain", PIN_DATA_VERSION),
1521            LoadedId::Unresolved
1522        );
1523        // An id nothing renamed, at any DataVersion.
1524        assert_eq!(
1525            reg.loaded_id_at("minecraft:nonesuch", 2975),
1526            LoadedId::Unresolved
1527        );
1528        // Ids the pin has, and a datapack's own block, are held as written.
1529        assert_eq!(
1530            reg.loaded_id_at("minecraft:iron_chain", 2975),
1531            LoadedId::AsWritten
1532        );
1533        assert_eq!(
1534            reg.loaded_id_at("minecraft:stone", PIN_DATA_VERSION),
1535            LoadedId::AsWritten
1536        );
1537        assert_eq!(
1538            reg.loaded_id_at("delvewright:nonesuch", 2975),
1539            LoadedId::AsWritten
1540        );
1541    }
1542
1543    /// The vendored rename table's own contract, asserted against the registry
1544    /// beside it rather than trusted: a row whose `from` still exists is not a
1545    /// rename, a row whose `to` does not exist points at nothing, and a bound
1546    /// at or above the pin would resolve a file the fixer never touches.
1547    ///
1548    /// The binding count is asserted too — an empty table would satisfy every
1549    /// clause above by examining nothing, which is the shape CLAUDE.md names.
1550    #[test]
1551    fn every_vendored_rename_leaves_the_registry_and_lands_inside_it() {
1552        let reg = BlockRegistry::v1_21_11();
1553        assert!(
1554            !reg.renames().is_empty(),
1555            "binding count is zero: the rename table has no rows, so nothing below examined anything"
1556        );
1557        for (from, rename) in reg.renames() {
1558            assert!(!reg.has(from), "{from} is still a block at the pin");
1559            assert!(
1560                reg.has(&rename.to),
1561                "{from} -> {} is not a block at the pin",
1562                rename.to
1563            );
1564            assert!(
1565                rename.valid_through < PIN_DATA_VERSION,
1566                "{from}: valid_through {} is not below the pin",
1567                rename.valid_through
1568            );
1569        }
1570    }
1571
1572    /// The shape class: connection properties of multipart-assembled blocks
1573    /// are shape-carrying; variant-picking properties (`waterlogged`, `snowy`,
1574    /// `powered`, a lantern's `hanging`, a chain's `axis`) are not.
1575    #[test]
1576    fn shape_carrying_is_the_multipart_class_not_a_hand_list() {
1577        let reg = BlockRegistry::v1_21_11();
1578        assert_eq!(
1579            reg.shape_carrying("minecraft:cobblestone_wall"),
1580            ["east", "north", "south", "up", "west"]
1581        );
1582        assert_eq!(
1583            reg.shape_carrying("iron_bars"),
1584            ["east", "north", "south", "west"]
1585        );
1586        assert!(!reg.shape_carrying("minecraft:vine").is_empty());
1587        assert!(!reg.shape_carrying("minecraft:glow_lichen").is_empty());
1588        // Variant-picking properties: complete model, benign omission.
1589        assert!(reg.shape_carrying("minecraft:lantern").is_empty());
1590        assert!(reg.shape_carrying("minecraft:grass_block").is_empty());
1591        assert!(reg.shape_carrying("minecraft:spruce_button").is_empty());
1592        assert!(reg.shape_carrying("minecraft:oak_stairs").is_empty());
1593        assert!(reg.shape_carrying("minecraft:deepslate").is_empty());
1594        assert!(reg.shape_carrying("minecraft:iron_chain").is_empty());
1595        // Foreign/unknown ids belong to other diagnostics.
1596        assert!(reg.shape_carrying("delvewright:nonesuch").is_empty());
1597        assert!(reg.shape_carrying("minecraft:chain").is_empty());
1598        // Binding: the table covers the multipart blocks of the pin.
1599        assert_eq!(reg.shape.len(), 95, "95 blocks assemble their model");
1600    }
1601
1602    #[test]
1603    fn omitted_shape_carrying_reports_exactly_the_missing_ones() {
1604        let reg = BlockRegistry::v1_21_11();
1605        assert_eq!(
1606            reg.omitted_shape_carrying("minecraft:iron_bars", &BTreeMap::new()),
1607            ["east", "north", "south", "west"]
1608        );
1609        assert_eq!(
1610            reg.omitted_shape_carrying(
1611                "minecraft:vine",
1612                &props(&[("north", "true"), ("waterlogged", "false")])
1613            ),
1614            ["east", "south", "up", "west"]
1615        );
1616        assert!(
1617            reg.omitted_shape_carrying(
1618                "minecraft:iron_bars",
1619                &props(&[
1620                    ("east", "false"),
1621                    ("north", "true"),
1622                    ("south", "true"),
1623                    ("west", "false")
1624                ])
1625            )
1626            .is_empty()
1627        );
1628        assert!(
1629            reg.omitted_shape_carrying("minecraft:lantern", &BTreeMap::new())
1630                .is_empty()
1631        );
1632    }
1633
1634    /// Nothing reflected — the frame's second half at rest.
1635    const STRAIGHT: [bool; 3] = [false, false, false];
1636
1637    /// The `DW0736` predicate: a state is safe under a frame exactly when
1638    /// transforming it through the frame changes nothing.
1639    #[test]
1640    fn oriented_mismatch_transforms_through_the_registry_vocabulary() {
1641        let reg = BlockRegistry::v1_21_11();
1642        let keep = [0, 1, 2];
1643        let swap_xz = [2, 1, 0]; // local X is world Z, local Z is world X
1644        let move_y = [0, 2, 1]; // local Y is world Z
1645
1646        // Identity: nothing can land wrong.
1647        assert_eq!(
1648            reg.oriented_mismatch(
1649                "minecraft:oak_stairs",
1650                &props(&[("facing", "north")]),
1651                keep,
1652                STRAIGHT
1653            ),
1654            None
1655        );
1656        // A horizontal facing under a horizontal swap is the defect.
1657        assert_eq!(
1658            reg.oriented_mismatch(
1659                "minecraft:oak_stairs",
1660                &props(&[("facing", "north")]),
1661                swap_xz,
1662                STRAIGHT
1663            ),
1664            Some("facing=north".to_string())
1665        );
1666        // A vertical facing survives a horizontal swap but not a moved Y.
1667        assert_eq!(
1668            reg.oriented_mismatch(
1669                "minecraft:barrel",
1670                &props(&[("facing", "up")]),
1671                swap_xz,
1672                STRAIGHT
1673            ),
1674            None
1675        );
1676        assert_eq!(
1677            reg.oriented_mismatch(
1678                "minecraft:barrel",
1679                &props(&[("facing", "up")]),
1680                move_y,
1681                STRAIGHT
1682            ),
1683            Some("facing=up".to_string())
1684        );
1685        // `axis=y` is invariant under the swap; `axis=x` is not.
1686        assert_eq!(
1687            reg.oriented_mismatch(
1688                "minecraft:spruce_log",
1689                &props(&[("axis", "y")]),
1690                swap_xz,
1691                STRAIGHT
1692            ),
1693            None
1694        );
1695        assert_eq!(
1696            reg.oriented_mismatch(
1697                "minecraft:spruce_log",
1698                &props(&[("axis", "x")]),
1699                swap_xz,
1700                STRAIGHT
1701            ),
1702            Some("axis=x".to_string())
1703        );
1704        // Connection flags: an asymmetric run turns; a symmetric one does not.
1705        assert_eq!(
1706            reg.oriented_mismatch(
1707                "minecraft:iron_bars",
1708                &props(&[
1709                    ("east", "false"),
1710                    ("north", "true"),
1711                    ("south", "true"),
1712                    ("west", "false")
1713                ]),
1714                swap_xz,
1715                STRAIGHT
1716            ),
1717            Some("east=false".to_string())
1718        );
1719        assert_eq!(
1720            reg.oriented_mismatch(
1721                "minecraft:iron_bars",
1722                &props(&[
1723                    ("east", "true"),
1724                    ("north", "true"),
1725                    ("south", "true"),
1726                    ("west", "true")
1727                ]),
1728                swap_xz,
1729                STRAIGHT
1730            ),
1731            None
1732        );
1733        // The documented residue: a 16-step yaw cannot be transformed by axis
1734        // vocabulary, so it is mismatched whenever a horizontal axis moves.
1735        assert_eq!(
1736            reg.oriented_mismatch(
1737                "minecraft:skeleton_skull",
1738                &props(&[("rotation", "8")]),
1739                swap_xz,
1740                STRAIGHT
1741            ),
1742            Some("rotation=8".to_string())
1743        );
1744        assert_eq!(
1745            reg.oriented_mismatch(
1746                "minecraft:skeleton_skull",
1747                &props(&[("rotation", "8")]),
1748                move_y,
1749                STRAIGHT
1750            ),
1751            Some("rotation=8".to_string()),
1752            "a moved Z scrambles a yaw too — the residue is conservative on purpose"
1753        );
1754        // Yaw-invariant properties never mismatch.
1755        assert_eq!(
1756            reg.oriented_mismatch(
1757                "minecraft:oak_slab",
1758                &props(&[("type", "top"), ("waterlogged", "false")]),
1759                swap_xz,
1760                STRAIGHT
1761            ),
1762            None
1763        );
1764    }
1765
1766    /// The reflection half of the frame. A mirror is not a permutation — no
1767    /// rotation reproduces it — so a predicate that reads only the permutation
1768    /// answers `None` for every mirrored scope, which is the answer that says
1769    /// "safe".
1770    #[test]
1771    fn oriented_mismatch_reads_the_reflection_half_of_the_frame() {
1772        let reg = BlockRegistry::v1_21_11();
1773        let keep = [0, 1, 2];
1774        let flip_z = [false, false, true];
1775        let flip_x = [true, false, false];
1776        let flip_y = [false, true, false];
1777
1778        // A reflected identity frame is NOT the identity frame: local north
1779        // now runs the other way, so a literal `north` lands south.
1780        assert_eq!(
1781            reg.oriented_mismatch(
1782                "minecraft:oak_stairs",
1783                &props(&[("facing", "north")]),
1784                keep,
1785                flip_z
1786            ),
1787            Some("facing=north".to_string())
1788        );
1789        // ...and the axis the mirror does not touch is untouched: the mirror
1790        // image of a north-facing stair across the east-west axis still faces
1791        // north. This is the assertion that keeps the check from degenerating
1792        // into "any mirror is wrong".
1793        assert_eq!(
1794            reg.oriented_mismatch(
1795                "minecraft:oak_stairs",
1796                &props(&[("facing", "north")]),
1797                keep,
1798                flip_x
1799            ),
1800            None
1801        );
1802        assert_eq!(
1803            reg.oriented_mismatch(
1804                "minecraft:oak_stairs",
1805                &props(&[("facing", "east")]),
1806                keep,
1807                flip_x
1808            ),
1809            Some("facing=east".to_string())
1810        );
1811        // A vertical reflection is the one that moves `up`.
1812        assert_eq!(
1813            reg.oriented_mismatch(
1814                "minecraft:barrel",
1815                &props(&[("facing", "up")]),
1816                keep,
1817                flip_y
1818            ),
1819            Some("facing=up".to_string())
1820        );
1821        assert_eq!(
1822            reg.oriented_mismatch(
1823                "minecraft:barrel",
1824                &props(&[("facing", "up")]),
1825                keep,
1826                flip_x
1827            ),
1828            None
1829        );
1830        // An axis carries no sign, so a reflection cannot disturb it: a pillar
1831        // reflected along its own axis is the same pillar.
1832        assert_eq!(
1833            reg.oriented_mismatch(
1834                "minecraft:spruce_log",
1835                &props(&[("axis", "x")]),
1836                keep,
1837                flip_x
1838            ),
1839            None
1840        );
1841        // Connection flags: the mirror image of a run that ends at the north
1842        // is a run that ends at the south.
1843        assert_eq!(
1844            reg.oriented_mismatch(
1845                "minecraft:iron_bars",
1846                &props(&[
1847                    ("east", "true"),
1848                    ("north", "false"),
1849                    ("south", "true"),
1850                    ("west", "true")
1851                ]),
1852                keep,
1853                flip_z
1854            ),
1855            Some("north=false".to_string())
1856        );
1857        // ...and a run symmetric about the mirror is not disturbed by it.
1858        assert_eq!(
1859            reg.oriented_mismatch(
1860                "minecraft:iron_bars",
1861                &props(&[
1862                    ("east", "true"),
1863                    ("north", "false"),
1864                    ("south", "false"),
1865                    ("west", "true")
1866                ]),
1867                keep,
1868                flip_z
1869            ),
1870            None
1871        );
1872        // The chiral residue. A reflection is exactly what flips a door's
1873        // hinge and a stair's corner, and exactly what a permutation cannot
1874        // express — so the residue counts a reflected horizontal axis as a
1875        // move, as it counts a permuted one.
1876        assert_eq!(
1877            reg.oriented_mismatch(
1878                "minecraft:oak_door",
1879                &props(&[("hinge", "left")]),
1880                keep,
1881                flip_x
1882            ),
1883            Some("hinge=left".to_string())
1884        );
1885        assert_eq!(
1886            reg.oriented_mismatch(
1887                "minecraft:skeleton_skull",
1888                &props(&[("rotation", "8")]),
1889                keep,
1890                flip_z
1891            ),
1892            Some("rotation=8".to_string())
1893        );
1894        // A two-direction value transforms component-wise through the sign.
1895        assert_eq!(
1896            reg.oriented_mismatch(
1897                "minecraft:jigsaw",
1898                &props(&[("orientation", "north_up")]),
1899                keep,
1900                flip_z
1901            ),
1902            Some("orientation=north_up".to_string())
1903        );
1904        // Reflecting an axis nothing in the state names changes nothing —
1905        // the check is not a blanket refusal of mirrored scopes.
1906        assert_eq!(
1907            reg.oriented_mismatch(
1908                "minecraft:oak_slab",
1909                &props(&[("type", "top"), ("waterlogged", "false")]),
1910                keep,
1911                flip_x
1912            ),
1913            None
1914        );
1915        // A permutation and a reflection compose, and the composite is a
1916        // different map from either half: swapping X and Z is itself a mirror
1917        // of the horizontal plane, and adding a Z reflection turns it into a
1918        // quarter turn. Local north lands east here where the bare swap lands
1919        // it west — a different wrong answer from the same literal, which is
1920        // why the sign cannot be dropped on the way in.
1921        assert_eq!(
1922            reg.oriented_mismatch(
1923                "minecraft:oak_stairs",
1924                &props(&[("facing", "north")]),
1925                [2, 1, 0],
1926                [false, false, true]
1927            ),
1928            Some("facing=north".to_string())
1929        );
1930        // The vertical rides through both halves of a composite frame
1931        // untouched, so a composite is not a blanket refusal either.
1932        assert_eq!(
1933            reg.oriented_mismatch(
1934                "minecraft:barrel",
1935                &props(&[("facing", "up")]),
1936                [2, 1, 0],
1937                [false, false, true]
1938            ),
1939            None
1940        );
1941    }
1942
1943    /// **The judge and the rewriter are one transform, checked from both
1944    /// ends**: whatever `oriented_mismatch` calls wrong, `permuted_properties`
1945    /// rewrites, and the rewrite is what the state would have had to say.
1946    #[test]
1947    fn permuted_properties_is_the_state_the_mismatch_predicate_wanted() {
1948        let reg = BlockRegistry::v1_21_11();
1949        let swap_xz = [2, 1, 0];
1950
1951        // Connection flags move by KEY: a run along local Z becomes a run
1952        // along world X.
1953        let bars = props(&[
1954            ("east", "false"),
1955            ("north", "true"),
1956            ("south", "true"),
1957            ("waterlogged", "false"),
1958            ("west", "false"),
1959        ]);
1960        assert_eq!(
1961            reg.oriented_mismatch("minecraft:iron_bars", &bars, swap_xz, STRAIGHT),
1962            Some("east=false".to_string()),
1963            "the literal is wrong under the swap…"
1964        );
1965        assert_eq!(
1966            reg.permuted_properties("minecraft:iron_bars", &bars, swap_xz, STRAIGHT),
1967            Ok(props(&[
1968                ("east", "true"),
1969                ("north", "false"),
1970                ("south", "false"),
1971                ("waterlogged", "false"),
1972                ("west", "true"),
1973            ])),
1974            "…and this is what it had to say instead"
1975        );
1976
1977        // A facing moves by VALUE, and a vertical one does not move at all.
1978        assert_eq!(
1979            reg.permuted_properties(
1980                "minecraft:oak_stairs",
1981                &props(&[
1982                    ("facing", "north"),
1983                    ("half", "bottom"),
1984                    ("shape", "straight"),
1985                    ("waterlogged", "false"),
1986                ]),
1987                swap_xz,
1988                STRAIGHT
1989            ),
1990            Ok(props(&[
1991                ("facing", "west"),
1992                ("half", "bottom"),
1993                ("shape", "straight"),
1994                ("waterlogged", "false"),
1995            ]))
1996        );
1997        assert_eq!(
1998            reg.permuted_properties(
1999                "minecraft:barrel",
2000                &props(&[("facing", "up")]),
2001                swap_xz,
2002                STRAIGHT
2003            ),
2004            Ok(props(&[("facing", "up")]))
2005        );
2006
2007        // A 16-step yaw: the swap is a REFLECTION of the horizontal plane, so
2008        // r becomes (12 - r) mod 16. Rotation 8 is north, 4 is west — which is
2009        // where the swap sends north.
2010        assert_eq!(
2011            reg.permuted_properties(
2012                "minecraft:skeleton_skull",
2013                &props(&[("powered", "false"), ("rotation", "8")]),
2014                swap_xz,
2015                STRAIGHT
2016            ),
2017            Ok(props(&[("powered", "false"), ("rotation", "4")]))
2018        );
2019        // A yaw on the reflection's own diagonal is its own image — and the
2020        // mismatch predicate agrees, because both read the one transform.
2021        assert_eq!(
2022            reg.permuted_properties(
2023                "minecraft:skeleton_skull",
2024                &props(&[("rotation", "6")]),
2025                swap_xz,
2026                STRAIGHT
2027            ),
2028            Ok(props(&[("rotation", "6")]))
2029        );
2030        assert_eq!(
2031            reg.oriented_mismatch(
2032                "minecraft:skeleton_skull",
2033                &props(&[("rotation", "6")]),
2034                swap_xz,
2035                STRAIGHT
2036            ),
2037            None
2038        );
2039
2040        // Handedness is what a reflection swaps.
2041        assert_eq!(
2042            reg.permuted_properties(
2043                "minecraft:oak_door",
2044                &props(&[("facing", "north"), ("hinge", "left")]),
2045                swap_xz,
2046                STRAIGHT
2047            ),
2048            Ok(props(&[("facing", "west"), ("hinge", "right")]))
2049        );
2050    }
2051
2052    /// **The refusal, and what secures it.** A frame that moves the vertical
2053    /// leaves a yaw, a handedness and a `top`/`bottom` half with nothing to
2054    /// mean, and a horizontal connection with nowhere to land. The answer is
2055    /// `DW0738` — no image — and never a plausible substitute.
2056    #[test]
2057    fn a_property_with_no_image_is_refused_rather_than_guessed() {
2058        let reg = BlockRegistry::v1_21_11();
2059        let move_y = [0, 2, 1]; // local Y is world Z
2060
2061        assert_eq!(DW_LOCAL_FRAME_UNRESOLVABLE, "DW0738");
2062        assert_eq!(
2063            reg.permuted_properties(
2064                "minecraft:skeleton_skull",
2065                &props(&[("rotation", "8")]),
2066                move_y,
2067                STRAIGHT
2068            ),
2069            Err("rotation=8".to_string())
2070        );
2071        assert_eq!(
2072            reg.permuted_properties(
2073                "minecraft:oak_slab",
2074                &props(&[("type", "top")]),
2075                move_y,
2076                STRAIGHT
2077            ),
2078            Err("type=top".to_string())
2079        );
2080        // A pane has no `up` flag, so a connection turned onto the vertical
2081        // has no key to land on.
2082        assert_eq!(
2083            reg.permuted_properties(
2084                "minecraft:iron_bars",
2085                &props(&[("north", "true")]),
2086                move_y,
2087                STRAIGHT
2088            ),
2089            Err("north=true".to_string())
2090        );
2091        // A rail's shape spells its directions inside a compound word. The
2092        // vocabulary says it carries a direction and does not say how to map
2093        // it, which is the case to refuse.
2094        assert_eq!(
2095            reg.permuted_properties(
2096                "minecraft:rail",
2097                &props(&[("shape", "ascending_north")]),
2098                [2, 1, 0],
2099                STRAIGHT
2100            ),
2101            Err("shape=ascending_north".to_string())
2102        );
2103        // The identity frame moves nothing, so nothing is ever refused under
2104        // it.
2105        assert_eq!(
2106            reg.permuted_properties(
2107                "minecraft:skeleton_skull",
2108                &props(&[("rotation", "8")]),
2109                [0, 1, 2],
2110                STRAIGHT
2111            ),
2112            Ok(props(&[("rotation", "8")]))
2113        );
2114    }
2115
2116    /// **A local frame inside a MIRRORED body** — the case that exists only
2117    /// where the resolver and the reflected frame meet, and that neither the
2118    /// reflection work nor the local-frame work could have had.
2119    ///
2120    /// The trap is the short circuit. A pure reflection has the identity axis
2121    /// permutation, so a resolver keyed on the permutation alone answers "the
2122    /// identity moves nothing" and writes the state through unchanged — the
2123    /// same short circuit to "safe" that the `DW0736` judge had before it grew
2124    /// its reflection half, and here it does not merely miss a defect, it
2125    /// WRITES one.
2126    #[test]
2127    fn a_local_frame_resolves_through_the_reflection_half_too() {
2128        let reg = BlockRegistry::v1_21_11();
2129        let keep = [0, 1, 2];
2130        let flip_x = [true, false, false];
2131        let flip_z = [false, false, true];
2132
2133        // The identity permutation is NOT the identity frame once an axis runs
2134        // backwards: a bar spanning the scope's local X spans it the other way
2135        // round, so `east`/`west` swap. They carry the same value here, so the
2136        // resolved state is equal to the literal — and the interesting one is
2137        // the ASYMMETRIC run below.
2138        assert_eq!(
2139            reg.permuted_properties(
2140                "minecraft:oak_stairs",
2141                &props(&[
2142                    ("facing", "east"),
2143                    ("half", "bottom"),
2144                    ("shape", "straight"),
2145                    ("waterlogged", "false"),
2146                ]),
2147                keep,
2148                flip_x
2149            ),
2150            Ok(props(&[
2151                ("facing", "west"),
2152                ("half", "bottom"),
2153                ("shape", "straight"),
2154                ("waterlogged", "false"),
2155            ])),
2156            "a reflected local X sends the scope's east to the world's west"
2157        );
2158        // An asymmetric run of bars: the local run ends at the scope's north,
2159        // and under a reflected local Z that end is the world's south.
2160        assert_eq!(
2161            reg.permuted_properties(
2162                "minecraft:iron_bars",
2163                &props(&[
2164                    ("east", "true"),
2165                    ("north", "true"),
2166                    ("south", "false"),
2167                    ("waterlogged", "false"),
2168                    ("west", "true"),
2169                ]),
2170                keep,
2171                flip_z
2172            ),
2173            Ok(props(&[
2174                ("east", "true"),
2175                ("north", "false"),
2176                ("south", "true"),
2177                ("waterlogged", "false"),
2178                ("west", "true"),
2179            ]))
2180        );
2181        // The axis half is sign-free, so a pillar is the same pillar in a
2182        // mirrored body — a reflection is not a blanket rewrite.
2183        assert_eq!(
2184            reg.permuted_properties(
2185                "minecraft:spruce_log",
2186                &props(&[("axis", "x")]),
2187                keep,
2188                flip_x
2189            ),
2190            Ok(props(&[("axis", "x")]))
2191        );
2192        // And a vertical facing rides a horizontal reflection untouched.
2193        assert_eq!(
2194            reg.permuted_properties(
2195                "minecraft:barrel",
2196                &props(&[("facing", "up")]),
2197                keep,
2198                flip_x
2199            ),
2200            Ok(props(&[("facing", "up")]))
2201        );
2202
2203        // A frame that both reflects AND permutes composes the two halves:
2204        // local north is world east here, where the bare swap would send it
2205        // west.
2206        assert_eq!(
2207            reg.permuted_properties(
2208                "minecraft:oak_stairs",
2209                &props(&[
2210                    ("facing", "north"),
2211                    ("half", "bottom"),
2212                    ("shape", "straight"),
2213                    ("waterlogged", "false"),
2214                ]),
2215                [2, 1, 0],
2216                flip_z
2217            ),
2218            Ok(props(&[
2219                ("facing", "east"),
2220                ("half", "bottom"),
2221                ("shape", "straight"),
2222                ("waterlogged", "false"),
2223            ]))
2224        );
2225    }
2226
2227    /// The yaw and the handedness are the residue, and a reflected frame is
2228    /// **outside** the vocabulary that determines them — so the resolver
2229    /// refuses rather than writing a plausible skull, and refuses exactly where
2230    /// the judge calls the same state wrong.
2231    ///
2232    /// One verdict read from two ends is the invariant that makes the refusal
2233    /// safe: were the resolver to guess here, it would write states the
2234    /// `DW0736` gate reports as mismatched, and the build would be red about a
2235    /// block the build itself had chosen.
2236    #[test]
2237    fn the_frame_relative_residue_refuses_under_a_reflection_and_the_judge_agrees() {
2238        let reg = BlockRegistry::v1_21_11();
2239        let keep = [0, 1, 2];
2240        let flip_x = [true, false, false];
2241        let swap_xz = [2, 1, 0];
2242
2243        for (perm, refl, state, prop) in [
2244            (keep, flip_x, "minecraft:skeleton_skull", "rotation=8"),
2245            (swap_xz, flip_x, "minecraft:skeleton_skull", "rotation=8"),
2246            (keep, flip_x, "minecraft:oak_door", "hinge=left"),
2247            (swap_xz, flip_x, "minecraft:oak_door", "hinge=left"),
2248        ] {
2249            let (k, v) = prop.split_once('=').unwrap();
2250            let p = props(&[(k, v)]);
2251            assert_eq!(
2252                reg.permuted_properties(state, &p, perm, refl),
2253                Err(prop.to_string()),
2254                "{state} {prop} under {perm:?}/{refl:?} must be refused, not guessed"
2255            );
2256            assert_eq!(
2257                reg.oriented_mismatch(state, &p, perm, refl),
2258                Some(prop.to_string()),
2259                "…and the judge must call the same state wrong"
2260            );
2261        }
2262
2263        // A vertical position has no image once the vertical itself runs
2264        // backwards, and it is untouched by a horizontal reflection.
2265        assert_eq!(
2266            reg.permuted_properties(
2267                "minecraft:oak_slab",
2268                &props(&[("type", "top")]),
2269                keep,
2270                [false, true, false]
2271            ),
2272            Err("type=top".to_string())
2273        );
2274        assert_eq!(
2275            reg.permuted_properties(
2276                "minecraft:oak_slab",
2277                &props(&[("type", "top")]),
2278                keep,
2279                flip_x
2280            ),
2281            Ok(props(&[("type", "top")]))
2282        );
2283    }
2284
2285    /// The two entry points cannot drift apart: over a corpus of real states
2286    /// and every frame the grammar can produce, `permuted_properties` succeeds
2287    /// exactly when `oriented_mismatch` is silent, and its output is a state
2288    /// the pin accepts.
2289    ///
2290    /// Binding count is asserted, so a corpus or a frame list that quietly
2291    /// stopped being enumerated is a red rather than a green over nothing.
2292    #[test]
2293    fn the_judge_and_the_resolver_agree_over_every_frame_the_grammar_can_make() {
2294        let reg = BlockRegistry::v1_21_11();
2295        let states: [(&str, &[(&str, &str)]); 8] = [
2296            (
2297                "minecraft:oak_stairs",
2298                &[
2299                    ("facing", "east"),
2300                    ("half", "bottom"),
2301                    ("shape", "straight"),
2302                    ("waterlogged", "false"),
2303                ],
2304            ),
2305            (
2306                "minecraft:iron_bars",
2307                &[
2308                    ("east", "true"),
2309                    ("north", "true"),
2310                    ("south", "false"),
2311                    ("waterlogged", "false"),
2312                    ("west", "false"),
2313                ],
2314            ),
2315            ("minecraft:spruce_log", &[("axis", "x")]),
2316            ("minecraft:barrel", &[("facing", "up"), ("open", "false")]),
2317            (
2318                "minecraft:skeleton_skull",
2319                &[("powered", "false"), ("rotation", "3")],
2320            ),
2321            (
2322                "minecraft:oak_door",
2323                &[
2324                    ("facing", "north"),
2325                    ("half", "lower"),
2326                    ("hinge", "left"),
2327                    ("open", "false"),
2328                    ("powered", "false"),
2329                ],
2330            ),
2331            (
2332                "minecraft:oak_slab",
2333                &[("type", "top"), ("waterlogged", "false")],
2334            ),
2335            ("minecraft:jigsaw", &[("orientation", "north_up")]),
2336        ];
2337        let perms = [
2338            [0usize, 1, 2],
2339            [2, 1, 0],
2340            [0, 2, 1],
2341            [1, 0, 2],
2342            [1, 2, 0],
2343            [2, 0, 1],
2344        ];
2345        let mut checked = 0usize;
2346        let mut resolved = 0usize;
2347        for (name, pairs) in states {
2348            let properties = props(pairs);
2349            for perm in perms {
2350                for bits in 0..8u8 {
2351                    let refl = [bits & 1 != 0, bits & 2 != 0, bits & 4 != 0];
2352                    checked += 1;
2353                    let judged = reg.oriented_mismatch(name, &properties, perm, refl);
2354                    match reg.permuted_properties(name, &properties, perm, refl) {
2355                        Ok(out) => {
2356                            resolved += 1;
2357                            // The resolver produced a state the pin accepts —
2358                            // a rewrite that invented an illegal value would
2359                            // pass every gate above this one and fail on a
2360                            // server.
2361                            assert!(
2362                                reg.validate(name, &out).is_ok(),
2363                                "{name} under {perm:?}/{refl:?} resolved to {out:?}, which \
2364                                 the pin does not accept"
2365                            );
2366                            // One transform, read from two ends: the judge is
2367                            // silent exactly when the transform is the
2368                            // identity on this state. Either direction failing
2369                            // means a state one end calls wrong is one the
2370                            // other quietly writes.
2371                            assert_eq!(
2372                                judged.is_none(),
2373                                out == properties,
2374                                "{name} under {perm:?}/{refl:?}: judge said {judged:?} while \
2375                                 the resolver wrote {out:?} for {properties:?}"
2376                            );
2377                        }
2378                        Err(refused) => assert!(
2379                            judged.is_some(),
2380                            "{name} under {perm:?}/{refl:?} was refused as {refused} while \
2381                             the judge called the state safe — the two ends disagree"
2382                        ),
2383                    }
2384                }
2385            }
2386        }
2387        assert_eq!(
2388            checked,
2389            8 * 6 * 8,
2390            "binding count: states x perms x mirrors"
2391        );
2392        assert!(
2393            resolved > 0 && resolved < checked,
2394            "binding count {resolved} of {checked}: the sweep must contain both \
2395             resolutions and refusals, or it discriminates nothing"
2396        );
2397    }
2398
2399    /// **A sculk block is judged by its state** (spec-0100 §4.1): `can_summon`
2400    /// is `DW0998`; every other consequential property and NBT field off its
2401    /// rest value is `DW0999` naming the field; the bare ids pass on their
2402    /// defaults; the inert two are not judged.
2403    #[test]
2404    fn a_sculk_block_is_judged_by_its_state() {
2405        let fault = |s: &str, n: Option<&SculkNbt>| sculk_rest(s, n).expect_err(s);
2406        let f = fault("minecraft:sculk_shrieker[can_summon=true]", None);
2407        assert_eq!(f.code(), DW_SHRIEKER_CAN_SUMMON);
2408        assert_eq!(f.code().id(), "DW0998");
2409        for (state, field) in [
2410            (
2411                "minecraft:sculk_sensor[sculk_sensor_phase=active]",
2412                "sculk_sensor_phase",
2413            ),
2414            ("minecraft:sculk_sensor[power=3]", "power"),
2415            ("minecraft:sculk_shrieker[shrieking=true]", "shrieking"),
2416            ("minecraft:sculk_catalyst[bloom=true]", "bloom"),
2417            (
2418                "minecraft:calibrated_sculk_sensor[sculk_sensor_phase=cooldown]",
2419                "sculk_sensor_phase",
2420            ),
2421        ] {
2422            let f = fault(state, None);
2423            assert_eq!(f.code(), DW_SCULK_NOT_AT_REST, "{state}");
2424            assert_eq!(f.code().id(), "DW0999");
2425            assert!(f.to_string().contains(&format!("`{field}`")), "{f}");
2426        }
2427        let listening = SculkNbt {
2428            listener_event: true,
2429            ..SculkNbt::default()
2430        };
2431        let f = fault("minecraft:sculk_sensor", Some(&listening));
2432        assert_eq!(f.code(), DW_SCULK_NOT_AT_REST);
2433        assert!(f.to_string().contains("nbt.listener.event"), "{f}");
2434        let warned = SculkNbt {
2435            warning_level: 2,
2436            ..SculkNbt::default()
2437        };
2438        assert!(
2439            fault("minecraft:sculk_shrieker", Some(&warned))
2440                .to_string()
2441                .contains("nbt.warning_level")
2442        );
2443        for state in [
2444            "minecraft:sculk_sensor",
2445            "minecraft:calibrated_sculk_sensor",
2446            "minecraft:calibrated_sculk_sensor[facing=east]",
2447            "minecraft:sculk_shrieker",
2448            "minecraft:sculk_catalyst",
2449            "minecraft:sculk_shrieker[can_summon=false]",
2450            "sculk_shrieker[can_summon=false,shrieking=false,waterlogged=false]",
2451        ] {
2452            assert_eq!(sculk_rest(state, None), Ok(()), "{state}");
2453            assert_eq!(
2454                sculk_rest(state, Some(&SculkNbt::default())),
2455                Ok(()),
2456                "{state}"
2457            );
2458        }
2459        // The inert two are never judged, whatever they carry.
2460        assert_eq!(sculk_rest("minecraft:sculk", Some(&listening)), Ok(()));
2461        assert_eq!(
2462            sculk_rest("minecraft:sculk_vein[down=true]", Some(&listening)),
2463            Ok(())
2464        );
2465        // The bare shrieker passes BECAUSE its default is read: the rule never
2466        // assumes rest for a value it cannot find.
2467        assert_eq!(
2468            BlockRegistry::v1_21_11()
2469                .default_state("minecraft:sculk_shrieker")
2470                .and_then(|d| d.get("can_summon"))
2471                .map(String::as_str),
2472            Some("false")
2473        );
2474    }
2475}