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