Test event log¶
Every test appends an ordered log of typed events. The same log is written to JUnit <system-out> for each <testcase> and to the server console (last 20 lines on failure). The goal is plain: a CI failure must be diagnosable from the XML alone, without starting the client. For the workflow built on top of this log, see Debugging failed tests.
Set -Dhorizonqa.events=off on the server JVM to disable recording. Emit sites use Supplier; when recording is off those suppliers are not called and payload work is skipped. See JVM & system properties.
Format¶
Each line is [t=NNN] [category] summary. The tick is simulated machine ticks since test start, not wall-clock server ticks. A 200-tick recipe shows t=200 even though the warp completed in milliseconds.
[FAIL] mymod.assembler.basic - Expected 64× copper plate but found 32×
[t=0] [lifecycle] Test started at TestPos{x=256, y=5, z=256}
[t=0] [structure] MTEAssemblyLine formed (OBSERVED_ON_FIRST_POLL, 2ib/1ob/1eh)
[t=0] [resource] Inserted 64× Aluminium Plate
[t=0] [energy] EU supply: 480 EU/t × 1 A for 250t
[t=2] [recipe] Started (480 EU/t × 200t)
[t=202] [recipe] Finished (took 200t)
[t=202] [failure] Expected 64× copper plate but found 32×
Reading the trace: inputs, EU supply, and recipe completion all look correct; only the output count is wrong. That pattern almost always indicates a recipe-definition problem rather than bad test setup.
Where events come from¶
Three sources, in roughly the order they appear in a typical trace:
- Test API methods.
fillHatch,Bus.insert,supplyEU,assertMachineFormed, and similar helpers emit on success, at the call site. - The warp differ. During
Multiblock.runRecipeorfastForwardTicks(ticks, watched), the framework snapshots each watched controller after every simulated tick and diffs against the previous snapshot.mProgresstimegoing from 0 to positive becomesRecipeStarted; the controller TE vanishing becomesMachineExploded; and so on. - Test lifecycle.
TestStarted,TestFinished,AssertionFailed, andIsolationViolationcome from the runner itself.
The differ only watches controllers you register
fastForwardTicks(1000) with no watched list emits no recipe events for that warp. Multiblock.runRecipe registers its controller automatically; the imperative helpers expect you to pass a list explicitly.
Catalog¶
Records live in com.gtnewhorizons.horizonqa.api.event. All are Java records, all carry a tick and a one-line summary().
Lifecycle¶
TestStarted, TestFinished, StructurePlaced, WarpStarted, WarpFinished, BeforeBatchRan, AfterBatchRan.
BeforeBatchRan / AfterBatchRan carry the batch name and the hook method name, so a setup-order question can be answered from the trace.
WarpFinished.stopReason is one of:
completed- The warp ran for its full duration without a stop predicate intervening.
predicate- The warp's stop condition fired, typical for
runRecipeending when the machine returns to idle. timeout- The warp hit its
maxTickscap without the predicate triggering.
Structure¶
| Record | When it fires |
|---|---|
MachineFormed | assertFormed succeeds, or the differ sees mMachine flip to true |
MachineDeformed | mMachine flipped to false while the controller TE is still present |
MachineExploded | Controller TE is gone, or assertMachineExploded passes |
MachineDisabled | Active machine stops without finishing a recipe |
StructureCheckRan | A multiblock helper ran checkStructure(...) |
MachineFormed.cause distinguishes "template loaded already formed" from "assertion forced it" from "formed mid-warp":
OBSERVED_ON_FIRST_POLL- The controller already reported formed at the first poll. Usually the structure template loaded a pre-formed multi.
FORCED_BY_ASSERTIONassertFormedfoundmMachine == false, rancheckStructure(forceReset=true), and the machine then formed. AStructureCheckRanevent sits immediately before this one.FORMED_DURING_WARP- The controller flipped to formed mid-warp. Rare; usually a delayed block update.
Recipe¶
| Record | When it fires |
|---|---|
RecipeStarted | mProgresstime went from 0 to positive |
RecipeProgressed | Progress crossed 25 / 50 / 75 % |
RecipeFinished | Progress reached max and then dropped |
RecipeAborted | Progress dropped before reaching max |
RecipeNotFound | runUntilMachineIdle exited and no recipe ever started |
TestRecipeInjected / TestRecipeRemoved | withTestRecipe injection / end-of-test cleanup |
RecipeAborted.reason is the raw CheckRecipeResult id from GT (item_output_full, power_overflow, no_fuel_found, …). That id is usually enough on its own to identify the abort cause.
Resource¶
BusInserted (bus, itemDisplay, count), HatchFilled (hatch, fluidName, amountMb, accepted), ProgrammedCircuitSet (bus, config).
Energy¶
EUSupplyJobRegistered (hatch, voltage, amperage, durationTicks) fires whenever you call supplyEU or Hatch.supply. EUBufferOverflow fires when a supply job tries to push more EU than the hatch can hold; you rarely want to see this in a test log. HatchVoltageMismatch is emitted alongside an explosion when the last supply job exceeded the hatch tier.
Maintenance¶
MaintenanceIssueAppeared fires when the differ sees one of the six tool flags flip from OK to broken. issueType is the tool name: WRENCH, SCREWDRIVER, SOFT_MALLET, HARD_HAMMER, SOLDERING_TOOL, CROWBAR. MaintenanceFixed fires from fixAllMaintenanceIssues and Multiblock.fixMaintenance.
Diagnostic¶
PollutionEmitted (originChunk, amount, cumulative), CleanroomEfficiencyChanged (controller, efficiencyTenThousandths in 0-10000, i.e. 0.00 to 100.00 %). EventOverflow is emitted at most once per test when the 10 000-event cap is exceeded.
Failure¶
AssertionFailed (message, throwableType, failPos) and IsolationViolation (culprit, landedAtAbs, detail). Both also show up in the standard <failure> and <error> JUnit elements; the event-log copy gives each failure a tick position relative to the rest of the run.
Differ notes¶
The differ snapshots mProgresstime once per simulated tick. A handful of edge cases follow from that:
- A recipe that starts and completes within one tick may emit only
RecipeFinishedwith noRecipeStarted, because progress never appears non-zero between snapshots. Check the recipe duration if that looks wrong. RecipeFinishedvs.RecipeAborted: the last observed progress within one tick of max counts as finished; a drop earlier counts as aborted. Hitting max and then failing post-processing on the next tick yieldsRecipeFinishedfollowed by an explosion or deformation, notRecipeAborted.- Maintenance changes are bitmask-diffed. Multiple flags flipping in one tick produce multiple
MaintenanceIssueAppearedevents in fixed tool order (wrench, screwdriver, soft mallet, …). - The event clock advances inside the warp loop. Events from
runRecipeuse simulated ticks (e.g.t=50for a recipe that starts 50 ticks into a 200-tick warp), not the outer@GameTestwall tick. This is intentional.
Programmatic access¶
The log is reachable from inside a test, which is occasionally useful when an assertion is more naturally phrased over the event stream than over machine state:
@GameTest
public static void exactlyOneRecipeFinished(GameTestHelper helper) {
helper.gtnh().multiblock(at(1, 0, 0)).runRecipe();
long finished = helper.getRecorder().snapshot().stream()
.filter(RecipeFinished.class::isInstance)
.count();
helper.assertEquals(1L, finished);
helper.succeed();
}
snapshot() returns an unmodifiable list in emit order. Pattern-match on the concrete record type to read typed payload fields.
Cost¶
With -Dhorizonqa.events=off, emit sites are Supplier instances that are never invoked: no payload work and no allocations.
With recording enabled, each simulated tick during a warp performs one adapter read per watched controller: six int fields and a comparison. An event record is allocated only when the diff detects a change. Typical counts: about five events for a 200-tick recipe, about ten for a 20 000-tick fusion run.
The per-test cap is 10 000 events (EventOverflow is emitted at most once if you exceed it). Reaching the cap almost always means the test is emitting from a tight loop; file an issue if you have a genuine reason to go higher.
When GT fields change¶
All GT-internal reads go through GT5UnofficialAdapter. If GT5 renames mProgresstime, splits MTEMultiBlockBase, or reshapes CheckRecipeResult, exactly one file fails to compile. Event records do not import gregtech.* directly, so adapter churn does not cascade into the public API.