Advanced Debugging for Recipe Search
What is Explainer?
Section titled “What is Explainer?”Explainer is a diagnostic class that records what happened during a recipe search (available from 5.3.0 onwards).
While implementing recipes, you will run into the situation where a recipe should match but never appears in the results.
Because Search.search returns only the list of recipes that matched, the result tells you nothing about why the others did not — whether the recipe never became a candidate, whether an item type differed, or whether a predicate rejected it.
Passing an Explainer to a search method records each decision the search made, one line at a time, so you can read back afterwards exactly where things went wrong.
Basic usage
Section titled “Basic usage”Pass an instance to the explainer parameter of Search.search or Search.asyncSearch, then read the lines back with getStringList().
val explainer = Explainer(Explainer.Loglevel.DEBUG, "debug")
val result: Search.SearchResult = Search.search( crafterId = player.uniqueId, view = view, sourceRecipes = listOf(myRecipe), explainer = explainer)
explainer.getStringList().forEach { println(it) }The explainer parameter defaults to null, and no recording work is performed at all when it is omitted.
Once you are done debugging, simply drop the argument (or pass null) to return to the original behaviour.
Log levels
Section titled “Log levels”The first constructor parameter, verbosity, controls how much detail is recorded.
| Level | What gets recorded |
|---|---|
WARN |
Errors that nothing else catches. Used in very few places |
INFO (default) |
What happened — candidate filtering results, matched recipes, how the search finished |
DEBUG |
What the values were — the concrete values behind each individual decision |
verbosity acts as a threshold: only lines whose detail level is at or below it are recorded.
Specifying Loglevel.INFO records INFO and WARN while discarding DEBUG.
When tracking down a cause, specify DEBUG.
// record everything, down to the concrete valuesval explainer = Explainer(Explainer.Loglevel.DEBUG, "debug")
// omitting the first parameter gives you INFOval infoOnly = Explainer(name = "debug")The second parameter, name, is a label for telling instances apart. It defaults to a random UUID.
Reading the output
Section titled “Reading the output”Each line has the form [LEVEL] [function/check] message.
The function/check prefix identifies exactly which check in which routine produced the line.
Symptom 1: the recipe never becomes a candidate
Section titled “Symptom 1: the recipe never becomes a candidate”The most common case is a recipe that never even enters the search.
A CRecipe is only considered when the input item count falls within requiresInputItemAmountMin() to requiresInputItemAmountMax().
Here is the output for a recipe requiring two iron ingots when only one is placed.
[INFO] [search/candidateFilter] candidates filtered by input size: inputSize=1, sourceRecipes=1, candidates=0, shaped=0, shapeless=0[DEBUG] [search/candidateFilter] excluded: name=iron_plate, type=SHAPELESS, inputSize=1 not in requires=2..2[DEBUG] [VanillaSearch/search] Bukkit#getCraftingRecipe: world=World, found=true[INFO] [search] search finished: matchedCustoms=0, vanillaFound=truecandidates=0 shows there were no candidates, and the following excluded line gives the reason.
inputSize=1 not in requires=2..2 means “the input has one item, but this recipe requires two”.
Symptom 2: a shapeless slot cannot be filled
Section titled “Symptom 2: a shapeless slot cannot be filled”Matching a shapeless recipe (CRecipe.Type.SHAPELESS) is solved as the problem of assigning input items to recipe slots without reusing an input.
When the assignment fails, the log records which slot could not be filled and why.
Here is the output for a recipe requiring two iron ingots when one iron ingot and one dirt are placed.
[INFO] [search/candidateFilter] candidates filtered by input size: inputSize=2, sourceRecipes=1, candidates=1, shaped=0, shapeless=1[DEBUG] [search/candidateFilter] candidate(0): name=iron_plate, type=SHAPELESS, requires=2..2[DEBUG] [shapeless/flowCheck] recipe=iron_plate, achieved=1, requiredMin=2[DEBUG] [shapeless/flowCheck] recipe=iron_plate, group#1 unsatisfied: matched=0 < min=1, members=(1,0)[DEBUG] [shapeless/flowCheck] recipe=iron_plate, slot=(1,0) unmatched: 1 acceptable input(s) [(0,0)] all taken by other slots[DEBUG] [VanillaSearch/search] Bukkit#getCraftingRecipe: world=World, found=false[INFO] [search] search finished: matchedCustoms=0, vanillaFound=falseThis time the recipe did become a candidate (candidates=1), but only one of the two required assignments succeeded (achieved=1, requiredMin=2).
The last line gives the cause. The slot at (1,0) has one input that satisfies its conditions, but that input is at (0,0) and has already been taken by another slot.
In other words, you are one iron ingot short.
Three reasons can be reported for an unfilled slot.
| Output | Meaning |
|---|---|
no input passes candidate=[...] amount>=N |
No input satisfies the type or amount requirement at all |
N input(s) pass candidate/amount>=N but all rejected by matter predicates |
Type and amount passed, but every CMatterPredicate rejected them |
N acceptable input(s) [...] all taken by other slots |
Acceptable inputs exist, but they are already assigned to other slots |
Symptom 3: a predicate is rejecting the input
Section titled “Symptom 3: a predicate is rejecting the input”When a CMatterPredicate or CRecipePredicate is responsible, the log records which one rejected it.
[DEBUG] [shaped/recipePredicateCheck] recipe=reinforced_ingot, failed=predicate#1/2predicate#1/2 means “index 1 (the second) of the two predicates rejected the input”.
Naming a predicate makes it appear by name instead of by index — see Naming predicates below.
When the search succeeds
Section titled “When the search succeeds”On a match, the finalised coordinate mapping is recorded at INFO level.
[INFO] [search/candidateFilter] candidates filtered by input size: inputSize=2, sourceRecipes=1, candidates=1, shaped=0, shapeless=1[INFO] [shapeless] recipe matched: name=iron_plate, relation=(0,0)->(1,0), (1,0)->(0,0)[INFO] [search] search finished: matchedCustoms=1, vanillaFound=falserelation maps recipeCoordinate->inputCoordinate.
In the example above, the recipe’s (0,0) was assigned the input at (1,0), and the recipe’s (1,0) was assigned the input at (0,0).
Since shapeless recipes may reorder assignments, this is useful for confirming that the mapping is what you intended.
Naming predicates
Section titled “Naming predicates”A Kotlin lambda carries no identity of its own, so by default it can only be shown by index, as in predicate#1/2.
Wrapping it with CMatterPredicates.named or CRecipePredicates.named makes it appear by name.
val undamaged = CMatterPredicates.named("undamaged") { ctx -> ctx.input.amount >= 64}Here is the output when that predicate rejects the input.
[DEBUG] [shaped/matterPredicateCheck] recipe=reinforced_ingot, coordinate=(0,0), failed=undamaged (predicate#0/1)Predicates composed with combinators such as and have their names composed automatically as well.
val combined = CMatterPredicates.named("isIron") { /* ... */ } .and(CMatterPredicates.named("isFullStack") { /* ... */ })[DEBUG] [shaped/matterPredicateCheck] recipe=reinforced_ingot, coordinate=(0,0), failed=(isIron and isFullStack) (predicate#0/1)A composed predicate is treated as a single unit, so the log cannot tell you which of the inner predicates rejected the input. If you need that level of detail, list them as separate predicates instead of composing them.
For details on named and the combinators, see About CRecipePredicate.
Writing logs from inside a predicate
Section titled “Writing logs from inside a predicate”CMatterPredicate.Context and CRecipePredicate.Context both expose an explainer field (available from 5.3.0 onwards).
Use it to let your own predicate record why it rejected an input.
val fullStack = CMatterPredicate { ctx -> val ok = ctx.input.amount >= 64 ctx.explainer?.writeLog( Explainer.Loglevel.DEBUG, "full stack check: amount=${ctx.input.amount}, required=64, passed=$ok" ) ok}[DEBUG] full stack check: amount=1, required=64, passed=false[DEBUG] [shaped/matterPredicateCheck] recipe=reinforced_ingot, coordinate=(0,0), failed=predicate#0/1explainer is non-null only when one was passed to the search.
Always use ?. so that nothing is recorded when no explainer is in use.
Retrieving the records
Section titled “Retrieving the records”getLogs
Section titled “getLogs”Returns the Log objects in the order they were recorded.
Each has a level and a line field, which is useful when you want to format them yourself.
explainer.getLogs().forEach { log -> if (log.level == Explainer.Loglevel.WARN) { plugin.logger.warning(log.line) }}getStringList
Section titled “getStringList”Returns the records formatted as [LEVEL] message.
Passing levels as arguments narrows the result down to those levels.
// retrieve everythingval all: List<String> = explainer.getStringList()
// only INFO (when you just want the overall flow)val infoOnly: List<String> = explainer.getStringList(Explainer.Loglevel.INFO)
// multiple levels can be specifiedval some: List<String> = explainer.getStringList( Explainer.Loglevel.INFO, Explainer.Loglevel.WARN)Notes on asynchronous search
Section titled “Notes on asynchronous search”Explainer is thread-safe, and no lines are lost even when several worker threads from asyncSearch write to it simultaneously.
However, asyncSearch evaluates candidate recipes in parallel, so lines from different recipes are interleaved.
Every line contains recipe=, so filter on it when you want to follow a single recipe.
val future = Search.asyncSearch( crafterId = player.uniqueId, view = view, explainer = explainer)
future.thenAccept { explainer.getStringList() .filter { it.contains("recipe=my_recipe") } .forEach { println(it) }}The recording order faithfully reflects the actual execution order, so it can also be used to follow the order in which work proceeded.
Supported search methods
Section titled “Supported search methods”| Method | Supported |
|---|---|
Search.search |
Yes |
Search.asyncSearch |
Yes |
PartialSearch.asyncPartialSearch |
Yes |
VanillaSearch.search |
Yes |
Note that the standard CustomCrafterAPI crafting screen (CraftUI) provides no route for passing an Explainer.
The standard screen performs fixed work that does not vary with recipe contents, so a problem occurring there is a bug in the library rather than in your code.
To verify how a recipe behaves, call the search methods above directly.