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