Merge pull request #11370 from MSedgeMC/test/brewing

brewing: every recipe's mix, slot filters and the stand tick
This commit is contained in:
rdbt authored and GitHub committed 2026-11-07 12:44:33 +00:00
commit db2bc09336
4 files changed
+442

No files matched your search

@@ -0,0 +1,96 @@
//! Brewing parity (tools/test_brewing_parity.py): reads "input reagent
//! candidate" lines of item SNBT and prints the three brewing-slot filters,
//! the matching recipe and its assembled result; "tick" lines build a
//! BrewingStand, run it for their tick count, and print the same state the
//! real BrewingStandBlockEntity.serverTick oracle prints.
//!
//! The first input line is `#resources RESOURCES`; usage: brewing_fixture INPUT OUTPUT
use horizon_client::{
brewing::{Brewing, BrewingStand, brewing_fuel},
item_data::{canon_stack, parse_stack},
items::{ItemStack, Items},
};
use horizon_client_render::providers::Providers;
use horizon_world::loot::PackOverlay;
use std::sync::Arc;
use std::{fmt::Write as _, fs};
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let input = fs::read_to_string(args.get(1).ok_or("missing INPUT")?)?;
let root = input
.lines()
.find_map(|line| line.strip_prefix("#resources "))
.map(std::path::PathBuf::from)
.ok_or("no #resources line")?;
let items = Items::catalog(&root, &PackOverlay::NONE)?;
let brewing = Brewing::load(&root, &items, &PackOverlay::NONE);
let providers = Providers::load(&root);
let mut out = String::new();
for (n, line) in input
.lines()
.filter(|line| !line.starts_with('#'))
.enumerate()
{
let f: Vec<&str> = line.split(' ').collect();
if f[0] == "tick" {
let ticks: u32 = f[1].parse()?;
let mut slots: [Option<ItemStack>; 5] = Default::default();
for i in 0..5 {
slots[i] = parse_stack(f[2 + i])?;
}
let mut stand = BrewingStand::new();
stand.items = slots;
stand.brew_time = f[7].parse()?;
stand.fuel = f[8].parse()?;
stand.total_brew_time = f[9].parse()?;
stand.total_fuel = f[10].parse()?;
stand.speed = f[11].parse()?;
stand.ingredient = (f[12] != "-").then(|| Arc::<str>::from(f[12]));
let mut brews = 0;
for _ in 0..ticks {
brews += stand.tick(&brewing, &items, &providers).brewed as u32;
}
let bits: String = stand.items[..3]
.iter()
.map(|s| if s.is_some() { '1' } else { '0' })
.collect();
writeln!(
out,
"{n} s0={} s1={} s2={} s3={} s4={} time={} fuel={} total={} totalfuel={} speed={:.3} bits={bits} ing={} brews={brews}",
canon_stack(stand.items[0].as_ref()),
canon_stack(stand.items[1].as_ref()),
canon_stack(stand.items[2].as_ref()),
canon_stack(stand.items[3].as_ref()),
canon_stack(stand.items[4].as_ref()),
stand.brew_time,
stand.fuel,
stand.total_brew_time,
stand.total_fuel,
stand.speed,
stand.ingredient.as_deref().unwrap_or("-"),
)?;
continue;
}
let input = parse_stack(f[0])?;
let reagent = parse_stack(f[1])?;
let candidate = parse_stack(f[2])?;
let potion_slot = candidate.as_ref().is_some_and(|s| brewing.is_bottle(s));
let reagent_slot = candidate.as_ref().is_some_and(|s| brewing.is_reagent(s));
let fuel_slot = candidate
.as_ref()
.is_some_and(|s| brewing_fuel(&items, s).is_some());
let found = match (input.as_ref(), reagent.as_ref()) {
(Some(input), Some(reagent)) => brewing.find(input, reagent),
_ => None,
};
let result = found.map_or_else(|| "-".to_owned(), |r| canon_stack(Some(&r.output)));
let id = found.map_or_else(|| "-".to_owned(), |r| r.id.clone());
writeln!(
out,
"{n} {potion_slot} {reagent_slot} {fuel_slot} {result} {id}"
)?;
}
fs::write(args.get(2).ok_or("missing OUTPUT")?, out)?;
Ok(())
}
@@ -24,6 +24,7 @@ mod block_particles_fixture;
mod block_tint_probe;
mod block_transform_fixture;
mod breaking_fixture;
mod brewing_fixture;
mod camera_probe;
mod cauldron_fixture;
mod chat_screen_fixture;
@@ -127,6 +128,7 @@ probes! {
block_entity_tickers_fixture,
block_particles_fixture,
block_transform_fixture,
brewing_fixture,
breaking_fixture,
block_tint_probe,
camera_probe,
@@ -0,0 +1,156 @@
package dev.horizonlab.parity;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.io.PrintWriter;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.world.flag.FeatureFlags;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.BrewingStandBlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.item.crafting.BrewingInput;
import net.minecraft.world.item.crafting.BrewingRecipe;
import net.minecraft.world.item.crafting.PotionIngredient;
import net.minecraft.world.item.crafting.RecipeHolder;
import net.minecraft.world.item.crafting.RecipeManager;
import net.minecraft.world.item.crafting.RecipePropertySet;
import net.minecraft.world.item.crafting.RecipeType;
/**
* Original oracle for the brewing recipes: a real `RecipeManager` over the
* vanilla data pack, its `brewing_input`/`brewing_reagent` property sets, the
* three brewing-slot filters, and the first matching `BrewingRecipe`'s
* `assemble`. Fixture lines are "input-snbt reagent-snbt candidate-snbt";
* "tick" lines build a brewing stand in a block-update world and call the real
* `BrewingStandBlockEntity.serverTick`, printing the slots, brew time, fuel,
* speed, bottle bits, ingredient and brew-event count.
*/
public final class VanillaBrewingOracle {
@SuppressWarnings("unchecked")
public static void main(String[] args) throws Exception {
ParityData.init();
VanillaBlockUpdateOracle.AIR = Blocks.AIR.defaultBlockState();
var allocator = Class.forName("sun.misc.Unsafe");
var unsafeField = allocator.getDeclaredField("theUnsafe");
unsafeField.setAccessible(true);
VanillaBlockUpdateOracle.unsafe = unsafeField.get(null);
VanillaBlockUpdateOracle.allocate = allocator.getMethod("allocateInstance", Class.class);
var recipes = new RecipeManager(ParityData.lookup);
recipes.finalizeRecipeLoading(FeatureFlags.VANILLA_SET);
VanillaBlockUpdateOracle.Server.recipes = recipes;
List<RecipeHolder<BrewingRecipe>> brewing = recipes.getRecipes().stream()
.filter(h -> h.value() instanceof BrewingRecipe)
.map(h -> (RecipeHolder<BrewingRecipe>) h)
.toList();
var inputs = recipes.propertySet(RecipePropertySet.BREWING_INPUTS);
var reagents = recipes.propertySet(RecipePropertySet.BREWING_REAGENTS);
Path in = Path.of(args[0]), out = Path.of(args[1]);
int n = 0;
try (var w = new PrintWriter(Files.newBufferedWriter(out, StandardCharsets.UTF_8));
var lines = Files.newBufferedReader(in, StandardCharsets.UTF_8)) {
for (String line = lines.readLine(); line != null; line = lines.readLine()) {
if (line.startsWith("#")) continue;
String[] f = line.split(" ");
if (f[0].equals("tick")) {
w.println(tick(n, f, recipes));
n++;
continue;
}
ItemStack input = ParityData.stack(f[0]);
ItemStack reagent = ParityData.stack(f[1]);
ItemStack candidate = ParityData.stack(f[2]);
boolean potionSlot = !candidate.isEmpty() && PotionIngredient.isPotionInput(candidate, recipes);
boolean reagentSlot = !candidate.isEmpty() && reagents.test(candidate);
boolean fuelSlot = !candidate.isEmpty() && candidate.has(DataComponents.BREWING_FUEL);
var brewingInput = new BrewingInput(input, reagent);
Optional<RecipeHolder<BrewingRecipe>> found = brewing.stream()
.filter(h -> h.value().matches(brewingInput))
.findFirst();
String result = found.map(h -> ParityData.canon(h.value().assemble(brewingInput))).orElse("-");
String id = found.map(h -> h.id().identifier().toString()).orElse("-");
w.println(n + " " + potionSlot + " " + reagentSlot + " " + fuelSlot + " " + result + " " + id);
n++;
}
}
System.exit(0);
}
private static Field field(Class<?> c, String name) throws Exception {
Field f = c.getDeclaredField(name);
f.setAccessible(true);
return f;
}
private static void setInt(Object o, Class<?> c, String name, int v) throws Exception {
field(c, name).setInt(o, v);
}
/** One brewing-stand tick run: initial slots/fields, `ticks` serverTick calls, final state. */
private static String tick(int n, String[] f, RecipeManager recipes) throws Exception {
int ticks = Integer.parseInt(f[1]);
ItemStack[] slots = new ItemStack[5];
for (int i = 0; i < 5; i++) slots[i] = ParityData.stack(f[2 + i]);
int brewTime = Integer.parseInt(f[7]);
int fuel = Integer.parseInt(f[8]);
int totalBrew = Integer.parseInt(f[9]);
int totalFuel = Integer.parseInt(f[10]);
float speed = Float.parseFloat(f[11]);
String ingredient = f[12];
JsonObject caseJson = new JsonObject();
JsonArray cells = new JsonArray();
JsonArray cell = new JsonArray();
cell.add(0);
cell.add(0);
cell.add(0);
cell.add(Block.getId(Blocks.BREWING_STAND.defaultBlockState()));
cells.add(cell);
caseJson.add("cells", cells);
caseJson.addProperty("seed", 0);
VanillaBlockUpdateOracle.World w = VanillaBlockUpdateOracle.build(caseJson);
w.registries = ParityData.access;
var pos = new BlockPos(0, 0, 0);
BlockState state = w.getBlockState(pos);
var be = (BrewingStandBlockEntity) w.getBlockEntity(pos);
for (int i = 0; i < 5; i++) be.setItem(i, slots[i]);
setInt(be, BrewingStandBlockEntity.class, "brewTime", brewTime);
setInt(be, BrewingStandBlockEntity.class, "fuel", fuel);
setInt(be, BrewingStandBlockEntity.class, "totalBrewTime", totalBrew);
setInt(be, BrewingStandBlockEntity.class, "totalFuel", totalFuel);
field(BrewingStandBlockEntity.class, "speedMultiplier").setFloat(be, speed);
Item ingredientItem = ingredient.equals("-") ? null
: BuiltInRegistries.ITEM.getValue(Identifier.parse(ingredient));
field(BrewingStandBlockEntity.class, "ingredient").set(be, ingredientItem);
for (int t = 0; t < ticks; t++) {
BrewingStandBlockEntity.serverTick(w, pos, state, be);
}
int brews = 0;
for (int at = w.log.indexOf("L 1035 "); at >= 0; at = w.log.indexOf("L 1035 ", at + 1)) brews++;
StringBuilder sb = new StringBuilder(Integer.toString(n));
for (int i = 0; i < 5; i++) sb.append(" s").append(i).append('=').append(ParityData.canon(be.getItem(i)));
sb.append(" time=").append(field(BrewingStandBlockEntity.class, "brewTime").getInt(be))
.append(" fuel=").append(field(BrewingStandBlockEntity.class, "fuel").getInt(be))
.append(" total=").append(field(BrewingStandBlockEntity.class, "totalBrewTime").getInt(be))
.append(" totalfuel=").append(field(BrewingStandBlockEntity.class, "totalFuel").getInt(be))
.append(" speed=").append(String.format(Locale.ROOT, "%.3f",
field(BrewingStandBlockEntity.class, "speedMultiplier").getFloat(be)))
.append(" bits=");
for (int i = 0; i < 3; i++) sb.append(be.getItem(i).isEmpty() ? '0' : '1');
Item ing = (Item) field(BrewingStandBlockEntity.class, "ingredient").get(be);
sb.append(" ing=").append(ing == null ? "-" : BuiltInRegistries.ITEM.getKey(ing).toString())
.append(" brews=").append(brews);
return sb.toString();
}
}
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Compare the brewing recipes' slot rules, matching and assembled output with vanilla.
The Java oracle loads the vanilla data pack's real `RecipeManager`, takes its
`brewing_input` and `brewing_reagent` property sets and the `BrewingRecipe`s,
checks all three brewing-slot filters and calls the first matching recipe's
`assemble`. The Rust probe calls `brewing::Brewing::is_bottle`/`is_reagent`,
`brewing_fuel` and `Brewing::find`. Cases cover every bundled brewing recipe,
every potion type and potion id against every reagent, candidates from the
whole item registry, and non-matching pairs. Tick cases run
`BrewingStandBlockEntity.serverTick` and `BrewingStand::tick` for 0..8001
ticks: the 400-tick brew time divided by `speed_multiplier` and its ceiling,
blaze powder's 20 fuel uses, fuel loaded with nothing to brew, splash and
lingering outputs, ingredient shrink and mismatch, the brew sound, and the
bottle bits. `--no-tick` leaves the tick corpus out, and `--baseline` keeps
only the exact recipe pairs.
"""
import json
from pathlib import Path
import sys
try:
from . import lab, oracle_harness as harness
except ImportError:
import lab
import oracle_harness as harness
POTION_ITEMS = ['potion', 'splash_potion', 'lingering_potion']
def stack(item, potion=None, count=1):
if potion:
return '{id:"%s",count:%d,components:{"minecraft:potion_contents":{potion:"%s"}}}' % (
item, count, potion)
return '{id:"%s",count:%d}' % (item, count)
def recipes(resources):
out = []
for path in sorted((resources / 'data/minecraft/recipe/brewing').glob('*.json')):
data = json.loads(path.read_text())
if data.get('type') != 'minecraft:brewing':
continue
potion = data['input'].get('potion_contents', {}).get('potions')
out.append({
'input': stack(data['input']['item'], potion),
'reagent': stack(data['reagent']['item']),
'output': data['output'],
})
return out
def potion(kind='potion', name='water', count=1):
return '{id:"minecraft:%s",count:%d,components:{"minecraft:potion_contents":{potion:"minecraft:%s"}}}' % (
kind, count, name)
def tick_case(ticks, slots, brew_time=0, fuel=0, total=0, total_fuel=20, speed=1.0,
ingredient='-'):
filled = [slot if slot else '-' for slot in slots]
return 'tick %d %s %d %d %d %d %s %s' % (
ticks, ' '.join(filled), brew_time, fuel, total, total_fuel, speed, ingredient)
def tick_cases():
water = potion()
awkward = potion(name='awkward')
splash = potion('splash_potion')
lingering = potion('lingering_potion')
wart = stack('minecraft:nether_wart')
wart2 = stack('minecraft:nether_wart', count=2)
wart5 = stack('minecraft:nether_wart', count=5)
wart21 = stack('minecraft:nether_wart', count=21)
blaze = stack('minecraft:blaze_powder')
blaze2 = '{id:"minecraft:blaze_powder",count:2}'
redstone = stack('minecraft:redstone')
out = []
# Brew-time countdown, with fuel already in the tank.
for ticks in (0, 1, 2, 399, 400, 401, 402, 800):
out.append(tick_case(ticks, [water, None, None, wart, None], fuel=20,
total=400, ingredient='minecraft:nether_wart'))
# Blaze powder loads the tank (uses=20, speed=1), then the brew runs.
for ticks in (0, 1, 2, 399, 400, 401):
out.append(tick_case(ticks, [water, None, None, wart, blaze], total=0,
ingredient='minecraft:nether_wart'))
out.append(tick_case(1, [water, None, None, wart, blaze2], total=0,
ingredient='minecraft:nether_wart'))
# Fuel loads even with nothing to brew.
out.append(tick_case(1, [None, None, None, wart, blaze], total=0,
ingredient='minecraft:nether_wart'))
out.append(tick_case(1, [None, None, None, None, blaze], total=0))
# Brew time is ceil(400 / speed) and speed 0 falls back to 1.
for speed, finish in ((2.0, 200), (3.0, 134), (0.3, 1334), (0.0, 400)):
for ticks in (1, finish, finish + 1, finish + 2):
out.append(tick_case(ticks, [water, None, None, wart, None], fuel=5,
total=0, speed=speed, ingredient='minecraft:nether_wart'))
# Only the slots with a recipe change; the no-recipe bottle stays.
for ticks in (1, 400, 401):
out.append(tick_case(ticks, [water, awkward, water, wart, None], fuel=20,
total=400, ingredient='minecraft:nether_wart'))
# The ingredient stack shrinks; the last one leaves an empty slot.
for wart_stack, ticks in ((wart2, 400), (wart5, 400), (stack('minecraft:nether_wart'), 400)):
out.append(tick_case(ticks, [water, None, None, wart_stack, None], fuel=20,
total=400, ingredient='minecraft:nether_wart'))
# Changing the ingredient mid-brew clears the progress; finishing does not need fuel.
out.append(tick_case(1, [water, None, None, redstone, None], brew_time=200, fuel=5,
total=400, ingredient='minecraft:nether_wart'))
out.append(tick_case(1, [water, None, None, wart, None], brew_time=1, fuel=0,
total=400, ingredient='minecraft:nether_wart'))
out.append(tick_case(1, [water, None, None, None, None], brew_time=1, fuel=5,
total=400, ingredient='minecraft:nether_wart'))
# Splash and lingering potions keep their kind through the mix.
out.append(tick_case(401, [splash, None, None, wart, None], fuel=20, total=400,
ingredient='minecraft:nether_wart'))
out.append(tick_case(401, [lingering, None, None, wart, None], fuel=20, total=400,
ingredient='minecraft:nether_wart'))
# Twenty brews from one blaze powder's twenty uses; the 21st ingredient stays.
out.append(tick_case(8001, [water, None, None, wart21, blaze], total=0,
ingredient='minecraft:nether_wart'))
out.append(tick_case(8002, [water, None, None, wart21, blaze], total=0,
ingredient='minecraft:nether_wart'))
# Bottle bits are set from the slots on the first tick.
for slots in ([None, None, None], [water, None, None], [water, None, water],
[water, awkward, water]):
out.append(tick_case(1, slots + [None, None], fuel=20))
return out
def make_cases(build):
resources = lab.LOCAL / 'horizon-client' / '26.3'
catalog = json.loads((resources / 'items.json').read_text())
all_recipes = recipes(resources)
potions = sorted({p for r in all_recipes
for p in (r['output'].get('components', {})
.get('minecraft:potion_contents', {}).get('potion', ''),)
if p})
potion_ids = sorted({r['input'].split('"potion":"')[1].split('"')[0]
for r in all_recipes if '"potion":"' in r['input']})
reagents = sorted({r['reagent'] for r in all_recipes})
cases = []
# The baseline corpus: each bundled recipe's exact pair with each of the
# three slots probed. --baseline reproduces it without the cross-product.
for r in all_recipes:
for candidate in (r['input'], r['reagent'], stack('minecraft:blaze_powder'),
stack('minecraft:stick')):
cases.append(f"{r['input']} {r['reagent']} {candidate}")
if '--baseline' not in sys.argv:
# Every potion item and potion id against every reagent, plus a few
# mismatches and empty sides.
for item in POTION_ITEMS:
for potion in potion_ids + ['minecraft:water', 'minecraft:awkward']:
for reagent in reagents:
inp = stack('minecraft:' + item, potion)
cases.append(f"{inp} {reagent} {inp}")
cases.append(f"{stack('minecraft:' + item, potion)} {stack('minecraft:stick')} -")
cases.append(f"- {stack('minecraft:stick')} {stack('minecraft:' + item, potion)}")
# Every item as a filter candidate, so the slot rules are compared on
# the whole registry and not only on the recipe inputs.
for item in sorted(catalog):
if item != 'minecraft:air':
cases.append(f"- - {stack(item)}")
for potion in potion_ids:
for item in POTION_ITEMS:
cases.append(f"- - {stack('minecraft:' + item, potion)}")
if '--no-tick' not in sys.argv:
cases += tick_cases()
# Duplicate-free while keeping the first occurrence order.
seen = set()
unique = []
for case in cases:
if case not in seen:
seen.add(case)
unique.append(case)
path = build / 'cases.txt'
path.write_text(f'#resources {resources}\n' + ''.join(c + '\n' for c in unique))
return path, len(unique)
if __name__ == '__main__':
harness.differential(
'brewing-parity', 'VanillaBrewingOracle', 'brewing_fixture', make_cases,
'BrewingRecipe matching and assemble over the vanilla brewing data, the brewing_input and '
'brewing_reagent property sets and the potion, reagent and fuel slot filters, for every '
'bundled recipe, potion type/id and item candidate, plus the brewing-stand tick: brew time and '
'speed multiplier, fuel uses and loading, ingredient shrink, outputs and bottle bits. --baseline '
'leaves out the cross-product and whole-registry slot candidates; --no-tick leaves out the tick cases.',
sources=[lab.ROOT / 'crates/horizon-client-render/src/brewing.rs',
lab.ROOT / 'crates/horizon-items/src/item_data.rs',
lab.ROOT / 'crates/horizon-items/src/items.rs'])