Skip to content
Edit on GitHub

Author carried bulk matter and sample-based assays

verifiedAgainst 971c93c · verifiedOn 2026-09-10.

In typical voxel sandboxes, inventory treats all items as discrete, uniform tokens: 64 “Iron Ore” blocks or 16 “Crushed Malachite” items. In Voxamine, granular materials (ores, crushed concentrates, powders, metal ingots, and chemical solutions) are bulk matter (MASTERPLAN.md §30.4): they are carried as mass with composition, not as counts.

You will implement the bulk matter pipeline using MaterialBatch.cs, model conserved-by-construction operations (MergedWith and TrySplit), track deposit provenance via OriginId, enforce honest sample-based assays where destructive methods consume their sample and non-destructive methods return it via MaterialBatchAssay.cs, ensure atomic inventory transitions via CarriedOreAssay.TryRun, and charge carried bulk matter into chemical reaction vessels via MaterialBatchCharging.cs.

// Split a real 20g sample from a 100g carried ore batch:
if (CarriedOreAssay.TryRun(
playerInventory, malachiteSubstanceId, AssayMethod.FireAssay,
sampleGrams: 20d, sampleId: 101UL,
out AssayReading reading, out MaterialBatch remainingBatch, out string error))
{
// Destructive fire assay consumes the 20g sample:
// remainingBatch.TotalMassGrams -> 80g
// reading.MeasuredGrade01 -> 0.30 ± 0.03
// Proportional sampling ensures remaining grade is unchanged!
}

Bulk matter bridges spatial mining and harvesting, player carrying capacity, instrument inspection, and industrial metallurgy:

  • Inventory Core (MaterialBatch & MaterialComponent) provides the immutable representation of mass and chemical composition with built-in atom conservation.
  • Analytical Layer (MaterialBatchAssay & MaterialBatchNarrator) isolates physical samples, executes instrument measurements, and formats qualitative or quantitative readouts without revealing unmeasured chemistry.
  • Player Interaction (CarriedOreAssay) guarantees that the inventory never temporarily owns two versions of the same material during an analytical test.
  • Vessel Charging (MaterialBatchCharging) preflights vessel capacity and converts phase-agnostic mass (grams) into discrete chemical moles within a VesselCharge.
flowchart TD
  subgraph MINING["World & Harvesters"]
    MINED["Mined Block / Harvested Plant\n(Measured Mass & Substance Ratios)"]
    BATCH["MaterialBatch\n(Immutable: SubstanceId -> MassGrams, OriginId)"]
  end

  subgraph INVENTORY["Player Carrying Layer"]
    PLAYER["PlayerInventory.BulkMatter\n(Carried matter as mass, not count)"]
    MERGE["MaterialBatch.MergedWith\n(Sums masses; keeps OriginId if identical)"]
    SPLIT["MaterialBatch.TrySplit\n(Proportional split: preserves mass fractions)"]
  end

  subgraph ASSAY["Analytical Assay Engine"]
    RUN["CarriedOreAssay.TryRun\n(Single-owner inventory transaction)"]
    ASSAY_METH["MaterialBatchAssay.TryAssay\n(Takes sampleGrams, seeds scatter)"]
    CONSUME{"Method Consumes\nSample?"}
    FIRE["FireAssay: Sample Consumed\nRemaining = Parent - Sample"]
    RETURN["Density / Hand: Sample Returned\nRemaining = Remainder + Sample"]
  end

  subgraph INDUSTRY["Vessel Reaction System"]
    CHARGE["MaterialBatchCharging.TryChargeIntoVessel\n(Preflights capacity, converts g -> moles)"]
    VESSEL["VesselCharge\n(Thermodynamic chemical reaction solver)"]
  end

  MINED --> BATCH
  BATCH --> PLAYER
  PLAYER --> MERGE
  PLAYER --> SPLIT
  PLAYER --> RUN
  RUN --> ASSAY_METH
  ASSAY_METH --> CONSUME
  CONSUME -->|Yes (Smelted)| FIRE
  CONSUME -->|No (Displaced)| RETURN
  FIRE --> PLAYER
  RETURN --> PLAYER
  PLAYER --> CHARGE
  CHARGE --> VESSEL

Read Docs/MASTERPLAN.md §30.4 (“Bulk matter”), §30.5 (“Assays and evidence”), and §36.4 (“Mined-block to vessel charging”), then inspect:

Review the governing principles of bulk matter:

