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