Skip to main content

delvewright_dsl/
blocks.rs

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