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 ®.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}