Principle Rule Implementation
Mixtures stay mixtures Combining two granular materials never spawns a new item name. It creates a single batch with summed component masses. MaterialBatch.MergedWith
Proportional splitting Splitting a sample off a batch preserves exact mass fractions: sampling cannot enrich or impoverish the parent. MaterialBatch.TrySplit
Provenance tracking A batch tracks where it came from via OriginId. Merging two batches from the same vein preserves origin; mixing different veins resets origin to 0. OriginId = (a.OriginId == b.OriginId) ? a.OriginId : 0UL
No hidden omniscience An unassayed batch exposes total mass to the UI, but never percentages or chemical identities. MaterialBatchNarrator.Describe
Sample destruction A fire assay destroys the sample it measures; Archimedes density testing returns it to the batch. MaterialBatchAssay.MethodConsumesSample

In MaterialBatch.cs, components are stored sorted by SubstanceId with strictly positive mass ($m > 0$):

public sealed class MaterialBatch
{
private readonly MaterialComponent[] components;
public double TotalMassGrams { get; }
public ulong OriginId { get; }
public int ComponentCount => components.Length;
public double MassOf(SubstanceId substanceId)
{
int index = BinarySearch(substanceId);
return index >= 0 ? components[index].MassGrams : 0d;
}
public double MassFractionOf(SubstanceId substanceId)
{
return TotalMassGrams > 0d ? MassOf(substanceId) / TotalMassGrams : 0d;
}
}

2. Conserved-by-Construction Merge and Split

Section titled “2. Conserved-by-Construction Merge and Split”

Merging two batches sums component masses and evaluates provenance:

public MaterialBatch MergedWith(MaterialBatch other)
{
if (other == null || other.IsEmpty) return this;
if (IsEmpty) return other;
// Sum masses across sorted arrays (linear merge):
var mergedComponents = MergeSortedComponents(this.components, other.components);
double totalMass = this.TotalMassGrams + other.TotalMassGrams;
ulong origin = (this.OriginId == other.OriginId) ? this.OriginId : 0UL;
return new MaterialBatch(mergedComponents, totalMass, origin);
}

Splitting divides each component strictly proportionally:

public bool TrySplit(double splitMassGrams, out MaterialBatch split, out MaterialBatch remainder, out string error)
{
if (splitMassGrams <= 0d || splitMassGrams >= TotalMassGrams)
{
error = "Split mass must be strictly between 0 and total batch mass.";
return false;
}
double ratio = splitMassGrams / TotalMassGrams;
var splitParts = new MaterialComponent[components.Length];
var remParts = new MaterialComponent[components.Length];
for (int i = 0; i < components.Length; i++)
{
double partMass = components[i].MassGrams * ratio;
splitParts[i] = new MaterialComponent(components[i].SubstanceId, partMass);
remParts[i] = new MaterialComponent(components[i].SubstanceId, components[i].MassGrams - partMass);
}
split = new MaterialBatch(splitParts, splitMassGrams, OriginId);
remainder = new MaterialBatch(remParts, TotalMassGrams - splitMassGrams, OriginId);
return true;
}

Because every component mass scales by ratio and 1 - ratio, total mass and individual atomic counts are conserved exactly.

In MaterialBatchAssay.TryAssay, the assay takes an actual sample from the parent batch:

public static bool TryAssay(
MaterialBatch batch, SubstanceId targetSubstanceId, AssayMethod method,
double sampleGrams, ulong sampleId,
out AssayReading reading, out MaterialBatch remainingBatch, out string error)
{
if (!batch.TrySplit(sampleGrams, out MaterialBatch sample, out MaterialBatch remainder, out error))
{
return false;
}
float trueFraction = (float)sample.MassFractionOf(targetSubstanceId);
reading = OreAssayMath.Assay(trueFraction, method, sampleId);
// Destructive methods discard the sample; non-destructive ones re-merge it:
remainingBatch = MethodConsumesSample(method) ? remainder : remainder.MergedWith(sample);
return true;
}

In CarriedOreAssay.TryRun, player inventory is updated only after all fallible operations succeed:

public static bool TryRun(
PlayerInventory inventory, SubstanceId targetSubstanceId, AssayMethod method,
double sampleGrams, ulong sampleId,
out AssayReading reading, out MaterialBatch assayedBatch, out string error)
{
MaterialBatch before = inventory.BulkMatter;
if (!MaterialBatchAssay.TryAssay(before, targetSubstanceId, method, sampleGrams, sampleId,
out reading, out MaterialBatch after, out error))
{
return false;
}
// Atomic commit: one removal followed by insertion:
inventory.RemoveBulk(before);
inventory.AddBulk(after);
assayedBatch = inventory.BulkMatter;
return true;
}

In MaterialBatchCharging.TryChargeIntoVessel, convert grams into moles using the vessel’s SubstanceTable:

$$n_i = \frac{m_i}{M_i}$$

for (int index = 0; index < batch.ComponentCount; index++)
{
MaterialComponent component = batch.ComponentAt(index);
ThermodynamicPhaseDefinition def = substances.Get(component.SubstanceId, phase);
double moles = component.MassGrams / def.MolarMassGramsPerMol;
molesToAdd[index] = moles;
if (!charge.Contains(component.SubstanceId, phase))
{
newKeys.Add(component.SubstanceId.Value);
}
}
// Preflight: ensure charge.KeyCount + newKeys.Count <= charge.Capacity
if (charge.KeyCount + newKeys.Count > charge.Capacity)
{
error = "Vessel does not have enough capacity for all batch components.";
return false;
}
// Apply atomically:
for (int i = 0; i < batch.ComponentCount; i++)
{
charge.AddMoles(batch.ComponentAt(i).SubstanceId, phase, molesToAdd[i]);
}

Run the focused Inventory and UI test fixtures:

Terminal window
/opt/unity/Editor/Unity -batchmode -nographics \
-projectPath /home/soulwax/workspace/engines/unity/minecraft/Minecraft-HD \
-runTests -testPlatform EditMode \
-testFilter VoxelSandbox.Tests.MaterialBatchTests
Terminal window
/opt/unity/Editor/Unity -batchmode -nographics \
-projectPath /home/soulwax/workspace/engines/unity/minecraft/Minecraft-HD \
-runTests -testPlatform EditMode \
-testFilter VoxelSandbox.Tests.CarriedOreAssayTests

Confirm that:

  • TryCreate_TotalsTheComponentMassesAndReportsFractions passes with exact floating-point tolerances.
  • MergedWith_SumsEverySubstanceAndConservesTotalMass verifies mass and atom conservation.
  • HandSpecimen_ReturnsTheSampleAndOnlyThenMakesAQualitativeGradeLegible confirms non-destructive sample recovery.
  • ConsumingMethod_ChangesOnlyTheSampleMassAndKeepsTheRemainder confirms destructive sample loss without enriching or impoverishing the remaining ore.

To implement a new bulk matter flow (e.g. Crushed Quartz Sand or Kiln Wood Ash):

  1. Author Substance Components: Ensure the mineral or chemical species exist in SubstanceCatalog.asset.
  2. Instantiate via MaterialBatch.TryCreate: Supply sorted component masses and valid originId.
  3. Verify Conserved Transitions: Route bulk drops through MaterialBatchDropRuntime when dropped in the world.
  4. Wire Vessel Charging: Use MaterialBatchCharging.TryChargeIntoVessel to load bulk matter into crucibles or leaching vats.
  5. Add Regression Tests: Assert mass conservation across merge/split cycles in MaterialBatchTests.cs.
Symptom Cause Fix
Granular materials turn into integer stack counts (e.g. “64 Sand”). Granular matter was routed through ItemStack instead of MaterialBatch. Use MaterialBatch for granular matter, powders, solutions, and bulk ores. Reserve ItemStack strictly for countable tools and apparatus.
Ore grade increases or decreases after splitting a sample. Split logic divided some components unequally. Always scale every component by the identical mass ratio $r = m_{\text{split}} / m_{\text{total}}$ as implemented in MaterialBatch.TrySplit.
Unassayed ore in inventory shows exact chemical percentages in the HUD. UI accessed batch.MassFractionOf() directly without an AssayReading. Never expose raw mass fractions to the player. Format descriptions through MaterialBatchNarrator.Describe(batch, reading).
Vessel charge partially applies before failing due to capacity limits. Components were loaded one by one into VesselCharge without preflighting. Use MaterialBatchCharging.TryChargeIntoVessel; it counts distinct new keys and validates total vessel capacity before applying any moles.
Merged batch retains an incorrect origin ID after mixing two different deposits. Merge logic blindly copied a.OriginId. Enforce origin validation: origin = (a.OriginId == b.OriginId) ? a.OriginId : 0UL.

User-contributed notes

Corrections, clarifications, and practical tips for this page. Anonymous is fine — a name is optional. Basic Markdown works: **bold**, *italic*, `code`, and links.

Notes policy

Notes are lightly filtered for spam and may be edited or removed. Keep them about this page — no support requests, no personal data, nothing you would not publish. Links are limited and marked nofollow.

  1. Loading notes…