mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
847210b1e7 |
No files matched your search
Generated
+7
@@ -1510,6 +1510,13 @@ dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mods-test-machine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-port-pack"
|
||||
version = "0.1.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-shaderpack", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-shaderpack", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/machine", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! The Horizon side of a code mod's machine harness (tools/mod_machine_harness.py,
|
||||
//! docs/research/code-mods-plan.md): runs a ported mod's block entity through
|
||||
//! horizon-mods on Horizon's own item, fuel and cooking tables, and writes its
|
||||
//! slots, block state and saved fields after every tick.
|
||||
//!
|
||||
//! cargo run -p horizon-client --example mod_machine_probe -- PREPARED MOD.hmod SCENARIOS.json OUT.json
|
||||
//!
|
||||
//! SCENARIOS.json is the list the NeoForge oracle replays on the Java mod:
|
||||
//! `[{"name", "block", "ticks", "slots": {"0": ["minecraft:raw_iron", 8]},
|
||||
//! "properties": {"lit": ["false", ["false", "true"]]}, "fields": ["CookTime"],
|
||||
//! "events": [{"tick": 40, "slot": 1, "stack": ["minecraft:coal", 1]}]}]`.
|
||||
//! Fields are read from the mod's save when it is a JSON object.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
assets::Assets,
|
||||
blocks::BlockFacts,
|
||||
containers::Providers,
|
||||
crafting::Recipes,
|
||||
items::{ItemStack, Items},
|
||||
};
|
||||
use horizon_mods::{
|
||||
BlockEntityView, CookingKind, CookingRecipe, ItemFacts, Mods, Property, Stack, Status,
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::{fs, path::Path, sync::Arc};
|
||||
|
||||
struct Facts {
|
||||
items: Items,
|
||||
recipes: Recipes,
|
||||
providers: Providers,
|
||||
}
|
||||
impl ItemFacts for Facts {
|
||||
fn max_stack_size(&self, id: &str) -> u32 {
|
||||
self.items.get(id).map_or(0, |i| i.max_stack)
|
||||
}
|
||||
fn burn_time(&self, id: &str) -> u32 {
|
||||
let stack = ItemStack::new(id, 1);
|
||||
self.providers.burn_time(&self.items, &stack, false).max(0) as u32
|
||||
}
|
||||
fn remainder(&self, id: &str) -> Option<String> {
|
||||
self.items.get(id)?.crafting_remainder.clone()
|
||||
}
|
||||
fn cooking_recipe(&self, kind: CookingKind, input: &str) -> Option<CookingRecipe> {
|
||||
let kind = match kind {
|
||||
CookingKind::Smelting => "minecraft:smelting",
|
||||
CookingKind::Blasting => "minecraft:blasting",
|
||||
CookingKind::Smoking => "minecraft:smoking",
|
||||
CookingKind::Campfire => "minecraft:campfire_cooking",
|
||||
};
|
||||
let c = self.recipes.cook(kind, &ItemStack::new(input, 1))?;
|
||||
Some(CookingRecipe {
|
||||
output: Stack {
|
||||
id: c.result.item.to_string(),
|
||||
count: c.result.count,
|
||||
},
|
||||
cooking_time: c.time,
|
||||
experience: c.experience,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn stack(v: &Value) -> Option<Stack> {
|
||||
Some(Stack {
|
||||
id: v[0].as_str()?.to_owned(),
|
||||
count: v[1].as_u64()? as u32,
|
||||
})
|
||||
}
|
||||
|
||||
fn run(mods: &mut Mods, scenario: &Value) -> Result<Value> {
|
||||
let block = scenario["block"].as_str().ok_or("scenario without block")?;
|
||||
let (handle, slots) = mods
|
||||
.create_block_entity(block, &[])
|
||||
.ok_or_else(|| format!("no running mod has a block entity for {block}"))?;
|
||||
let mut view = BlockEntityView {
|
||||
handle,
|
||||
slots: vec![None; slots as usize],
|
||||
..Default::default()
|
||||
};
|
||||
for (slot, s) in scenario["slots"].as_object().into_iter().flatten() {
|
||||
view.slots[slot.parse::<usize>()?] = stack(s);
|
||||
}
|
||||
for (name, p) in scenario["properties"].as_object().into_iter().flatten() {
|
||||
view.properties.push(Property {
|
||||
name: name.clone(),
|
||||
value: p[0].as_str().unwrap_or_default().to_owned(),
|
||||
values: p[1]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| Some(v.as_str()?.to_owned()))
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
let fields: Vec<&str> = scenario["fields"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.collect();
|
||||
let mut trace = Vec::new();
|
||||
for tick in 0..scenario["ticks"].as_u64().unwrap_or(0) {
|
||||
for e in scenario["events"].as_array().into_iter().flatten() {
|
||||
if e["tick"].as_u64() == Some(tick) {
|
||||
view.slots[e["slot"].as_u64().unwrap_or(0) as usize] = stack(&e["stack"]);
|
||||
}
|
||||
}
|
||||
let mut views = [view];
|
||||
mods.tick_block_entities(&mut views);
|
||||
[view] = views;
|
||||
let saved = mods.save_block_entity(handle).unwrap_or_default();
|
||||
let saved: Value = serde_json::from_slice(&saved).unwrap_or(Value::Null);
|
||||
let slots: Map<String, Value> = view
|
||||
.slots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, s)| Some((i.to_string(), json!([s.as_ref()?.id, s.as_ref()?.count]))))
|
||||
.collect();
|
||||
let properties: Map<String, Value> = view
|
||||
.properties
|
||||
.iter()
|
||||
.map(|p| (p.name.clone(), json!(p.value)))
|
||||
.collect();
|
||||
let values: Map<String, Value> = fields
|
||||
.iter()
|
||||
.map(|f| (f.to_string(), saved[f].clone()))
|
||||
.collect();
|
||||
trace.push(json!({"slots": slots, "properties": properties, "fields": values}));
|
||||
}
|
||||
mods.remove_block_entity(handle);
|
||||
Ok(json!({"name": scenario["name"], "trace": trace}))
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let [root, hmod, scenarios, out] = args.as_slice() else {
|
||||
return Err("usage: mod_machine_probe PREPARED MOD.hmod SCENARIOS.json OUT.json".into());
|
||||
};
|
||||
let root = Path::new(root);
|
||||
let assets = Assets::load(root)?;
|
||||
let facts = BlockFacts::load(root, assets.registry)?;
|
||||
let items = Items::load(root, assets.registry, &facts)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let providers = Providers::load(root);
|
||||
let mut mods = Mods::load(&[hmod.into()], |_| true, |_| false);
|
||||
if let Status::Failed(why) = &mods.entries()[0].status {
|
||||
return Err(format!("{hmod}: {why}").into());
|
||||
}
|
||||
mods.set_item_facts(Arc::new(Facts {
|
||||
items,
|
||||
recipes,
|
||||
providers,
|
||||
}));
|
||||
let scenarios: Value = serde_json::from_slice(&fs::read(scenarios)?)?;
|
||||
let mut results = Vec::new();
|
||||
for scenario in scenarios.as_array().into_iter().flatten() {
|
||||
results.push(run(&mut mods, scenario)?);
|
||||
}
|
||||
fs::write(out, serde_json::to_vec_pretty(&results)?)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -21,18 +21,26 @@
|
||||
//! ```
|
||||
//!
|
||||
//! Every interface but `log` is a capability: list the ones the mod uses in
|
||||
//! `horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`).
|
||||
//! `horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`, and
|
||||
//! `block-entities` for [`machine`] with `api = "0.4"`).
|
||||
//!
|
||||
//! Block entities (API 0.4): a mod whose blocks are machines returns their
|
||||
//! types from [`Mod::block_entity_types`], keeps each one's state by its
|
||||
//! handle, and reads and writes its slots and block state through [`machine`]
|
||||
//! during [`Mod::tick_block_entities`]. The game owns the slots (hoppers,
|
||||
//! menus, drops and saves see them); the mod saves the rest.
|
||||
|
||||
wit_bindgen::generate!({
|
||||
path: "wit",
|
||||
world: "horizon-mod",
|
||||
world: "horizon-machine-mod",
|
||||
pub_export_macro: true,
|
||||
export_macro_name: "__export_horizon_mod",
|
||||
default_bindings_module: "horizon_mod_sdk",
|
||||
});
|
||||
|
||||
pub use exports::horizon::mods::block_entities::{BlockEntityType, Guest as __BlockEntitiesGuest};
|
||||
pub use exports::horizon::mods::events::{Guest as __Guest, Registration};
|
||||
pub use horizon::mods::{chat, game, hud, log, storage};
|
||||
pub use horizon::mods::{chat, game, hud, log, machine, storage};
|
||||
|
||||
/// The game's calls into a mod. Only the hooks [`Registration`] asks for
|
||||
/// are called; the others may stay as their defaults.
|
||||
@@ -51,6 +59,29 @@ pub trait Mod: Sized + 'static {
|
||||
}
|
||||
/// A chat line the player received, when [`Registration::chat`] is set.
|
||||
fn chat(&mut self, _text: &str) {}
|
||||
|
||||
/// The mod's block entity types, once after [`Mod::init`]: each for one
|
||||
/// of its own blocks. Needs capability `block-entities`.
|
||||
fn block_entity_types(&mut self) -> Vec<BlockEntityType> {
|
||||
Vec::new()
|
||||
}
|
||||
/// A block entity was placed (`saved` empty) or loaded (`saved` is what
|
||||
/// [`Mod::save_block_entity`] returned).
|
||||
fn create_block_entity(&mut self, _handle: u32, _block: &str, _saved: &[u8]) {}
|
||||
/// One server tick of every loaded ticking block entity of the mod; its
|
||||
/// slots and block state are readable and writable through [`machine`]
|
||||
/// during this call only.
|
||||
fn tick_block_entities(&mut self, _handles: &[u32]) {}
|
||||
/// Its state for the save, beside the slots the game saves.
|
||||
fn save_block_entity(&mut self, _handle: u32) -> Vec<u8> {
|
||||
Vec::new()
|
||||
}
|
||||
/// Broken or unloaded.
|
||||
fn remove_block_entity(&mut self, _handle: u32) {}
|
||||
/// The values its menu shows (progress bars, fuel left).
|
||||
fn block_entity_data(&mut self, _handle: u32) -> Vec<i32> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Registration {
|
||||
@@ -135,6 +166,32 @@ macro_rules! export_mod {
|
||||
with(|m| $crate::Mod::chat(m, &text))
|
||||
}
|
||||
}
|
||||
impl $crate::__BlockEntitiesGuest for Export {
|
||||
fn types() -> Vec<$crate::BlockEntityType> {
|
||||
let mut out = Vec::new();
|
||||
with(|m| out = $crate::Mod::block_entity_types(m));
|
||||
out
|
||||
}
|
||||
fn create(handle: u32, block: String, saved: Vec<u8>) {
|
||||
with(|m| $crate::Mod::create_block_entity(m, handle, &block, &saved))
|
||||
}
|
||||
fn tick(handles: Vec<u32>) {
|
||||
with(|m| $crate::Mod::tick_block_entities(m, &handles))
|
||||
}
|
||||
fn save(handle: u32) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
with(|m| out = $crate::Mod::save_block_entity(m, handle));
|
||||
out
|
||||
}
|
||||
fn remove(handle: u32) {
|
||||
with(|m| $crate::Mod::remove_block_entity(m, handle))
|
||||
}
|
||||
fn data(handle: u32) -> Vec<i32> {
|
||||
let mut out = Vec::new();
|
||||
with(|m| out = $crate::Mod::block_entity_data(m, handle));
|
||||
out
|
||||
}
|
||||
}
|
||||
$crate::__export_horizon_mod!(Export);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -107,3 +107,89 @@ world horizon-mod {
|
||||
import storage;
|
||||
export events;
|
||||
}
|
||||
|
||||
/// Capability `block-entities` (API 0.4): what the code of a mod's block
|
||||
/// entities may read and change while the game ticks them. A handle names one
|
||||
/// block entity and is valid only during the `block-entities` call it was
|
||||
/// passed to; slots and block state belong to the game (hoppers, menus, drops
|
||||
/// and saves see them), the mod keeps the rest of its state itself.
|
||||
interface machine {
|
||||
/// An item and how many. Item components are not carried yet.
|
||||
record item-stack {
|
||||
/// `minecraft:coal`, `<mod>:<path>`, ...
|
||||
id: string,
|
||||
count: u32,
|
||||
}
|
||||
enum cooking-kind {
|
||||
smelting,
|
||||
blasting,
|
||||
smoking,
|
||||
campfire,
|
||||
}
|
||||
record cooking-recipe {
|
||||
output: item-stack,
|
||||
/// Ticks the recipe takes in its vanilla block.
|
||||
cooking-time: u32,
|
||||
experience: f32,
|
||||
}
|
||||
/// The block entity's slot, none when empty or out of range.
|
||||
get-slot: func(handle: u32, slot: u32) -> option<item-stack>;
|
||||
/// Empties the slot with none; a stack over the item's maximum is cut to it.
|
||||
/// False for a slot out of range, an unknown item or a handle not in this call.
|
||||
set-slot: func(handle: u32, slot: u32, stack: option<item-stack>) -> bool;
|
||||
/// A property of the block entity's block state (`lit` -> `true`).
|
||||
get-property: func(handle: u32, name: string) -> option<string>;
|
||||
/// False when the block has no such property or value.
|
||||
set-property: func(handle: u32, name: string, value: string) -> bool;
|
||||
/// The most of `id` one slot holds; 0 for an unknown item.
|
||||
max-stack-size: func(id: string) -> u32;
|
||||
/// Ticks `id` burns as a furnace's fuel (the game's fuel values), 0 if it
|
||||
/// is no fuel.
|
||||
burn-time: func(id: string) -> u32;
|
||||
/// What is left of `id` once used up as fuel or an ingredient (a bucket
|
||||
/// from a lava bucket).
|
||||
remainder: func(id: string) -> option<string>;
|
||||
/// The data pack's cooking recipe of `kind` for `input`.
|
||||
cooking: func(kind: cooking-kind, input: string) -> option<cooking-recipe>;
|
||||
}
|
||||
|
||||
/// What the game calls on a mod's block entities (API 0.4). Only for the
|
||||
/// blocks `types` names, which must be the mod's own.
|
||||
interface block-entities {
|
||||
record block-entity-type {
|
||||
/// The mod's block, `<mod>:<path>`.
|
||||
block: string,
|
||||
/// Slots the game keeps for each of its block entities.
|
||||
slots: u32,
|
||||
/// Call `tick` every server tick while it is loaded.
|
||||
ticks: bool,
|
||||
}
|
||||
/// Once, after `init`.
|
||||
types: func() -> list<block-entity-type>;
|
||||
/// A block entity was placed or loaded: `saved` is what `save` returned
|
||||
/// for it, empty for a new one.
|
||||
create: func(handle: u32, block: string, saved: list<u8>);
|
||||
/// One server tick of every loaded ticking block entity of this mod.
|
||||
tick: func(handles: list<u32>);
|
||||
/// Its state for the save, beside the slots the game saves itself.
|
||||
save: func(handle: u32) -> list<u8>;
|
||||
/// Broken or unloaded: forget it.
|
||||
remove: func(handle: u32);
|
||||
/// The values its menu shows (vanilla's `ContainerData`: progress
|
||||
/// bars, fuel left).
|
||||
data: func(handle: u32) -> list<s32>;
|
||||
}
|
||||
|
||||
/// A mod with block entities: the SDK builds every mod against this world.
|
||||
/// Mods built for `horizon-mod` alone (no `block-entities` export) still load.
|
||||
world horizon-machine-mod {
|
||||
include horizon-mod;
|
||||
import machine;
|
||||
export block-entities;
|
||||
}
|
||||
|
||||
/// The game's side of `horizon-machine-mod` beyond `horizon-mod`.
|
||||
world machine-host {
|
||||
import machine;
|
||||
export block-entities;
|
||||
}
|
||||
@@ -12,7 +12,9 @@ pub const API: &str = "0.1";
|
||||
/// The manifest `api` lines this build loads. 0.2 is 0.1 plus content:
|
||||
/// `assets/` and `data/` in the `.hmod`, with blocks and items declared in
|
||||
/// `data/<id>/horizon/` (docs/modpacks.md). Code still links API 0.1.
|
||||
pub const APIS: [&str; 2] = [API, "0.2"];
|
||||
/// 0.4 is 0.2 plus block entities: capability `block-entities` and the
|
||||
/// `block-entities` export (docs/mods.md).
|
||||
pub const APIS: [&str; 3] = [API, "0.2", "0.4"];
|
||||
/// Namespaces no mod may claim: vanilla's, the convention tags' (mods add
|
||||
/// to those, docs/mods.md) and the game's own.
|
||||
const RESERVED_NAMESPACES: [&str; 3] = ["minecraft", "c", "horizon"];
|
||||
@@ -30,7 +32,8 @@ pub struct Manifest {
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
/// The API line the mod was built for: `0.1`, or `0.2` for content.
|
||||
/// The API line the mod was built for: `0.1`, `0.2` for content, `0.4`
|
||||
/// for block entities.
|
||||
pub api: String,
|
||||
#[serde(default)]
|
||||
pub side: Side,
|
||||
@@ -68,14 +71,18 @@ pub enum Capability {
|
||||
/// New blocks and items from the mod's `data/` (API 0.2): no WIT
|
||||
/// interface, the game reads them before the world loads.
|
||||
Registries,
|
||||
/// Block entities for the mod's own blocks (API 0.4): the `machine`
|
||||
/// interface and the `block-entities` export.
|
||||
BlockEntities,
|
||||
}
|
||||
impl Capability {
|
||||
pub const ALL: [Capability; 5] = [
|
||||
pub const ALL: [Capability; 6] = [
|
||||
Capability::GameRead,
|
||||
Capability::Chat,
|
||||
Capability::Hud,
|
||||
Capability::Storage,
|
||||
Capability::Registries,
|
||||
Capability::BlockEntities,
|
||||
];
|
||||
/// The WIT interface it grants, in `horizon:mods`.
|
||||
pub fn interface(self) -> &'static str {
|
||||
@@ -85,6 +92,7 @@ impl Capability {
|
||||
Capability::Hud => "hud",
|
||||
Capability::Storage => "storage",
|
||||
Capability::Registries => "registries",
|
||||
Capability::BlockEntities => "machine",
|
||||
}
|
||||
}
|
||||
/// What the player reads before turning the mod on: "May <this>".
|
||||
@@ -95,6 +103,7 @@ impl Capability {
|
||||
Capability::Hud => "draw text on your screen",
|
||||
Capability::Storage => "save its own settings to disk",
|
||||
Capability::Registries => "add blocks and items to your worlds",
|
||||
Capability::BlockEntities => "run its blocks' machines in your worlds",
|
||||
}
|
||||
}
|
||||
/// The capability a component import needs, `Ok(None)` for one every
|
||||
@@ -125,6 +134,7 @@ impl fmt::Display for Capability {
|
||||
Capability::Hud => "hud",
|
||||
Capability::Storage => "storage",
|
||||
Capability::Registries => "registries",
|
||||
Capability::BlockEntities => "block-entities",
|
||||
};
|
||||
f.write_str(name)
|
||||
}
|
||||
@@ -155,6 +165,11 @@ impl Manifest {
|
||||
"{MANIFEST}: capability registries needs api = \"0.2\""
|
||||
));
|
||||
}
|
||||
if m.capabilities.contains(&Capability::BlockEntities) && m.api != "0.4" {
|
||||
return Err(format!(
|
||||
"{MANIFEST}: capability block-entities needs api = \"0.4\""
|
||||
));
|
||||
}
|
||||
if !m.namespaces.is_empty() && !m.capabilities.contains(&Capability::Registries) {
|
||||
return Err(format!(
|
||||
"{MANIFEST}: namespaces are for content, which needs capability registries"
|
||||
@@ -382,6 +397,20 @@ capabilities = ["game-read", "hud"]
|
||||
assert!(Manifest::parse(&code).unwrap_err().contains("registries"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_entities_need_api_0_4() {
|
||||
let base = "id = \"ironfurnaces\"\nname = \"I\"\nversion = \"1\"\nside = \"both\"\n";
|
||||
let caps = "capabilities = [\"registries\", \"block-entities\"]\n";
|
||||
let m = Manifest::parse(&format!("{base}api = \"0.4\"\n{caps}")).unwrap();
|
||||
assert_eq!(m.check_imports(["horizon:mods/[email protected]"]), Ok(()));
|
||||
let old = Manifest::parse(&format!("{base}api = \"0.2\"\n{caps}")).unwrap_err();
|
||||
assert!(old.contains("needs api = \"0.4\""), "{old}");
|
||||
let without = "capabilities = [\"registries\"]\n";
|
||||
let m = Manifest::parse(&format!("{base}api = \"0.4\"\n{without}")).unwrap();
|
||||
let e = m.check_imports(["horizon:mods/[email protected]"]).unwrap_err();
|
||||
assert!(e.contains("block-entities"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmod_round_trips() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mods-{}", std::process::id()));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Loading and calling mods with wasmtime's component model.
|
||||
use crate::manifest::{Manifest, Package, Side};
|
||||
use crate::manifest::{Capability, Manifest, Package, Side};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant, SystemTime},
|
||||
@@ -16,6 +17,15 @@ mod bindings {
|
||||
world: "horizon-mod",
|
||||
});
|
||||
}
|
||||
/// API 0.4's block entities: a second world, so mods built before it (no
|
||||
/// `block-entities` export) still instantiate as `horizon-mod`.
|
||||
mod machine_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
path: "../horizon-mod-sdk/wit",
|
||||
world: "machine-host",
|
||||
additional_derives: [PartialEq],
|
||||
});
|
||||
}
|
||||
pub use bindings::horizon::mods::{
|
||||
game::{Clock, Player},
|
||||
hud::{Corner, Line},
|
||||
@@ -25,6 +35,11 @@ use bindings::{
|
||||
exports::horizon::mods::events::Registration,
|
||||
horizon::mods::{chat, game, hud, log as mod_log, storage},
|
||||
};
|
||||
use machine_bindings::{MachineHost, horizon::mods::machine};
|
||||
pub use machine_bindings::{
|
||||
exports::horizon::mods::block_entities::BlockEntityType,
|
||||
horizon::mods::machine::{CookingKind, CookingRecipe, ItemStack as Stack},
|
||||
};
|
||||
|
||||
/// A mod's linear memory cap.
|
||||
const MEMORY: usize = 64 << 20;
|
||||
@@ -53,6 +68,43 @@ const STORAGE_WRITE_BUDGET: u64 = 1 << 20;
|
||||
/// never has a manifest there, so `find` never mistakes it for a mod).
|
||||
const STORAGE_DIR: &str = ".horizon-mod-data";
|
||||
|
||||
/// The export a block-entity mod adds (`horizon:mods/block-entities`).
|
||||
const BLOCK_ENTITIES_EXPORT: &str = "horizon:mods/[email protected]";
|
||||
/// Slots a mod's block entity may ask the game to keep.
|
||||
const MAX_SLOTS: u32 = 256;
|
||||
|
||||
/// The game's item and recipe facts that block entities query (`machine`):
|
||||
/// set once with [`Mods::set_item_facts`] after the data packs load.
|
||||
pub trait ItemFacts: Send + Sync {
|
||||
/// 0 for an unknown item.
|
||||
fn max_stack_size(&self, id: &str) -> u32;
|
||||
/// Ticks the item burns as furnace fuel, 0 if it is no fuel.
|
||||
fn burn_time(&self, id: &str) -> u32;
|
||||
/// What is left once the item is used up (a bucket from a lava bucket).
|
||||
fn remainder(&self, id: &str) -> Option<String>;
|
||||
fn cooking_recipe(&self, kind: CookingKind, input: &str) -> Option<CookingRecipe>;
|
||||
}
|
||||
|
||||
/// A block state property of a block entity's block, as `machine` sees it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Property {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
/// The values the block allows for it; `set-property` refuses others.
|
||||
pub values: Vec<String>,
|
||||
}
|
||||
|
||||
/// What the game lends a mod's block entity during a tick: its slots and its
|
||||
/// block's properties. `changed` is set when the mod wrote either, so the
|
||||
/// game saves the chunk and updates the block and open menus.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct BlockEntityView {
|
||||
pub handle: u32,
|
||||
pub slots: Vec<Option<Stack>>,
|
||||
pub properties: Vec<Property>,
|
||||
pub changed: bool,
|
||||
}
|
||||
|
||||
/// What mods may read of the game, copied once per tick by the game.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Snapshot {
|
||||
@@ -101,8 +153,16 @@ pub struct Entry {
|
||||
|
||||
struct Running {
|
||||
store: Store<State>,
|
||||
exports: HorizonMod,
|
||||
exports: Exports,
|
||||
hooks: Registration,
|
||||
/// Its block entity types (API 0.4), checked against its namespaces.
|
||||
types: Vec<BlockEntityType>,
|
||||
}
|
||||
|
||||
struct Exports {
|
||||
events: HorizonMod,
|
||||
/// Present when the component exports `block-entities`.
|
||||
machine: Option<MachineHost>,
|
||||
}
|
||||
|
||||
struct State {
|
||||
@@ -119,6 +179,10 @@ struct State {
|
||||
/// needs its own budget.
|
||||
storage_budget: u64,
|
||||
limits: StoreLimits,
|
||||
/// Capability `block-entities`: the game's facts, and the block entities
|
||||
/// lent to the current `tick` call (none outside it).
|
||||
facts: Option<Arc<dyn ItemFacts>>,
|
||||
views: Vec<BlockEntityView>,
|
||||
}
|
||||
|
||||
/// The installed mods and the engine running them. `Mods::default()` is
|
||||
@@ -129,6 +193,13 @@ pub struct Mods {
|
||||
entries: Vec<Entry>,
|
||||
snapshot: Arc<Snapshot>,
|
||||
out: Vec<Output>,
|
||||
facts: Option<Arc<dyn ItemFacts>>,
|
||||
/// Each live block entity's handle: the entry of the mod that owns it.
|
||||
handles: HashMap<u32, usize>,
|
||||
next_handle: u32,
|
||||
/// The block entities a mod held when it stopped mid-call, saved from
|
||||
/// its dropped store so the game gets their slots back.
|
||||
stranded: Vec<BlockEntityView>,
|
||||
}
|
||||
|
||||
/// The `.hmod` files and mod folders in `dir`, by name.
|
||||
@@ -204,6 +275,8 @@ impl Mods {
|
||||
let mut linker = Linker::new(&engine);
|
||||
HorizonMod::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
|
||||
.map_err(|e| e.to_string())?;
|
||||
MachineHost::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.engine = Some((engine, linker));
|
||||
}
|
||||
Ok(self.engine.as_ref().expect("made above"))
|
||||
@@ -242,6 +315,7 @@ impl Mods {
|
||||
.flat_map(|r| r.hooks.commands.iter().cloned())
|
||||
.collect();
|
||||
let snapshot = self.snapshot.clone();
|
||||
let facts = self.facts.clone();
|
||||
let (engine, linker) = self.engine()?;
|
||||
let component =
|
||||
Component::new(engine, wasm).map_err(|e| format!("not a component: {e}"))?;
|
||||
@@ -251,6 +325,9 @@ impl Mods {
|
||||
.map(|(name, _)| name.to_string())
|
||||
.collect();
|
||||
m.check_imports(imports.iter().map(String::as_str))?;
|
||||
let has_machine = ty
|
||||
.exports(engine)
|
||||
.any(|(name, _)| name == BLOCK_ENTITIES_EXPORT);
|
||||
let data_dir = path
|
||||
.parent()
|
||||
.unwrap_or(Path::new("."))
|
||||
@@ -268,16 +345,38 @@ impl Mods {
|
||||
.memory_size(MEMORY)
|
||||
.instances(16)
|
||||
.build(),
|
||||
facts,
|
||||
views: Vec::new(),
|
||||
};
|
||||
let mut store = Store::new(engine, state);
|
||||
store.limiter(|s| &mut s.limits);
|
||||
store.set_fuel(FUEL_INIT).map_err(|e| e.to_string())?;
|
||||
let exports =
|
||||
HorizonMod::instantiate(&mut store, &component, linker).map_err(|e| reason(&e))?;
|
||||
let mut hooks = exports
|
||||
let instance = linker
|
||||
.instantiate(&mut store, &component)
|
||||
.map_err(|e| reason(&e))?;
|
||||
let events = HorizonMod::new(&mut store, &instance).map_err(|e| reason(&e))?;
|
||||
let machine = match has_machine {
|
||||
true => Some(MachineHost::new(&mut store, &instance).map_err(|e| reason(&e))?),
|
||||
false => None,
|
||||
};
|
||||
let mut hooks = events
|
||||
.horizon_mods_events()
|
||||
.call_init(&mut store)
|
||||
.map_err(|e| reason(&e))??;
|
||||
let mut types = Vec::new();
|
||||
if let Some(machine) = &machine {
|
||||
let declared = machine
|
||||
.horizon_mods_block_entities()
|
||||
.call_types(&mut store)
|
||||
.map_err(|e| reason(&e))?;
|
||||
if !declared.is_empty() && !m.capabilities.contains(&Capability::BlockEntities) {
|
||||
return Err(format!(
|
||||
"has block entities but not capability block-entities in {}",
|
||||
crate::manifest::MANIFEST
|
||||
));
|
||||
}
|
||||
types = check_types(m, declared)?;
|
||||
}
|
||||
hooks.commands.retain(|c| {
|
||||
let free = !reserved(c) && !taken.contains(c);
|
||||
if !free {
|
||||
@@ -288,8 +387,9 @@ impl Mods {
|
||||
self.out.append(&mut store.data_mut().out);
|
||||
Ok(Some(Running {
|
||||
store,
|
||||
exports,
|
||||
exports: Exports { events, machine },
|
||||
hooks,
|
||||
types,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -310,7 +410,7 @@ impl Mods {
|
||||
&mut self,
|
||||
fuel: u64,
|
||||
wants: impl Fn(&Registration) -> bool,
|
||||
mut f: impl FnMut(&HorizonMod, &mut Store<State>) -> wasmtime::Result<()>,
|
||||
mut f: impl FnMut(&Exports, &mut Store<State>) -> wasmtime::Result<()>,
|
||||
) {
|
||||
for i in 0..self.entries.len() {
|
||||
if self.entries[i]
|
||||
@@ -328,12 +428,13 @@ impl Mods {
|
||||
&mut self,
|
||||
i: usize,
|
||||
fuel: u64,
|
||||
f: impl FnOnce(&HorizonMod, &mut Store<State>) -> wasmtime::Result<R>,
|
||||
f: impl FnOnce(&Exports, &mut Store<State>) -> wasmtime::Result<R>,
|
||||
) -> Option<R> {
|
||||
let entry = &mut self.entries[i];
|
||||
let run = entry.run.as_mut()?;
|
||||
run.store.data_mut().snapshot = self.snapshot.clone();
|
||||
run.store.data_mut().storage_budget = STORAGE_WRITE_BUDGET;
|
||||
run.store.data_mut().facts = self.facts.clone();
|
||||
let started = Instant::now();
|
||||
let result = run
|
||||
.store
|
||||
@@ -353,6 +454,7 @@ impl Mods {
|
||||
reason: reason.clone(),
|
||||
});
|
||||
entry.status = Status::Failed(reason);
|
||||
self.stranded = std::mem::take(&mut run.store.data_mut().views);
|
||||
entry.run = None;
|
||||
None
|
||||
}
|
||||
@@ -364,7 +466,7 @@ impl Mods {
|
||||
self.each(
|
||||
FUEL_TICK,
|
||||
|h| h.tick,
|
||||
|m, s| m.horizon_mods_events().call_tick(s, tick),
|
||||
|m, s| m.events.horizon_mods_events().call_tick(s, tick),
|
||||
);
|
||||
}
|
||||
/// A rendered frame, `seconds` after the last.
|
||||
@@ -372,7 +474,7 @@ impl Mods {
|
||||
self.each(
|
||||
FUEL_FRAME,
|
||||
|h| h.frame,
|
||||
|m, s| m.horizon_mods_events().call_frame(s, seconds),
|
||||
|m, s| m.events.horizon_mods_events().call_frame(s, seconds),
|
||||
);
|
||||
}
|
||||
/// A chat line the player received, as plain text.
|
||||
@@ -380,7 +482,7 @@ impl Mods {
|
||||
self.each(
|
||||
FUEL_EVENT,
|
||||
|h| h.chat,
|
||||
|m, s| m.horizon_mods_events().call_chat(s, text),
|
||||
|m, s| m.events.horizon_mods_events().call_chat(s, text),
|
||||
);
|
||||
}
|
||||
/// Whether a running mod owns `/name`.
|
||||
@@ -402,7 +504,7 @@ impl Mods {
|
||||
return false;
|
||||
};
|
||||
let result = self.call(i, FUEL_EVENT, |m, s| {
|
||||
m.horizon_mods_events().call_command(s, name, args)
|
||||
m.events.horizon_mods_events().call_command(s, name, args)
|
||||
});
|
||||
match result {
|
||||
Some(Ok(Some(text))) => self.out.push(Output::Feedback { text, error: false }),
|
||||
@@ -430,6 +532,117 @@ impl Mods {
|
||||
std::mem::take(&mut self.out)
|
||||
}
|
||||
|
||||
/// The game's item and recipe facts, for capability `block-entities`.
|
||||
pub fn set_item_facts(&mut self, facts: Arc<dyn ItemFacts>) {
|
||||
self.facts = Some(facts);
|
||||
}
|
||||
|
||||
/// The block entity types of the running mods (API 0.4).
|
||||
pub fn block_entity_types(&self) -> impl Iterator<Item = &BlockEntityType> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter_map(|e| e.run.as_ref())
|
||||
.flat_map(|r| r.types.iter())
|
||||
}
|
||||
|
||||
/// The running mod whose block entity type is `block`'s, and the type.
|
||||
fn block_entity_type(&self, block: &str) -> Option<(usize, &BlockEntityType)> {
|
||||
self.entries.iter().enumerate().find_map(|(i, e)| {
|
||||
let t = e.run.as_ref()?.types.iter().find(|t| t.block == block)?;
|
||||
Some((i, t))
|
||||
})
|
||||
}
|
||||
|
||||
/// A block entity for `block`, placed (`saved` empty) or loaded (what
|
||||
/// [`Mods::save_block_entity`] returned): its handle, and the slots the
|
||||
/// game must keep for it. None when no running mod has `block`, or the
|
||||
/// mod stopped.
|
||||
pub fn create_block_entity(&mut self, block: &str, saved: &[u8]) -> Option<(u32, u32)> {
|
||||
let (i, ty) = self.block_entity_type(block)?;
|
||||
let slots = ty.slots;
|
||||
let handle = self.next_handle;
|
||||
self.next_handle = self.next_handle.wrapping_add(1);
|
||||
self.call(i, FUEL_EVENT, |m, s| {
|
||||
machine_of(m)?
|
||||
.horizon_mods_block_entities()
|
||||
.call_create(s, handle, block, saved)
|
||||
})?;
|
||||
self.handles.insert(handle, i);
|
||||
Some((handle, slots))
|
||||
}
|
||||
|
||||
/// One server tick of the ticking block entities in `views`, one call
|
||||
/// per mod. Each mod sees only its own; their slots and properties come
|
||||
/// back changed. Views of unknown handles or stopped mods are left alone.
|
||||
pub fn tick_block_entities(&mut self, views: &mut [BlockEntityView]) {
|
||||
for i in 0..self.entries.len() {
|
||||
let Some(run) = self.entries[i].run.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let ticking = |v: &BlockEntityView| self.handles.get(&v.handle) == Some(&i);
|
||||
if !run.types.iter().any(|t| t.ticks) || !views.iter().any(ticking) {
|
||||
continue;
|
||||
}
|
||||
let mine: Vec<usize> = (0..views.len()).filter(|&v| ticking(&views[v])).collect();
|
||||
let lent: Vec<BlockEntityView> = mine
|
||||
.iter()
|
||||
.map(|&v| std::mem::take(&mut views[v]))
|
||||
.collect();
|
||||
let handles: Vec<u32> = lent.iter().map(|v| v.handle).collect();
|
||||
if let Some(run) = self.entries[i].run.as_mut() {
|
||||
run.store.data_mut().views = lent;
|
||||
}
|
||||
self.call(i, FUEL_TICK, |m, s| {
|
||||
machine_of(m)?
|
||||
.horizon_mods_block_entities()
|
||||
.call_tick(s, &handles)
|
||||
});
|
||||
// A mod that stopped in the call left its views in `stranded`,
|
||||
// with what it wrote before it stopped.
|
||||
let returned = match self.entries[i].run.as_mut() {
|
||||
Some(run) => std::mem::take(&mut run.store.data_mut().views),
|
||||
None => std::mem::take(&mut self.stranded),
|
||||
};
|
||||
for (slot, view) in mine.into_iter().zip(returned) {
|
||||
views[slot] = view;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the game saves for the block entity beside its slots.
|
||||
pub fn save_block_entity(&mut self, handle: u32) -> Option<Vec<u8>> {
|
||||
let i = *self.handles.get(&handle)?;
|
||||
self.call(i, FUEL_EVENT, |m, s| {
|
||||
machine_of(m)?
|
||||
.horizon_mods_block_entities()
|
||||
.call_save(s, handle)
|
||||
})
|
||||
}
|
||||
|
||||
/// The values the block entity's menu shows (progress, fuel).
|
||||
pub fn block_entity_data(&mut self, handle: u32) -> Vec<i32> {
|
||||
let Some(&i) = self.handles.get(&handle) else {
|
||||
return Vec::new();
|
||||
};
|
||||
self.call(i, FUEL_EVENT, |m, s| {
|
||||
machine_of(m)?
|
||||
.horizon_mods_block_entities()
|
||||
.call_data(s, handle)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The block entity was broken or unloaded.
|
||||
pub fn remove_block_entity(&mut self, handle: u32) {
|
||||
if let Some(i) = self.handles.remove(&handle) {
|
||||
self.call(i, FUEL_EVENT, |m, s| {
|
||||
machine_of(m)?
|
||||
.horizon_mods_block_entities()
|
||||
.call_remove(s, handle)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Restarts the mods whose file changed since they loaded (mod
|
||||
/// development: rebuild, and the game picks it up). True when any did.
|
||||
pub fn reload_changed(&mut self, reserved: impl Fn(&str) -> bool) -> bool {
|
||||
@@ -444,6 +657,9 @@ impl Mods {
|
||||
any = true;
|
||||
self.entries[i].modified = now;
|
||||
self.entries[i].run = None;
|
||||
// The new instance knows none of the old one's block entities:
|
||||
// the game creates them again from their saves.
|
||||
self.handles.retain(|_, owner| *owner != i);
|
||||
let path = self.entries[i].path.clone();
|
||||
let started = Package::read(&path).and_then(|p| {
|
||||
self.entries[i].manifest = Some(p.manifest.clone());
|
||||
@@ -467,6 +683,38 @@ impl Mods {
|
||||
}
|
||||
}
|
||||
|
||||
/// A mod's `block-entities` export; an error (the call fails, the mod goes
|
||||
/// on) for a component without one, which then has no types to call it for.
|
||||
fn machine_of(m: &Exports) -> wasmtime::Result<&MachineHost> {
|
||||
m.machine
|
||||
.as_ref()
|
||||
.ok_or_else(|| wasmtime::Error::msg("no block-entities export"))
|
||||
}
|
||||
|
||||
/// The block entity types a mod declared, if every one is for a block in its
|
||||
/// own namespaces, declared once, with at most [`MAX_SLOTS`] slots.
|
||||
fn check_types(m: &Manifest, types: Vec<BlockEntityType>) -> Result<Vec<BlockEntityType>, String> {
|
||||
for (n, t) in types.iter().enumerate() {
|
||||
let owned = t
|
||||
.block
|
||||
.split_once(':')
|
||||
.is_some_and(|(ns, path)| !path.is_empty() && m.owned_namespaces().any(|o| o == ns));
|
||||
if !owned {
|
||||
return Err(format!(
|
||||
"declares a block entity for {}, which is not one of its blocks",
|
||||
t.block
|
||||
));
|
||||
}
|
||||
if types[..n].iter().any(|o| o.block == t.block) {
|
||||
return Err(format!("declares {} twice", t.block));
|
||||
}
|
||||
if t.slots > MAX_SLOTS {
|
||||
return Err(format!("{} asks for over {MAX_SLOTS} slots", t.block));
|
||||
}
|
||||
}
|
||||
Ok(types)
|
||||
}
|
||||
|
||||
fn modified(path: &Path) -> Option<SystemTime> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
if meta.is_dir() {
|
||||
@@ -603,3 +851,83 @@ fn storage_key_ok(key: &str) -> bool {
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_.-".contains(&b));
|
||||
chars_ok && key != "." && key != ".."
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// The block entity `handle` lent to this call.
|
||||
fn view(&mut self, handle: u32) -> Option<&mut BlockEntityView> {
|
||||
self.views.iter_mut().find(|v| v.handle == handle)
|
||||
}
|
||||
}
|
||||
impl machine::Host for State {
|
||||
fn get_slot(&mut self, handle: u32, slot: u32) -> Option<Stack> {
|
||||
self.view(handle)?
|
||||
.slots
|
||||
.get(slot as usize)
|
||||
.cloned()
|
||||
.flatten()
|
||||
}
|
||||
fn set_slot(&mut self, handle: u32, slot: u32, stack: Option<Stack>) -> bool {
|
||||
let stack = match stack {
|
||||
Some(s) if s.count > 0 => {
|
||||
// Unknown items are refused; with no facts set (a test host)
|
||||
// nothing is known about items, so any id passes uncut.
|
||||
let max = match &self.facts {
|
||||
Some(facts) => match facts.max_stack_size(&s.id) {
|
||||
0 => return false,
|
||||
max => max,
|
||||
},
|
||||
None => u32::MAX,
|
||||
};
|
||||
Some(Stack {
|
||||
count: s.count.min(max),
|
||||
id: s.id,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let Some(view) = self.view(handle) else {
|
||||
return false;
|
||||
};
|
||||
let Some(cell) = view.slots.get_mut(slot as usize) else {
|
||||
return false;
|
||||
};
|
||||
if *cell != stack {
|
||||
*cell = stack;
|
||||
view.changed = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
fn get_property(&mut self, handle: u32, name: String) -> Option<String> {
|
||||
let view = self.view(handle)?;
|
||||
let p = view.properties.iter().find(|p| p.name == name)?;
|
||||
Some(p.value.clone())
|
||||
}
|
||||
fn set_property(&mut self, handle: u32, name: String, value: String) -> bool {
|
||||
let Some(view) = self.view(handle) else {
|
||||
return false;
|
||||
};
|
||||
let Some(p) = view.properties.iter_mut().find(|p| p.name == name) else {
|
||||
return false;
|
||||
};
|
||||
if !p.values.contains(&value) {
|
||||
return false;
|
||||
}
|
||||
if p.value != value {
|
||||
p.value = value;
|
||||
view.changed = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
fn max_stack_size(&mut self, id: String) -> u32 {
|
||||
self.facts.as_ref().map_or(0, |f| f.max_stack_size(&id))
|
||||
}
|
||||
fn burn_time(&mut self, id: String) -> u32 {
|
||||
self.facts.as_ref().map_or(0, |f| f.burn_time(&id))
|
||||
}
|
||||
fn remainder(&mut self, id: String) -> Option<String> {
|
||||
self.facts.as_ref()?.remainder(&id)
|
||||
}
|
||||
fn cooking(&mut self, kind: CookingKind, input: String) -> Option<CookingRecipe> {
|
||||
self.facts.as_ref()?.cooking_recipe(kind, &input)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "horizon-mods-test-machine"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "A mod with a block entity, for horizon-mods' block-entity tests"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = { path = "../../../horizon-mod-sdk" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,93 @@
|
||||
//! A block entity for the runtime's tests (API 0.4): `test_machine:smelter`
|
||||
//! has an input and an output slot and smelts one input item every recipe's
|
||||
//! cooking time, `lit` while it does. Its progress is saved and shown as the
|
||||
//! menu's one data value; TNT in its input makes it panic (after lighting
|
||||
//! up). `test_machine:crate` holds 4 slots and never ticks.
|
||||
use horizon_mod_sdk::{
|
||||
BlockEntityType, Mod, Registration,
|
||||
machine::{self, CookingKind, ItemStack},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
struct Machines {
|
||||
/// Each smelter's ticks of cooking so far.
|
||||
progress: HashMap<u32, u32>,
|
||||
}
|
||||
|
||||
impl Mod for Machines {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
let progress = HashMap::new();
|
||||
Ok((Machines { progress }, Registration::new()))
|
||||
}
|
||||
fn block_entity_types(&mut self) -> Vec<BlockEntityType> {
|
||||
vec![
|
||||
BlockEntityType {
|
||||
block: "test_machine:smelter".into(),
|
||||
slots: 2,
|
||||
ticks: true,
|
||||
},
|
||||
BlockEntityType {
|
||||
block: "test_machine:crate".into(),
|
||||
slots: 4,
|
||||
ticks: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
fn create_block_entity(&mut self, handle: u32, block: &str, saved: &[u8]) {
|
||||
if block == "test_machine:smelter" {
|
||||
let progress = saved.try_into().map_or(0, u32::from_le_bytes);
|
||||
self.progress.insert(handle, progress);
|
||||
}
|
||||
}
|
||||
fn tick_block_entities(&mut self, handles: &[u32]) {
|
||||
for &h in handles {
|
||||
let progress = self.progress.entry(h).or_default();
|
||||
if machine::get_slot(h, 0).is_some_and(|s| s.id == "minecraft:tnt") {
|
||||
machine::set_property(h, "lit", "true");
|
||||
panic!("tnt in the smelter");
|
||||
}
|
||||
let recipe = machine::get_slot(h, 0)
|
||||
.and_then(|input| machine::cooking(CookingKind::Smelting, &input.id));
|
||||
let Some(recipe) = recipe else {
|
||||
*progress = 0;
|
||||
machine::set_property(h, "lit", "false");
|
||||
continue;
|
||||
};
|
||||
machine::set_property(h, "lit", "true");
|
||||
*progress += 1;
|
||||
if *progress < recipe.cooking_time {
|
||||
continue;
|
||||
}
|
||||
*progress = 0;
|
||||
let out = match machine::get_slot(h, 1) {
|
||||
Some(o) if o.id == recipe.output.id => ItemStack {
|
||||
count: o.count + recipe.output.count,
|
||||
..o
|
||||
},
|
||||
Some(_) => continue,
|
||||
None => recipe.output,
|
||||
};
|
||||
if !machine::set_slot(h, 1, Some(&out)) {
|
||||
continue;
|
||||
}
|
||||
let input = machine::get_slot(h, 0).filter(|i| i.count > 1);
|
||||
let rest = input.map(|i| ItemStack {
|
||||
count: i.count - 1,
|
||||
..i
|
||||
});
|
||||
machine::set_slot(h, 0, rest.as_ref());
|
||||
}
|
||||
}
|
||||
fn save_block_entity(&mut self, handle: u32) -> Vec<u8> {
|
||||
let progress = self.progress.get(&handle).copied().unwrap_or(0);
|
||||
progress.to_le_bytes().to_vec()
|
||||
}
|
||||
fn remove_block_entity(&mut self, handle: u32) {
|
||||
self.progress.remove(&handle);
|
||||
}
|
||||
fn block_entity_data(&mut self, handle: u32) -> Vec<i32> {
|
||||
vec![self.progress.get(&handle).copied().unwrap_or(0) as i32]
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(Machines);
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Loads real mods built with the SDK: the coords-hud example and a guest
|
||||
//! that misbehaves on command. Needs `rustup target add wasm32-unknown-unknown`.
|
||||
use horizon_mods::{
|
||||
Clock, Corner, Mods, Output, Package, Player, Snapshot, Status, manifest::componentize,
|
||||
BlockEntityView, Clock, CookingKind, CookingRecipe, Corner, ItemFacts, Mods, Output, Package,
|
||||
Player, Property, Snapshot, Stack, Status, manifest::componentize,
|
||||
};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
@@ -18,7 +19,16 @@ api = "0.1"
|
||||
capabilities = ["chat", "storage"]
|
||||
"#;
|
||||
|
||||
/// Builds both mods once and writes their `.hmod` files; returns the folder.
|
||||
const MACHINE_MANIFEST: &str = r#"
|
||||
id = "test_machine"
|
||||
name = "Test Machine"
|
||||
version = "0.1.0"
|
||||
api = "0.4"
|
||||
side = "both"
|
||||
capabilities = ["block-entities"]
|
||||
"#;
|
||||
|
||||
/// Builds the mods once and writes their `.hmod` files; returns the folder.
|
||||
fn mods_dir() -> &'static Path {
|
||||
static DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
@@ -32,6 +42,8 @@ fn mods_dir() -> &'static Path {
|
||||
"coords-hud",
|
||||
"-p",
|
||||
"horizon-mods-test-guest",
|
||||
"-p",
|
||||
"horizon-mods-test-machine",
|
||||
"--target-dir",
|
||||
])
|
||||
.arg(&target)
|
||||
@@ -48,6 +60,7 @@ fn mods_dir() -> &'static Path {
|
||||
let mods = [
|
||||
("coords_hud", std::fs::read_to_string(example).unwrap()),
|
||||
("horizon_mods_test_guest", GUEST_MANIFEST.to_string()),
|
||||
("horizon_mods_test_machine", MACHINE_MANIFEST.to_string()),
|
||||
];
|
||||
for (name, manifest) in mods {
|
||||
let core = std::fs::read(built.join(format!("{name}.wasm"))).unwrap();
|
||||
@@ -396,3 +409,177 @@ capabilities = ["registries"]
|
||||
// Content has no hooks: the game's per-tick mod work stays skipped.
|
||||
assert!(!mods.is_running());
|
||||
}
|
||||
|
||||
/// Raw iron smelts into iron ingots in 3 ticks; `test:ghost` is a recipe
|
||||
/// output no item table knows.
|
||||
struct Facts;
|
||||
impl ItemFacts for Facts {
|
||||
fn max_stack_size(&self, id: &str) -> u32 {
|
||||
if id == "test:ghost" { 0 } else { 64 }
|
||||
}
|
||||
fn burn_time(&self, _id: &str) -> u32 {
|
||||
0
|
||||
}
|
||||
fn remainder(&self, _id: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn cooking_recipe(&self, kind: CookingKind, input: &str) -> Option<CookingRecipe> {
|
||||
let output = match (kind, input) {
|
||||
(CookingKind::Smelting, "minecraft:raw_iron") => "minecraft:iron_ingot",
|
||||
(CookingKind::Smelting, "test:haunted") => "test:ghost",
|
||||
_ => return None,
|
||||
};
|
||||
Some(CookingRecipe {
|
||||
output: stack(output, 1),
|
||||
cooking_time: 3,
|
||||
experience: 0.7,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn stack(id: &str, count: u32) -> Stack {
|
||||
Stack {
|
||||
id: id.into(),
|
||||
count,
|
||||
}
|
||||
}
|
||||
|
||||
fn smelter(handle: u32, input: Option<Stack>) -> BlockEntityView {
|
||||
BlockEntityView {
|
||||
handle,
|
||||
slots: vec![input, None],
|
||||
properties: vec![Property {
|
||||
name: "lit".into(),
|
||||
value: "false".into(),
|
||||
values: vec!["false".into(), "true".into()],
|
||||
}],
|
||||
changed: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn machines() -> Mods {
|
||||
let mut mods = load("horizon_mods_test_machine.hmod");
|
||||
assert_eq!(mods.entries()[0].status, Status::Running);
|
||||
mods.set_item_facts(std::sync::Arc::new(Facts));
|
||||
mods
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_entities_tick_save_and_load() {
|
||||
let mut mods = machines();
|
||||
let types: Vec<(&str, u32, bool)> = mods
|
||||
.block_entity_types()
|
||||
.map(|t| (t.block.as_str(), t.slots, t.ticks))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
types,
|
||||
[
|
||||
("test_machine:smelter", 2, true),
|
||||
("test_machine:crate", 4, false)
|
||||
]
|
||||
);
|
||||
assert_eq!(mods.create_block_entity("minecraft:furnace", &[]), None);
|
||||
let (h, slots) = mods
|
||||
.create_block_entity("test_machine:smelter", &[])
|
||||
.unwrap();
|
||||
assert_eq!(slots, 2);
|
||||
let mut views = [smelter(h, Some(stack("minecraft:raw_iron", 2)))];
|
||||
mods.tick_block_entities(&mut views);
|
||||
assert!(views[0].changed, "lit turned on");
|
||||
assert_eq!(views[0].properties[0].value, "true");
|
||||
assert_eq!(mods.block_entity_data(h), [1]);
|
||||
views[0].changed = false;
|
||||
mods.tick_block_entities(&mut views);
|
||||
assert!(!views[0].changed, "nothing the game keeps changed");
|
||||
mods.tick_block_entities(&mut views);
|
||||
assert_eq!(
|
||||
views[0].slots,
|
||||
[
|
||||
Some(stack("minecraft:raw_iron", 1)),
|
||||
Some(stack("minecraft:iron_ingot", 1))
|
||||
]
|
||||
);
|
||||
mods.tick_block_entities(&mut views);
|
||||
assert_eq!(mods.block_entity_data(h), [1]);
|
||||
|
||||
// Saved and loaded again (a chunk unloads and comes back).
|
||||
let saved = mods.save_block_entity(h).unwrap();
|
||||
mods.remove_block_entity(h);
|
||||
assert_eq!(mods.block_entity_data(h), Vec::<i32>::new());
|
||||
let (again, _) = mods
|
||||
.create_block_entity("test_machine:smelter", &saved)
|
||||
.unwrap();
|
||||
assert_ne!(again, h);
|
||||
assert_eq!(mods.block_entity_data(again), [1]);
|
||||
assert!(mods.drain().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_entities_see_only_their_own() {
|
||||
let mut mods = machines();
|
||||
let (h, _) = mods
|
||||
.create_block_entity("test_machine:smelter", &[])
|
||||
.unwrap();
|
||||
// A view with a handle the mod never got, and an output no table knows:
|
||||
// the stranger is not lent, and the ghost item is refused.
|
||||
let mut views = [
|
||||
smelter(h + 100, Some(stack("minecraft:raw_iron", 1))),
|
||||
smelter(h, Some(stack("test:haunted", 1))),
|
||||
];
|
||||
for _ in 0..3 {
|
||||
mods.tick_block_entities(&mut views);
|
||||
}
|
||||
assert_eq!(
|
||||
views[0],
|
||||
smelter(h + 100, Some(stack("minecraft:raw_iron", 1)))
|
||||
);
|
||||
assert_eq!(views[1].slots, [Some(stack("test:haunted", 1)), None]);
|
||||
assert_eq!(mods.entries()[0].status, Status::Running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_entities_need_their_capability_and_blocks() {
|
||||
let wasm = Package::read(&mods_dir().join("horizon_mods_test_machine.hmod"))
|
||||
.unwrap()
|
||||
.wasm
|
||||
.unwrap();
|
||||
let cases = [
|
||||
(
|
||||
MACHINE_MANIFEST.replace("\"block-entities\"", ""),
|
||||
"uses capability block-entities, missing from horizon-mod.toml",
|
||||
),
|
||||
(
|
||||
MACHINE_MANIFEST.replace("id = \"test_machine\"", "id = \"other\""),
|
||||
"declares a block entity for test_machine:smelter, which is not one of its blocks",
|
||||
),
|
||||
];
|
||||
for (n, (manifest, why)) in cases.into_iter().enumerate() {
|
||||
let dir = mods_dir().join(format!("machine-refused-{n}"));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("horizon-mod.toml"), manifest).unwrap();
|
||||
std::fs::write(dir.join("mod.wasm"), &wasm).unwrap();
|
||||
let mods = Mods::load(&[dir], |_| true, |_| false);
|
||||
assert_eq!(mods.entries()[0].status, Status::Failed(why.into()));
|
||||
assert_eq!(mods.block_entity_types().count(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_entity_that_traps_gives_its_slots_back() {
|
||||
let mut mods = machines();
|
||||
let (h, _) = mods
|
||||
.create_block_entity("test_machine:smelter", &[])
|
||||
.unwrap();
|
||||
let mut views = [smelter(h, Some(stack("minecraft:tnt", 3)))];
|
||||
mods.tick_block_entities(&mut views);
|
||||
assert!(matches!(mods.entries()[0].status, Status::Failed(_)));
|
||||
assert!(matches!(mods.drain()[..], [Output::Stopped { .. }]));
|
||||
// Its slots are back, with what it wrote before it stopped.
|
||||
assert_eq!(views[0].handle, h);
|
||||
assert_eq!(views[0].slots, [Some(stack("minecraft:tnt", 3)), None]);
|
||||
assert_eq!(views[0].properties[0].value, "true");
|
||||
// A stopped mod's block entities are left alone after that.
|
||||
mods.tick_block_entities(&mut views);
|
||||
assert_eq!(views[0].slots, [Some(stack("minecraft:tnt", 3)), None]);
|
||||
assert_eq!(mods.save_block_entity(h), None);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
mod_id,name,class,tier,licence,modrinth_downloads,notice,gaps,requires
|
||||
ae2,Applied Energistics 2,content,deep,custom,6316000,3,CDIMNRTV,
|
||||
allthemodium,Allthemodium,content,deep,unknown,,3,BIWX,
|
||||
alltheores,AllTheOres,content,data,unknown,,3,J,
|
||||
apotheosis,Apotheosis,content,deep,permissive,373000,3,DEHIMRTX,
|
||||
ars_nouveau,Ars Nouveau,content,deep,all-rights-reserved,4014000,3,BDHIMNRTVX,
|
||||
create,Create,content,deep,custom,27867000,3,BCDHINRTVX,
|
||||
farmersdelight,Farmer's Delight,content,machines,permissive,25505000,3,BDIMRTV,
|
||||
ftbquests,FTB Quests,content,machines,unknown,,3,EIMNU,
|
||||
irons_spellbooks,Iron's Spells 'n Spellbooks,content,deep,unknown,,3,ADEIMNRTX,
|
||||
jade,Jade 🔍,client-ui,client,restricted,72845000,3,NU,
|
||||
jei,Just Enough Items (JEI),client-ui,client,permissive,84344000,3,NU,
|
||||
journeymap,JourneyMap,client-ui,client,all-rights-reserved,15400000,3,NU,
|
||||
mekanism,Mekanism,content,deep,permissive,3906000,3,BCDIMNRTVX,
|
||||
mysticalagriculture,Mystical Agriculture,content,machines,permissive,1303000,3,BDIMNRTV,
|
||||
refinedstorage,Refined Storage,content,deep,unknown,,3,CDMNRTV,
|
||||
sophisticatedbackpacks,Sophisticated Backpacks,content,deep,unknown,,3,ADIMNV,
|
||||
sophisticatedstorage,Sophisticated Storage,content,machines,unknown,,3,DMNTV,
|
||||
supplementaries,Supplementaries,content,deep,custom,29039000,3,BDHINTX,
|
||||
twilightforest,The Twilight Forest,content,deep,unknown,,3,BDHIMTVWX,
|
||||
waystones,Waystones,content,machines,all-rights-reserved,27351000,3,BDEMNT,
|
||||
actuallyadditions,Actually Additions,content,machines,permissive,246000,2,BCDIMNRTV,
|
||||
ad_astra,Ad Astra,content,deep,custom,5985000,2,CDEIMNTVWX,
|
||||
ae2wtlib,Applied Energistics 2 Wireless Terminals,content,machines,permissive,3149000,2,AIMN,ae2
|
||||
aether,The Aether,content,deep,unknown,,2,BDHIMWX,
|
||||
allthecompressed,AllTheCompressed,content,data,unknown,,2,J,
|
||||
allthetweaks,AllTheTweaks,content,logic,unknown,,2,BI,
|
||||
amendments,Amendments,content,deep,custom,20004000,2,BDHTX,
|
||||
apothic_enchanting,Apothic-Enchanting,content,machines,permissive,128000,2,EHIMTV,
|
||||
appleskin,AppleSkin,client-ui,client,permissive,97093000,2,NU,
|
||||
aquaculture,Aquaculture 2,content,deep,all-rights-reserved,1214000,2,BDIMTVX,
|
||||
artifacts,Artifacts,content,deep,permissive,14143000,2,ADEIX,
|
||||
astralsorcery,Astral Sorcery,content,deep,unknown,,2,BDIMNRTVX,
|
||||
bigreactors,Extreme Reactors,content,machines,unknown,,2,CDMNRTV,
|
||||
biomeswevegone,Oh The Biomes We've Gone,content,deep,all-rights-reserved,11969000,2,BDWX,
|
||||
botanypots,Botany Pots,content,machines,copyleft,2167000,2,DMNRTV,
|
||||
buildinggadgets2,Building Gadgets 2,content,deep,unknown,,2,DIMN,
|
||||
cataclysm,L_Ender's Cataclysm,content,deep,restricted,19343000,2,BDIWX,
|
||||
chisel,Chisel Reborn,content,machines,custom,1538000,2,IMNTV,
|
||||
compactmachines,Compact Machines,content,deep,unknown,,2,CDMNTVW,
|
||||
computercraft,CC: Tweaked,content,deep,custom,5344000,2,DMNSTUV,
|
||||
cookingforblockheads,Cooking for Blockheads,content,machines,unknown,,2,BDMNTV,
|
||||
craftingtweaks,Crafting Tweaks,client-ui,client,all-rights-reserved,10529000,2,NU,
|
||||
createaddition,Create Crafts & Additions,content,deep,permissive,11314000,2,CDNRTV,create
|
||||
creeperoverhaul,Creeper Overhaul,content,deep,all-rights-reserved,10880000,2,DX,
|
||||
deeperdarker,Deeper and Darker,content,deep,copyleft,16319000,2,BDIWX,
|
||||
draconicevolution,Draconic Evolution,content,deep,custom,926000,2,ACDEIMNTVX,
|
||||
easy_villagers,Easy Villagers,content,deep,all-rights-reserved,1681000,2,DMNTVX,
|
||||
elevatorid,ElevatorMod,content,logic,permissive,2338000,2,BMN,
|
||||
enchdesc,Enchantment Descriptions,client-ui,client,copyleft,39608000,2,U,
|
||||
enderio,Ender IO,content,deep,permissive,1004000,2,BCDIMNRTV,
|
||||
endermanoverhaul,Enderman Overhaul,content,deep,all-rights-reserved,8223000,2,DX,
|
||||
enderstorage,Ender Storage,content,machines,permissive,691000,2,CDMNTV,
|
||||
explorerscompass,Explorer's Compass,content,machines,restricted,12346000,2,IMN,
|
||||
extendedae,ExtendedAE,content,machines,unknown,,2,CDMNTV,ae2
|
||||
fluxnetworks,Flux Networks,content,machines,unknown,,2,CDMNT,
|
||||
framedblocks,FramedBlocks,content,deep,copyleft,2265000,2,BDNT,
|
||||
ftbchunks,FTB Chunks,server-only,deep,unknown,,2,ENU,
|
||||
ftbteams,FTB Teams,server-only,logic,unknown,,2,ENU,
|
||||
ftbultimine,FTB Ultimine,content,logic,unknown,,2,DEIN,
|
||||
functionalstorage,Functional Storage,content,machines,unknown,,2,DMNTV,
|
||||
handcrafted,Handcrafted,content,machines,custom,27534000,2,BDMTVX,
|
||||
iceandfire,IceAndFire Community Edition,content,deep,copyleft,2208000,2,BDIMTWX,
|
||||
immersiveengineering,Immersive Engineering,content,deep,custom,3691000,2,BCDIMNRTVX,
|
||||
industrialforegoing,Industrial Foregoing,content,machines,permissive,978000,2,CDIMNRTVX,
|
||||
ironfurnaces,Iron Furnaces,content,machines,permissive,10092000,2,MNTV,
|
||||
ironjetpacks,Iron Jetpacks,content,logic,permissive,641000,2,ADEIN,
|
||||
justdirethings,Just Dire Things,content,deep,unknown,,2,CDIMNTVX,
|
||||
laserio,LaserIO,content,machines,unknown,,2,CDMNTV,
|
||||
lootr,Lootr,content,machines,permissive,23796000,2,DEMNTV,
|
||||
mcwfurnitures,Macaw's Furniture,content,machines,all-rights-reserved,7371000,2,BMTVX,
|
||||
mcwwindows,Macaw's Windows,content,logic,all-rights-reserved,11637000,2,B,
|
||||
mekanismgenerators,Mekanism Generators,content,deep,permissive,2656000,2,CDMNTV,mekanism
|
||||
minecolonies,MineColonies,content,deep,unknown,,2,BDEIMNTVWX,
|
||||
mininggadgets,Mining Gadgets,content,deep,unknown,,2,DIMNT,
|
||||
modern_industrialization,Modern Industrialization,content,deep,permissive,2060000,2,CDMNRTV,
|
||||
mousetweaks,Mouse Tweaks,client-ui,client,permissive,63400000,2,U,
|
||||
naturescompass,Nature's Compass,content,machines,restricted,27185000,2,IMN,
|
||||
notenoughanimations,Not Enough Animations,client-ui,client,custom,93276000,2,DU,
|
||||
occultism,Occultism,content,deep,permissive,893000,2,DIMNRTVX,
|
||||
oritech,Oritech,content,deep,permissive,1271000,2,CDMNRTV,
|
||||
pamhc2crops,Pam's HarvestCraft - Crops,content,data,unknown,,2,J,
|
||||
pamhc2foodcore,Pam's HarvestCraft - Food Core,content,data,unknown,,2,J,
|
||||
pipez,Pipez,content,machines,all-rights-reserved,565000,2,CDMNTV,
|
||||
pneumaticcraft,PneumaticCraft: Repressurized,content,deep,copyleft,811000,2,BCDIMNRTVX,
|
||||
powah,Powah!,content,machines,copyleft,2573000,2,CDMNTV,
|
||||
productivebees,Productive Bees,content,deep,unknown,,2,BDMNRTVX,
|
||||
rechiseled,Rechiseled,content,machines,all-rights-reserved,8991000,2,DIMN,
|
||||
refurbished_furniture,MrCrayfish's Furniture Mod: Refurbished,content,deep,unknown,,2,BDMNTVX,
|
||||
relics,Relics,content,deep,all-rights-reserved,3258000,2,ADEIN,
|
||||
sawmill,Sawmill,content,machines,all-rights-reserved,6447000,2,MNR,
|
||||
securitycraft,SecurityCraft,content,deep,permissive,5921000,2,BDEMNTVX,
|
||||
silentgear,Silent Gear,content,deep,permissive,501000,2,DIMNRT,
|
||||
the_bumblezone,The Bumblezone - NeoForge/Forge,content,deep,all-rights-reserved,4273000,2,BDHIWX,
|
||||
tiab,Time in a bottle,content,logic,permissive,332000,2,EHI,
|
||||
tombstone,Corail Tombstone,content,deep,all-rights-reserved,344000,2,DEIMTVX,
|
||||
toolbelt,Tool Belt,content,machines,unknown,,2,ADIMN,
|
||||
torchmaster,TorchMaster,content,logic,permissive,3360000,2,BEH,
|
||||
trashcans,Trash Cans,content,machines,all-rights-reserved,2185000,2,CMNTV,
|
||||
undergarden,The Undergarden,content,deep,all-rights-reserved,3075000,2,BDIWX,
|
||||
accelerateddecay,Accelerated Decay,content,logic,copyleft,1187000,1,BH,
|
||||
ad_astra_giselle_addon,Ad Astra: Giselle Addon,integration,machines,unknown,,1,CMNRTV,ad_astra mekanism
|
||||
additional_lights,Additional Lights,unknown,none,unknown,,1,B,
|
||||
advanced_ae,AdvancedAE,content,machines,copyleft,651000,1,MNTV,ae2
|
||||
advancedperipherals,Advanced Peripherals,integration,machines,unknown,,1,CSTV,computercraft
|
||||
ae2ct,AE2:Crafting Tree,client-ui,client,unknown,,1,U,ae2
|
||||
ae2importexportcard,AE2 Import Export Card,content,machines,permissive,320000,1,MNTV,ae2
|
||||
ae2jeiintegration,AE2 JEI Integration,integration,client,unknown,,1,U,ae2 jei
|
||||
ae2netanalyser,AE2NetworkAnalyzer,content,machines,unknown,,1,DIN,ae2
|
||||
aeinfinitybooster,AEInfinityBooster,content,logic,copyleft,220000,1,I,ae2
|
||||
akashictome,Akashic Tome,content,logic,restricted,454000,1,IN,
|
||||
allthearcanistgear,All The Arcanist Gear,content,logic,unknown,,1,I,ars_nouveau
|
||||
allthewizardgear,All the Wizard Gear,content,logic,unknown,,1,I,irons_spellbooks
|
||||
apothic_spawners,Apothic-Spawners,content,logic,permissive,105000,1,HT,
|
||||
appflux,AppliedFlux,content,machines,unknown,,1,CMNTV,ae2
|
||||
appmek,Applied Mekanistics,integration,machines,copyleft,847000,1,CTV,ae2 mekanism
|
||||
ars_additions,Ars Additions,content,logic,copyleft,388000,1,BI,ars_nouveau
|
||||
ars_controle,Ars Controle,content,logic,copyleft,92000,1,BI,ars_nouveau
|
||||
ars_creo,Ars Creo,integration,logic,all-rights-reserved,352000,1,BI,ars_nouveau create
|
||||
ars_elemancy,Ars Elemancy,content,logic,unknown,,1,BIX,ars_nouveau
|
||||
ars_elemental,Ars Elemental,content,deep,unknown,,1,BDITX,ars_nouveau
|
||||
ars_ocultas,Ars Ocultas,content,logic,copyleft,422000,1,BI,ars_nouveau
|
||||
ars_polymorphia,Ars Polymorphia,content,logic,copyleft,75000,1,BI,ars_nouveau
|
||||
ars_technica,Ars Technica,content,machines,unknown,,1,CDMNRTV,ars_nouveau create
|
||||
ars_unification,Ars Unification,integration,data,copyleft,61000,1,J,ars_nouveau
|
||||
arseng,Ars Énergistique,integration,machines,copyleft,413000,1,CTV,ae2 ars_nouveau
|
||||
atmcompat,ATM: Compat,integration,data,unknown,,1,J,
|
||||
auroral,Auroral,content,deep,permissive,9000,1,DIX,
|
||||
auroras,Auroras,client-ui,client,permissive,262000,1,DU,
|
||||
bellsandwhistles,Create: Bells & Whistles,content,deep,copyleft,5037000,1,DT,create
|
||||
betteradvancedtooltips,Better Advanced Tooltips,client-ui,client,permissive,104000,1,U,
|
||||
betteradvancements,Better Advancements,client-ui,client,custom,27359000,1,U,
|
||||
bhc,Baubley Heart Canisters,content,logic,unknown,,1,AEI,
|
||||
bibliobiomes,Bibliobiomes Legacy,integration,data,permissive,69000,1,J,
|
||||
bibliocraft,Bibliocraft Legacy,content,machines,permissive,132000,1,BDMNTV,
|
||||
bibliowoods,Bibliowoods Legacy,integration,data,permissive,84000,1,J,
|
||||
blueflame,Blue Flame Burning,content,logic,all-rights-reserved,69000,1,EH,
|
||||
botanypotsmystical,Botany Pots - Mystical Agriculture Compat,integration,data,copyleft,64000,1,J,botanypots mysticalagriculture
|
||||
botanytrees,Botany Trees,integration,data,copyleft,802000,1,J,botanypots
|
||||
bridgingmod,Bridging Mod,content,logic,permissive,6843000,1,EHN,
|
||||
bwncr,Bad Wither No Cookie - Reloaded,server-only,logic,permissive,14196000,1,E,
|
||||
byzantine,Byzantine,unknown,none,unknown,,1,B,
|
||||
cabletiers,Cable Tiers,content,machines,permissive,206000,1,MNTV,refinedstorage
|
||||
camol,Camol,unknown,none,unknown,,1,B,
|
||||
catjammies,CatJammies,unknown,none,unknown,,1,DH,
|
||||
charginggadgets,Charging Gadgets,content,machines,unknown,,1,CMNTV,
|
||||
chromacarvings,Chroma Carvings,unknown,none,unknown,,1,J,
|
||||
cleanswing,Clean Swing,unknown,none,unknown,,1,EH,
|
||||
cloudglass,Cloud Glass,content,data,unknown,,1,J,
|
||||
cobblegengalore,Cobblegen Galore,unknown,none,unknown,,1,MTV,
|
||||
colorfulhearts,Colorful Hearts,client-ui,client,permissive,4640000,1,U,
|
||||
comforts,Comforts,content,logic,copyleft,24012000,1,BEH,
|
||||
constructionstick,Construction Sticks,content,logic,unknown,,1,DIN,
|
||||
controlling,Controlling,client-ui,client,permissive,41821000,1,U,
|
||||
copycats,Create: Copycats+,content,deep,all-rights-reserved,10946000,1,DT,create
|
||||
cosmeticarmorreworked,CosmeticArmorReworked,content,machines,unknown,,1,DEMN,
|
||||
crafting_on_a_stick,Crafting on a Stick,content,logic,all-rights-reserved,416000,1,IM,
|
||||
crashutilities,Crash Utilities,server-only,logic,unknown,,1,E,
|
||||
create_aquatic_ambitions,Create: Aquatic Ambitions,content,deep,permissive,620000,1,BDTX,create
|
||||
create_dragons_plus,Create: Dragons Plus,content,deep,copyleft,3448000,1,DTX,create
|
||||
create_enchantment_industry,Create: Enchantment Industry,content,deep,copyleft,6160000,1,CDRTV,create
|
||||
create_hypertube,Create: Hypertubes,content,deep,permissive,1219000,1,DNTX,create
|
||||
crystalix,Crystalix,content,data,unknown,,1,J,
|
||||
darkmodeeverywhere,Dark Mode Everywhere,client-ui,client,permissive,375000,1,U,
|
||||
dimstorage,DimStorage,content,machines,copyleft,105000,1,CMNTV,
|
||||
disenchanting_table,Dis-Enchanting Table,content,machines,all-rights-reserved,1293000,1,MTV,
|
||||
domum_ornamentum,Domum Ornamentum,content,deep,unknown,,1,BDMNT,
|
||||
drippyloadingscreen,Drippy Loading Screen,client-ui,client,custom,23220000,1,U,
|
||||
dummmmmmy,MmmMmmMmmMmm,content,deep,permissive,13223000,1,DX,
|
||||
dungeoncrawl,Dungeon Crawl,content,deep,copyleft,664000,1,W,
|
||||
dyenamics,Dyenamics,content,logic,unknown,,1,BHI,
|
||||
dyenamicsandfriends,Dyenamics and Friends,integration,data,unknown,,1,J,dyenamics
|
||||
dysoncubeproject,Dyson Cube Project,content,deep,unknown,,1,CDMNTV,
|
||||
enderdrives,Ender Drives,content,machines,unknown,,1,MNTV,ae2
|
||||
energymeter,Energy Meter,content,machines,all-rights-reserved,78000,1,CDMNT,
|
||||
entangled,Entangled,content,machines,all-rights-reserved,309000,1,CDTV,
|
||||
eternal_starlight,Eternal Starlight,content,deep,permissive,744000,1,BDIWX,
|
||||
everythingcopper,Everything is Copper,content,data,unknown,,1,J,
|
||||
evilcraft,EvilCraft,content,deep,unknown,,1,CDIMNRTVX,
|
||||
expandedae,Expanded AE,content,machines,unknown,,1,MNTV,ae2
|
||||
extended_industrialization,Extended Industrialization,content,machines,permissive,562000,1,CDMNRTV,modern_industrialization
|
||||
extradisks,Extra Disks,content,machines,permissive,312000,1,MNTV,refinedstorage
|
||||
extrastorage,ExtraStorage,content,machines,copyleft,220000,1,MNTV,refinedstorage
|
||||
extremesoundmuffler,Extreme sound muffler,client-ui,client,copyleft,5131000,1,U,
|
||||
factory_blocks,Factory Blocks,content,data,unknown,,1,J,
|
||||
fancymenu,FancyMenu,client-ui,client,custom,69939000,1,U,
|
||||
farmingforblockheads,Farming for Blockheads,content,machines,all-rights-reserved,551000,1,MNTV,
|
||||
fireproofboats,Fireproof Boats,content,deep,unknown,,1,IX,
|
||||
forbidden_arcanus,Forbidden and Arcanus ,content,deep,all-rights-reserved,541000,1,BDIMRTVX,
|
||||
ftbessentials,FTB Essentials,server-only,logic,unknown,,1,E,
|
||||
ftbjeiextras,FTB Jei Extras,integration,client,unknown,,1,U,jei
|
||||
ftbranks,FTB Ranks,server-only,logic,unknown,,1,E,
|
||||
ftbxmodcompat,FTB XMod Compat,integration,logic,unknown,,1,E,ftbquests
|
||||
fuelgoeshere,Fuel Goes Here,content,logic,all-rights-reserved,138000,1,H,
|
||||
gateways,Gateways To Eternity,content,deep,unknown,,1,DINTX,
|
||||
generatorgalore,Generator Galore,content,machines,unknown,,1,CMNTV,
|
||||
gmut,Gravitational Modulating Additional Unit,integration,logic,unknown,,1,EI,mekanism
|
||||
hardenedarmadillos,Hardened Armadillos,unknown,none,unknown,,1,HX,
|
||||
herbsandharvest,Mama's Herbs and Harvest,content,data,unknown,,1,J,
|
||||
heyberryshutup,Hey Berry! SHUT UP,content,logic,all-rights-reserved,108000,1,H,
|
||||
hostilenetworks,Hostile Neural Networks,content,machines,permissive,55000,1,CDMNTV,
|
||||
illagerwarship,Illager Warship,content,deep,all-rights-reserved,291000,1,DWX,
|
||||
imfast,I'm Fast,content,logic,permissive,1436000,1,H,
|
||||
immeng,Immersive Energistics,integration,machines,copyleft,89000,1,CTV,ae2 immersiveengineering
|
||||
incontrol,In Control!,server-only,logic,permissive,4035000,1,EH,
|
||||
industrialforegoingsouls,Industrial Foregoing: Souls,content,machines,permissive,178000,1,CDMNTV,industrialforegoing
|
||||
industrialization_overdrive,Industrialization Overdrive,content,machines,permissive,70000,1,CDMNRTV,modern_industrialization
|
||||
integratedcrafting,IntegratedCrafting,content,deep,unknown,,1,CDMNRSTV,integrateddynamics
|
||||
integrateddynamics,IntegratedDynamics,content,deep,unknown,,1,CDMNRSTV,
|
||||
integratedscripting,IntegratedScripting,content,deep,unknown,,1,CDMNRSTV,integrateddynamics
|
||||
integratedterminals,IntegratedTerminals,content,deep,unknown,,1,CDMNRSTV,integrateddynamics
|
||||
integratedtunnels,IntegratedTunnels,content,deep,unknown,,1,CDMNRSTV,integrateddynamics
|
||||
interdimensionalwirelesstransmitter,Interdimensional Wireless Transmitter,content,machines,permissive,106000,1,CMNTV,
|
||||
invtweaks,Inventory Tweaks Refoxed,client-ui,client,unknown,,1,NU,
|
||||
irons_jewelry,Iron's Gems 'n Jewelry,content,machines,all-rights-reserved,19000,1,AIMTV,irons_spellbooks
|
||||
itemcollectors,Item Collectors,content,machines,all-rights-reserved,571000,1,DMNTV,
|
||||
jearchaeology,Just Enough Archaeology,integration,client,unknown,,1,U,jei
|
||||
jei_mekanism_multiblocks,Just Enough Mekanism Multiblocks,integration,client,unknown,,1,U,jei mekanism
|
||||
jeistuff,Just Enough Stuff,integration,client,unknown,,1,U,jei
|
||||
jumpboat,Jumpy Boats,content,logic,all-rights-reserved,179000,1,HX,
|
||||
justenoughbreeding,Just Enough Breeding (JEBr),integration,client,permissive,8335000,1,U,jei
|
||||
justenoughprofessions,Just Enough Professions (JEP),integration,client,unknown,,1,U,jei
|
||||
keybindbundles,KeyBind Bundles,client-ui,client,unknown,,1,U,
|
||||
keybindspurger,KeybindsPurger,client-ui,client,permissive,159000,1,U,
|
||||
kotlinforforge-5.12.0-all,,unknown,none,unknown,,1,L,
|
||||
laserbridges,Laser Bridges & Doors,content,machines,permissive,240000,1,BDMNT,
|
||||
little_big_redstone,Little Big Redstone,content,deep,permissive,77000,1,DMNT,
|
||||
livingthings,LivingThings,content,deep,restricted,586000,1,DX,
|
||||
logisticsnetworks,LogisticsNetworks,unknown,none,unknown,,1,MNTV,
|
||||
mahoutsukai,Mahou Tsukai,content,deep,unknown,,1,DEINX,
|
||||
mcwlights,Macaw's Lights and Lamps,content,logic,all-rights-reserved,4128000,1,B,
|
||||
measurements,Measurements,content,logic,unknown,,1,DIN,
|
||||
megacells,MEGA Cells,content,machines,unknown,,1,MNTV,ae2
|
||||
mekanismcovers,Mekanism Covers,integration,machines,copyleft,134000,1,DT,mekanism
|
||||
mekanisticrouters,Mekanistic Routers,integration,machines,unknown,,1,CTV,mekanism modularrouters
|
||||
mekmm,Mekanism:More Machine,content,machines,permissive,166000,1,CDMNRTV,mekanism
|
||||
merequester,ME Requester,content,machines,copyleft,1781000,1,MNTV,ae2
|
||||
merrymaking,Mama's MerryMaking,content,logic,unknown,,1,BI,
|
||||
mffs,Modular Force Field System,content,deep,unknown,,1,CDEMNT,
|
||||
mifa,More Industrial Foregoing Addons (MIFA),content,machines,permissive,98000,1,CDMNTV,industrialforegoing
|
||||
mob_grinding_utils,Mob Grinding Utils,content,machines,unknown,,1,CDMNTVX,
|
||||
moderndynamics,Modern Dynamics,content,machines,copyleft,184000,1,CDNTV,
|
||||
modularbees,ModularBees,content,machines,unknown,,1,MNTV,productivebees
|
||||
modularrouters,Modular Routers,content,machines,permissive,291000,1,DIMNTV,
|
||||
moredragoneggs,More Dragon Eggs,content,logic,unknown,,1,B,
|
||||
moreoverlays,More Overlays Updated,client-ui,client,permissive,2194000,1,U,
|
||||
morered,More Red,content,logic,unknown,,1,BDT,
|
||||
moreredxcctcompat,More Red x CC:Tweaked Compat,integration,logic,unknown,,1,T,morered computercraft
|
||||
multipiston,Multi-Piston,content,logic,unknown,,1,BT,
|
||||
mysticalagradditions,Mystical Agradditions,content,logic,permissive,707000,1,BI,mysticalagriculture
|
||||
naturesaura,Nature's Aura,content,deep,permissive,154000,1,BDIRTVX,
|
||||
neovitae,Neo Vitae,content,deep,permissive,24000,1,DITX,
|
||||
netherportalfix,NetherPortalFix,server-only,logic,unknown,,1,EH,
|
||||
not_enough_glyphs,Not Enough Glyphs,content,logic,copyleft,109000,1,BIX,ars_nouveau
|
||||
novillagerdm,No Villager Death Messages,server-only,logic,permissive,114000,1,E,
|
||||
overlapless,Overlapless,unknown,none,unknown,,1,U,
|
||||
overloadedarmorbar,OverloadedArmorBar,client-ui,client,unknown,,1,U,
|
||||
pamhc2trees,Pam's HarvestCraft - Trees,content,data,unknown,,1,J,
|
||||
pocketstorage,Pocket Storage,content,machines,unknown,,1,IMN,
|
||||
polyeng,Polymorphic Energistics,integration,machines,copyleft,2035000,1,MNTV,ae2
|
||||
polymorph,Polymorph,client-ui,client,copyleft,26223000,1,MNU,
|
||||
potionsmaster,PotionsMaster,unknown,none,unknown,,1,MRTV,
|
||||
productivemetalworks,Productive Metalworks,content,machines,unknown,,1,CDMRTV,
|
||||
productivetrees,Productive Trees,content,logic,unknown,,1,BRT,
|
||||
pylons,Pylons,content,machines,all-rights-reserved,167000,1,EMNTV,
|
||||
quarryplus,Additional Enchanted Miner,content,machines,copyleft,538000,1,CDMNTV,
|
||||
railcraft,Railcraft Reborn,content,deep,custom,165000,1,BCDMNRTVX,
|
||||
rangedpumps,Ranged Pumps,content,machines,unknown,,1,CMT,
|
||||
rechiseled_chipped,Rechiseled: Chipped,integration,data,all-rights-reserved,338000,1,J,rechiseled
|
||||
rechiseledcreate,Rechiseled: Create,integration,data,all-rights-reserved,2978000,1,J,rechiseled create
|
||||
redstonepen,Redstone Pen,content,logic,permissive,365000,1,BDNT,
|
||||
refinedfluidsubstitution,Refined Fluid Substitution,integration,logic,permissive,0000,1,T,refinedstorage
|
||||
refinedstorage_curios_integration,Refined Storage - Curios Integration,integration,machines,unknown,,1,AN,refinedstorage
|
||||
refinedstorage_jei_integration,Refined Storage - JEI Integration,integration,client,unknown,,1,U,refinedstorage jei
|
||||
refinedstorage_mekanism_integration,Refined Storage - Mekanism Integration,integration,machines,unknown,,1,CTV,refinedstorage mekanism
|
||||
refinedstorage_quartz_arsenal,Refined Storage - Quartz Arsenal,content,logic,unknown,,1,I,refinedstorage
|
||||
refinedtypes,Refined Types,content,machines,permissive,51000,1,MTV,refinedstorage
|
||||
reliquary,Reliquary Reincarnations,content,deep,unknown,,1,BDEITX,
|
||||
reliquified_artifacts,Reliquified Artifacts,integration,logic,all-rights-reserved,633000,1,AEI,artifacts relics
|
||||
repeatable_trial_vaults,Repeatable Trial Vaults,content,logic,permissive,158000,1,H,
|
||||
restrictions,Restrictions,content,logic,unknown,,1,E,
|
||||
rftoolsbase,RFTools Base,content,machines,permissive,382000,1,CDMNTV,
|
||||
rftoolsbuilder,RFTools Builder,content,deep,permissive,309000,1,CDMNTVW,rftoolsbase
|
||||
rftoolspower,RFTools Power,content,machines,permissive,148000,1,CDMNTV,rftoolsbase
|
||||
rftoolsstorage,RFTools Storage,content,machines,permissive,91000,1,CDMNTV,rftoolsbase
|
||||
rftoolsutility,RFTools Utility,content,machines,permissive,327000,1,CDMNTV,rftoolsbase
|
||||
rootsclassic,Roots Classic,content,deep,unknown,,1,DIMRTVX,
|
||||
rsinsertexportupgrade,Refined Wireless Upgrades,content,machines,permissive,52000,1,MNTV,refinedstorage
|
||||
sawmill.zip-(datapack),,other,data,unknown,,1,J,
|
||||
sfm,Super Factory Manager,content,deep,copyleft,381000,1,DMNSTV,
|
||||
sgearmetalworks,Silent Gear Metalworks,integration,logic,unknown,,1,I,silentgear
|
||||
shiny,Shiny!,unknown,none,unknown,,1,DH,
|
||||
shrink,Shrink,content,deep,unknown,,1,DEHN,
|
||||
silentgems,Silent's Gems,content,logic,permissive,118000,1,BI,
|
||||
simple_weather,Simple Weather,unknown,none,unknown,,1,DU,
|
||||
simplebackups,Simple Backups,server-only,logic,permissive,1023000,1,E,
|
||||
simplemagnets,Simple Magnets,content,logic,all-rights-reserved,558000,1,EIN,
|
||||
simplylight,Simply Light,content,data,unknown,,1,J,
|
||||
smithingtemplateviewer,Smithing Template Viewer,client-ui,client,permissive,120000,1,U,
|
||||
solcarrot,Spice of Life: Carrot Edition,content,logic,unknown,,1,ENU,
|
||||
sophisticatedbackpackscreateintegration,Sophisticated Backpacks Create Integration,integration,deep,unknown,,1,TX,sophisticatedbackpacks create
|
||||
sophisticatedstoragecreateintegration,Sophisticated Storage Create Integration,integration,deep,unknown,,1,TX,sophisticatedstorage create
|
||||
sophisticatedstorageinmotion,Sophisticated Storage In Motion,integration,deep,unknown,,1,TVX,sophisticatedstorage create
|
||||
soulplied_energistics,Soulplied Energistics,integration,machines,unknown,,1,MNTV,ae2
|
||||
starbunclemania,Starbunclemania,content,logic,unknown,,1,X,ars_nouveau
|
||||
stepcrafter,Step Crafter,content,machines,permissive,9000,1,MNTV,
|
||||
stevescarts,StevesCarts2,content,deep,unknown,,1,DMNTVX,
|
||||
storagedelight,Storage Delight,integration,machines,permissive,2117000,1,DMTV,farmersdelight
|
||||
stylecolonies,Stylecolonies,integration,data,unknown,,1,J,minecolonies
|
||||
sushigocrafting,Sushi Go Crafting,content,machines,permissive,132000,1,BMTV,
|
||||
sussysniffers,Sussy Sniffers,unknown,none,unknown,,1,HX,
|
||||
tempad,Tempad,content,deep,custom,471000,1,DEIMN,
|
||||
theurgy,Theurgy,content,deep,permissive,224000,1,DRTV,
|
||||
toastcontrol,Toast Control,client-ui,client,unknown,,1,U,
|
||||
towntalk,Towntalk,unknown,none,unknown,,1,HX,
|
||||
transfer_labels,Transfer Labels,content,machines,permissive,69000,1,IT,
|
||||
trashslot,TrashSlot,client-ui,client,unknown,,1,MNU,
|
||||
treetap,Tree Tap,content,machines,unknown,,1,TV,
|
||||
trophymanager,Jonn's Trophies,content,machines,unknown,,1,DNT,
|
||||
underground_villages,Underground Villages Mod,unknown,none,unknown,,1,WX,
|
||||
universalgrid,Universal Grid,content,machines,permissive,290000,1,IMN,refinedstorage
|
||||
uranus,Uranus,unknown,none,unknown,,1,B,
|
||||
utilitarian,Utilitarian,content,logic,unknown,,1,BIT,
|
||||
utilityvest,Utility vest,unknown,none,unknown,,1,AIM,
|
||||
variantsandventures,Variants&Ventures,content,deep,restricted,1788000,1,DX,
|
||||
wirelesschargers,Wireless Chargers,content,machines,all-rights-reserved,242000,1,CDT,
|
||||
wits,WITS (What Is This Structure?),server-only,logic,permissive,2090000,1,EN,
|
||||
wstweaks,Wither Skeleton Tweaks,content,logic,unknown,,1,EH,
|
||||
xnet,XNet,content,machines,permissive,314000,1,CDMNTV,rftoolsbase
|
||||
xtonesreworked,XTones Reworked,content,data,unknown,,1,J,
|
||||
xycraft_machines,XyCraft Machines,content,machines,unknown,,1,CDMNTV,
|
||||
xycraft_override,XyCraft Override,unknown,none,unknown,,1,H,
|
||||
xycraft_world,XyCraft World,content,logic,unknown,,1,BDW,
|
||||
|
+28
-2
@@ -11,7 +11,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
|
||||
|
||||
| Part | Where | State |
|
||||
|---|---|---|
|
||||
| The contract (WIT) | `crates/horizon-mod-sdk/wit/horizon.wit` | 0.1: log, game-read, chat, hud, storage; tick, frame, chat and command hooks |
|
||||
| The contract (WIT) | `crates/horizon-mod-sdk/wit/horizon.wit` | 0.1: log, game-read, chat, hud, storage; tick, frame, chat and command hooks. 0.4 preview: block entities (runtime only, not in the game yet) |
|
||||
| SDK for mod authors | `crates/horizon-mod-sdk` | 0.1 |
|
||||
| Example mod | `crates/horizon-mod-sdk/examples/coords-hud` | coordinates, facing and time HUD, `/where`, `/coords` |
|
||||
| Runtime | `crates/horizon-mods` | wasmtime 36 LTS, component model |
|
||||
@@ -356,6 +356,30 @@ none.
|
||||
"top": "carve", "bottom": "none", "rigid_junctions": true, "inflate": true } }
|
||||
```
|
||||
|
||||
### Block entities (API 0.4, preview)
|
||||
|
||||
The first piece of the machines tier ([research/code-mods-plan.md](research/code-mods-plan.md)):
|
||||
a mod with `api = "0.4"` and capability `block-entities` gives its own blocks
|
||||
block entities whose code ticks in the sandbox. The runtime and the SDK have
|
||||
it; the game does not create, tick or save them yet (next step in the plan).
|
||||
|
||||
| Part | What |
|
||||
|---|---|
|
||||
| export `block-entities` | `types()`: the mod's blocks with a block entity, their slot count and whether they tick; `create(handle, block, saved)`, `tick(handles)`, `save(handle)`, `remove(handle)`, `data(handle)` (the menu's values, vanilla's `ContainerData`) |
|
||||
| import `machine` | during `tick` only: `get-slot`, `set-slot` and `get-property`, `set-property` (the block state) of the block entities lent to the call; any time: `max-stack-size`, `burn-time`, `remainder`, `cooking(kind, input)` from the game's item, fuel and recipe tables |
|
||||
|
||||
- **The game owns the slots and the block state**, so hoppers, menus, drops and
|
||||
saves see them as they see vanilla's. The mod owns the rest and saves it as
|
||||
bytes with `save`.
|
||||
- **One call per mod per tick** for all its ticking block entities, under the
|
||||
tick's fuel budget. A block entity sees only the handles lent to that call; a
|
||||
slot write of an unknown item is refused, a stack over the item's maximum is
|
||||
cut to it, a property value the block does not allow is refused.
|
||||
- **Types are checked at start:** each for a block in the mod's own namespaces,
|
||||
at most once, at most 256 slots. A component that exports block entities
|
||||
without the capability does not start.
|
||||
- Components built before 0.4 (no `block-entities` export) load as before.
|
||||
|
||||
## Versions and compatibility
|
||||
|
||||
- The API is the WIT package `horizon:[email protected]`. New functions and
|
||||
@@ -426,4 +450,6 @@ none.
|
||||
Modpacks ([modpacks.md](modpacks.md)) move data-driven `registries` first:
|
||||
API 0.2 is content-only `.hmod`s (blocks, items, recipes, loot, models and
|
||||
textures), then server hooks (0.3) and block entities, menus, capabilities and
|
||||
`net` (0.4), in the order the ATM10 inventory needs them.
|
||||
`net` (0.4), in the order the ATM10 inventory needs them. The ranking of ATM10's
|
||||
code mods by the gaps they hit, and the first block-entity piece, are in
|
||||
[research/code-mods-plan.md](research/code-mods-plan.md).
|
||||
@@ -0,0 +1,282 @@
|
||||
# Code mods in modpacks: the gaps, the ranking, a first port
|
||||
|
||||
ATM10's world now generates well in Horizon (structures, ores, decoration blocks), but **333 of its
|
||||
496 mods are still "not yet"**. Almost all of them have code: machines, magic, mobs, tools. This page
|
||||
is the research spike that gives Ludovic the facts to choose the path. It answers four questions:
|
||||
what the mod API carries today, which ATM10 mods each missing piece unlocks, what a first real code
|
||||
port looks like, and what porting costs.
|
||||
|
||||
The decision from [modpacks.md](../modpacks.md) stands: **porting, not emulation**. Java mods become
|
||||
native Horizon mods (Rust compiled to WebAssembly, against the Horizon mod API), and each port is
|
||||
checked against the Java mod running in a dev-only NeoForge oracle. Mod jars and decompiled code stay
|
||||
on the machine that ports them and never enter this repository.
|
||||
|
||||
## 1. What the mod API carries today
|
||||
|
||||
| Line | What a mod can do | Where |
|
||||
|---|---|---|
|
||||
| API 0.1 (code) | client side only: read the player and the clock, show chat lines and a HUD text panel, keep small files, own chat commands, hooks per tick, frame and chat line | `horizon.wit`, `crates/horizon-mods` |
|
||||
| API 0.2 (content) | blocks and items declared as data, each copying a vanilla *template* block or item with its numbers changed; recipes, loot, tags, models, textures, lang and worldgen JSON in the mod's namespace | `content.rs`, the content root |
|
||||
| **API 0.4 preview (this spike)** | block entities for the mod's own blocks: game-owned slots and block state, a batched tick per mod, a saved state, the menu's data values, and the game's item, fuel and cooking-recipe facts | `machine` and `block-entities` in `horizon.wit`, [mods.md](../mods.md#block-entities-api-04-preview) |
|
||||
|
||||
Code still cannot touch the world. API 0.1 runs on the client and only reads the player; the runtime
|
||||
refuses `side = "server"` mods. API 0.2 is pure data: a template gives a mod block every behaviour
|
||||
Horizon already has for the vanilla block it copies, but nothing of its own.
|
||||
|
||||
The sandbox is good and stays: a memory cap, fuel per call (a runaway loop stops the mod, not the
|
||||
game), no files, sockets or processes, and capabilities the player sees before turning a mod on.
|
||||
|
||||
### The gaps for code mods
|
||||
|
||||
Each gap is a piece of API plus the game wiring behind it. Codes are used in the ranking below.
|
||||
|
||||
| Code | Gap | What it means | State |
|
||||
|---|---|---|---|
|
||||
| **T** | ticking block entities with saved state | per-block state, a tick, save and load | **runtime and SDK done here**; the game does not create or save them yet |
|
||||
| **V** | inventories | slots that hoppers, pipes, menus and drops see; slot rules per side (`WorldlyContainer`) | slots done here (game-owned); slot rules missing |
|
||||
| **M** | menus and screens | a slot layout, progress bars, buttons, the server-side click logic | data values (`data()`) done here; layout and screen missing |
|
||||
| **N** | client↔server networking | a mod's own packets between its two halves | missing (`net`, planned in mods.md) |
|
||||
| **C** | energy, fluid and item transfer between blocks | NeoForge capabilities: FE, fluid tanks, item handlers on neighbours | missing |
|
||||
| **R** | custom recipe types | machine recipes in the mod's own JSON, and their recipe-viewer layout | missing (vanilla cooking recipes are readable now) |
|
||||
| **B** | block behaviour hooks | use, place, break, neighbour change, random and scheduled ticks on mod blocks | missing (API 0.3) |
|
||||
| **I** | item behaviour hooks | use, use on block, inventory tick, attributes, tools and armour of the mod | missing (API 0.3) |
|
||||
| **E** | server events, player data, config | join, death, spawn, chat, commands with types; data attached to players and chunks; a config schema | missing (API 0.3) |
|
||||
| **H** | new hooks into vanilla behaviour | what a Mixin does: a named hook added to the engine for everyone | per hook |
|
||||
| **X** | entities and AI | entity types, attributes, goals, brains, spawning | missing (API 0.5) |
|
||||
| **D** | custom rendering | block-entity renderers, animated models (GeckoLib), custom baked models, item renderers | missing (API 0.5) |
|
||||
| **W** | dimensions and worldgen code | dimension types, chunk generators, portals, structure code | worldgen JSON carries; code missing |
|
||||
| **U** | client UI interfaces | `keys`, `target`, `inventory-read`, `ui`, `recipes` | missing (planned in mods.md) |
|
||||
| **A** | accessory slots | what Curios does | missing |
|
||||
| **S** | an embedded scripting VM | CC: Tweaked's Lua computers, Integrated Dynamics, SFM programs | missing |
|
||||
|
||||
## 2. ATM10's code mods, ranked by the gaps they hit
|
||||
|
||||
**Method.** Each of the 333 not-yet mods in ATM10's coverage report
|
||||
(`horizon-port-pack report`, 2026-10-19) got the set of gaps above it needs and a "players notice it"
|
||||
score: 3 for the pack-defining mods (Create, Mekanism, AE2, Refined Storage, Sophisticated Storage and
|
||||
Backpacks, Waystones, JEI, Jade, JourneyMap, FTB Quests, Ars Nouveau, Iron's Spells, Twilight Forest,
|
||||
Apotheosis, Farmer's Delight, Mystical Agriculture, Supplementaries, Allthemodium), 2 for mods most
|
||||
players meet, 1 for the rest. The gap sets are a judgement from what each mod does (its Modrinth
|
||||
categories, tier and, for the big ones, knowledge of the mod), not from reading 333 jars, so treat any
|
||||
single row as approximate and the totals as the signal. The table is
|
||||
[`docs/modpacks/atm10-code-gaps.csv`](../modpacks/atm10-code-gaps.csv) (metadata only: ids, names,
|
||||
licences, downloads, gaps, required mods).
|
||||
|
||||
Of the 333: **24 are data only** (AllTheOres, AllTheCompressed, Pam's crops, XTones...: their jars
|
||||
were not downloaded yet, no code needed), **1 is a library** (Kotlin for Forge, mislabelled), and
|
||||
**307 need code**: 221 content mods, 30 integrations, 27 client-UI mods, 18 unclassified and 11
|
||||
server-only.
|
||||
|
||||
### How many mods need each gap
|
||||
|
||||
| Gap | Mods that need it | Weighted by "players notice" |
|
||||
|---|---:|---:|
|
||||
| T ticking block entities | **162** | 233 |
|
||||
| N networking | 148 | 231 |
|
||||
| D custom rendering | 145 | 229 |
|
||||
| M menus and screens | 140 | 217 |
|
||||
| V inventories | 124 | 183 |
|
||||
| I item hooks | 93 | 154 |
|
||||
| B block hooks | 71 | 115 |
|
||||
| C energy/fluid/item transfer | 69 | 98 |
|
||||
| X entities and AI | 69 | 113 |
|
||||
| E server events, data, config | 47 | 70 |
|
||||
| R custom recipe types | 44 | 77 |
|
||||
| U client UI | 42 | 58 |
|
||||
| H vanilla hooks | 33 | 49 |
|
||||
| W dimensions | 18 | 32 |
|
||||
| A accessory slots | 13 | 23 |
|
||||
| S scripting VM | 8 | 9 |
|
||||
|
||||
**Block entities are the most common gap**: 162 of the 307 code mods need them. Of those 162, 123 also
|
||||
need inventories, 121 menus, 110 custom rendering, 69 transfer between blocks and 43 their own recipe
|
||||
types. The most common exact sets are "client UI only" (26 mods), **"block entity + inventory + menu +
|
||||
networking" (17)** and the same plus rendering and transfer (17).
|
||||
|
||||
### Unlocked by gap X
|
||||
|
||||
A mod is unlocked when every gap it needs is filled *and* the mods it requires are unlocked (AE2's 15
|
||||
add-ons wait for AE2). Filling the gaps cheapest first, then the machine stack in the order that
|
||||
unlocks the most at each step:
|
||||
|
||||
| Step | Adds | Mods unlocked (total) | Players notice (score 3) |
|
||||
|---|---|---:|---|
|
||||
| 1 | U client UI | 17 | |
|
||||
| 2 | N networking | 28 | JEI, Jade, JourneyMap |
|
||||
| 3 | B, I, E, H (API 0.3: logic) | 67 | |
|
||||
| 4 | T, V, M (machines core) | 91 | FTB Quests |
|
||||
| 5 | D custom rendering | 120 | Sophisticated Storage, Waystones |
|
||||
| 6 | C transfer | 145 | |
|
||||
| 7 | R recipe types | 189 | AE2, Refined Storage, Farmer's Delight, Mystical Agriculture |
|
||||
| 8 | X entities | 264 | Create, Mekanism, Ars Nouveau, Apotheosis, Supplementaries |
|
||||
| 9 | W dimensions | 283 | Allthemodium, Twilight Forest |
|
||||
| 10 | A accessory slots | 298 | Iron's Spells, Sophisticated Backpacks |
|
||||
| 11 | S scripting | 307 | |
|
||||
|
||||
What this says:
|
||||
|
||||
- **The cheap gaps unlock small mods.** Client UI, networking and the 0.3 logic hooks together unlock
|
||||
67 mods, but only JEI, Jade and JourneyMap among the ones players notice, and those three are better
|
||||
built into Horizon (a recipe viewer, a look-at tooltip, a map) than ported.
|
||||
- **Block entities are the backbone of the big tier.** Machines core (T, V, M) plus rendering (D),
|
||||
transfer (C) and recipe types (R) take the count from 67 to 189 and bring AE2, Refined Storage,
|
||||
Sophisticated Storage, Waystones, Farmer's Delight and Mystical Agriculture.
|
||||
- **Rendering is the hidden gate.** 110 block-entity mods also draw something of their own (a
|
||||
rotating shaft, items on a pedestal, a fluid level). A data-driven first step (animated models in a
|
||||
declared format, items and fluids drawn at declared spots) covers most of them without arbitrary
|
||||
draw code.
|
||||
- **Entities are the last big step to the flagships.** Create, Mekanism, Ars Nouveau and Apotheosis
|
||||
all need entities (contraptions, the Robit, familiars, bosses) on top of everything else.
|
||||
- Licences of the 307: 84 permissive, 33 copyleft, 39 All Rights Reserved, 16 custom, 7 restricted
|
||||
(non-commercial or no-derivatives) and 128 unknown (CurseForge-only, read from the jar at import).
|
||||
Ports stay local whatever the licence; only permissive or copyleft ones could ever be published.
|
||||
|
||||
## 3. Proof of concept: Iron Furnaces' iron furnace
|
||||
|
||||
**The pick.** Iron Furnaces (10.1 million Modrinth downloads, **Apache-2.0**) is the most downloaded
|
||||
ATM10 mod whose gaps are exactly the most common machine set: block entity, inventory, menu,
|
||||
networking. Its iron furnace is a ticking block entity with an inventory, a block state that follows
|
||||
it, fuel and cooking-recipe lookups, and menu data. The slice ported: **the iron furnace in furnace
|
||||
mode** (no augments, factory or generator modes, redstone settings, auto input/output or experience).
|
||||
|
||||
**The API pieces added** (generic, nothing Iron Furnaces-specific; behind tests):
|
||||
|
||||
- `horizon.wit`: interface `machine` (capability `block-entities`): slots and block-state properties
|
||||
of the block entities lent to the current tick; the game's max stack sizes, fuel burn times,
|
||||
remainders (a bucket from a lava bucket) and cooking recipes. Export `block-entities`: `types`,
|
||||
`create`, `tick(handles)`, `save`, `remove`, `data`. A second world, so mods built before keep
|
||||
loading.
|
||||
- `horizon-mods`: the runtime side. Types checked at start (own namespaces, once each, at most 256
|
||||
slots); one fuel-budgeted call per mod per tick for all its block entities; slot writes of unknown
|
||||
items refused, stacks cut to the item's maximum, property values the block does not allow refused;
|
||||
a mod sees only the handles lent to it. Manifest `api = "0.4"`, capability `block-entities`.
|
||||
- `horizon-mod-sdk`: the `Mod` trait gains `block_entity_types`, `create_block_entity`,
|
||||
`tick_block_entities`, `save_block_entity`, `remove_block_entity`, `block_entity_data`, all
|
||||
defaulted, so existing mods compile unchanged.
|
||||
- Tests: a test mod with a small smelter block entity (`crates/horizon-mods/tests/machine`) and three
|
||||
sandbox tests (tick, save and load; only its own handles and known items; refused without the
|
||||
capability or for a block it does not own).
|
||||
|
||||
**The port** is about 200 lines of Rust (local, in the dev-only modports folder; see below). It keeps
|
||||
the furnace's private state (burn time left, cooking progress and total) by handle, and reads and
|
||||
writes the game's slots and its `lit` property.
|
||||
|
||||
**The harness** runs the same scenarios on both sides and compares every tick:
|
||||
|
||||
- Java: `tools/neoforge_oracle.py machines` starts the dev-only NeoForge 21.1.259 server with the
|
||||
mod's jar and our exporter mod, places the block, fills its container, runs the block entity's own
|
||||
ticker N times and writes the slots, the block-state properties and the saved tag's fields after
|
||||
every tick (`MachineTrace.java`).
|
||||
- Horizon: `crates/horizon-client/examples/mod_machine_probe.rs` loads the `.hmod` through
|
||||
`horizon-mods` with **Horizon's own** item, fuel and recipe tables (26.3), and writes the same.
|
||||
- `tools/mod_machine_harness.py` compares the two.
|
||||
|
||||
| Scenario | Ticks | Equal |
|
||||
|---|---:|---:|
|
||||
| 10 raw iron, 2 coal (one coal smelts 8 items, 160 ticks each) | 1,800 | 1,800 |
|
||||
| cobblestone on a lava bucket (the bucket stays) | 500 | 500 |
|
||||
| a stick's fuel runs out mid-item (progress falls back 2 a tick) | 200 | 200 |
|
||||
| output slot fills up at 64 | 600 | 600 |
|
||||
| input swapped to kelp, taken out, raw gold put in, fuel taken | 400 | 400 |
|
||||
| no recipe: the coal is not burnt | 60 | 60 |
|
||||
| a wet sponge dried over an empty bucket fills it | 200 | 200 |
|
||||
| **all** | **3,760** | **3,760** |
|
||||
|
||||
The harness fails as it should: the same port with the tier speed set to vanilla's 200 instead of
|
||||
160 differs from the first tick in six of the seven scenarios.
|
||||
|
||||
**What the slice does not show yet.** The game does not create, tick or save mod block entities, so
|
||||
the furnace does not run in a world yet; its block and model come over through the content carry-over
|
||||
as any other block. That wiring (place and break, chunk save and load, hoppers through the slots,
|
||||
drops) is the next step and is generic. The menu needs the screen layout (M), and the other furnace
|
||||
modes need the gaps they name (energy for the generator: C).
|
||||
|
||||
**Licence.** Iron Furnaces is Apache-2.0, which allows publishing a derivative with its notice. The
|
||||
port still stays local (`~/code/horizon-parity/modports/atm10/codemods/ironfurnaces-port`, with a
|
||||
NOTICE) until Ludovic decides to publish ports and, if he wants, asks the authors (Qelifern and
|
||||
XenoMustache).
|
||||
|
||||
## 4. What porting costs
|
||||
|
||||
**This slice, measured** (one agent on the Mac mini, wall clock from its logs; the whole spike,
|
||||
ranking included, took under two hours, a third of it waiting for release builds):
|
||||
|
||||
| Step | Time | Once or per mod |
|
||||
|---|---|---|
|
||||
| Gap ranking of 333 mods (a judgement per mod, see section 2) | about 15 min | once |
|
||||
| API piece (WIT, runtime, SDK, tests) | about 15 min to write, 2 min of tests | once per gap |
|
||||
| Oracle machine trace, probe and harness | about 20 min, plus a 10 min release build | once |
|
||||
| Download, decompile, read the mod's furnace path | about 5 min | per mod |
|
||||
| Write the port (200 lines) | about 5 min | per mod |
|
||||
| Scenarios (7) | about 5 min | per mod (partly automatable) |
|
||||
| Harness: oracle 31 s (NeoForge start included), probe 2 s | 1 min | per run |
|
||||
|
||||
So for a machine of this size, **once the API exists, a port slice costs well under an hour of agent
|
||||
time**, and the harness passed on the first run. Small API pieces are cheap to write; what costs is
|
||||
wiring each into the game and proving it with harnesses, which the estimates below count. A whole small mod (Iron Furnaces with all its tiers,
|
||||
augments, modes, menus and recipe viewer pages) is an estimated 1–2 agent-days. A big system is a
|
||||
campaign: Mekanism, AE2 or Create are hundreds of thousands of lines of Java with their own
|
||||
networks, renderers and entities, so weeks each even with the API complete.
|
||||
|
||||
**The API gaps cost more than the ports.** Estimates in agent-days (build, game wiring, harness,
|
||||
review), from this spike and from comparable vanilla work in the repo:
|
||||
|
||||
| Gap | Agent-days | Notes |
|
||||
|---|---:|---|
|
||||
| T/V game wiring: create, tick, save, drop, hoppers, slot rules | 3–4 | builds on this PR |
|
||||
| M menus and screens (declared layouts the game draws, server click logic) | 4–6 | the container screens exist for vanilla |
|
||||
| N networking (single player is free; LAN and servers need packets) | 2–3 | |
|
||||
| B, I, E, H logic hooks (0.3) | 4–6, then about 0.5 per new vanilla hook | |
|
||||
| C capabilities (FE, fluids, item handlers on neighbours) | 3–5 | |
|
||||
| R custom recipe types and their viewer layout | 3–4 | |
|
||||
| U client UI interfaces | 5–8 | or build JEI/Jade/map natively |
|
||||
| D rendering: data-driven animated models first, then a budgeted draw list | 10–15 | the biggest |
|
||||
| X entities and AI | 10–15 | Horizon's mob AI exists for vanilla |
|
||||
| W dimensions and worldgen code | 8–12 | |
|
||||
| A accessory slots | 2–3 | |
|
||||
|
||||
**What agents can automate:** inventory and classification (done), fetching the player's jars,
|
||||
decompiling, carrying data and assets, writing the port from the decompiled reference and the
|
||||
harness, generating scenarios from the mod's own recipes and blocks, running the oracle and the
|
||||
harness, and fixing until green. The harness loop is what makes this safe to hand to agents: they
|
||||
cannot claim a port works without a tick-by-tick match.
|
||||
|
||||
**What agents cannot do alone:** decide to publish ports or contact authors (licences; Ludovic's
|
||||
call), design each new engine hook a Mixin needed (they can draft it, a reviewer must keep the engine
|
||||
clean and vanilla-exact), judge visual parity of custom renderers (image diffs help, a human looks),
|
||||
and follow mod updates (each update reruns the harness, and the port is fixed again).
|
||||
|
||||
## 5. Options for Ludovic
|
||||
|
||||
| Option | Gaps | Unlocks (of 307) | Cost (agent-days, gaps only) |
|
||||
|---|---|---:|---:|
|
||||
| **A. Machines first** | the 0.3 logic hooks (B, I, E, H), then T/V wiring, M, N, D, C, R | 148, incl. AE2, Refined Storage, Sophisticated Storage, Waystones, Farmer's Delight, Mystical Agriculture | 30–45 |
|
||||
| B. Cheap gaps first | U, N, B, I, E, H | 67, mostly small QoL mods (and JEI, Jade, JourneyMap) | 12–20 |
|
||||
| A and B | all of the above | 189 | 35–50 |
|
||||
| C. One flagship vertical | everything Create needs (B, C, D, H, I, N, R, T, V, X), then Create | Create + 12 add-ons, and on the way most of A | 35–55, then weeks for the port |
|
||||
| D. Stop at data | none: ATM10-like packs stay worldgen, decoration and ores | 0 more | 0 |
|
||||
|
||||
The machine stack needs the logic hooks: on its own (T, V, M, N, C, R, D without B, I, E, H) it
|
||||
unlocks only 53 mods, because most machines also have items and blocks that do something when used.
|
||||
The ports themselves come on top: under an hour per machine slice like the one above, days per small
|
||||
mod, weeks per big system.
|
||||
|
||||
**Recommendation: A, with JEI, Jade and a minimap built into Horizon itself.** Block entities are the
|
||||
backbone of the tech and magic mods players build their game around, the first piece is in, and its
|
||||
first port matched the Java mod on the first run. Building the three most-noticed client mods natively
|
||||
(a recipe viewer, a look-at tooltip, a minimap) gives ATM10 players what they use every minute without
|
||||
porting them. Rendering (D) comes right after machines because it gates 110 of the 162 block-entity
|
||||
mods; entities (X) and dimensions (W) come last, and with them Create, Mekanism, Ars Nouveau and
|
||||
Twilight Forest.
|
||||
|
||||
Next steps if Ludovic picks A: wire mod block entities into the game (create, tick, save, hoppers) and
|
||||
run the iron furnace in a world; then declared menus; then port a second mod of the same set
|
||||
(Cobblegen Galore, Step Crafter or Treetap) to measure the per-mod cost again.
|
||||
|
||||
## Decisions for Ludovic
|
||||
|
||||
1. **The path** (A, B, C or D above).
|
||||
2. **Publishing ports**: whether permissively licensed ports (Iron Furnaces is Apache-2.0) may ever
|
||||
be published, and whether to ask authors first. Until then every port stays local.
|
||||
3. **Who runs port agents**: only us (players download published ports) or players too, with their
|
||||
own AI login.
|
||||
@@ -52,7 +52,8 @@ import net.neoforged.neoforge.event.server.ServerStartedEvent;
|
||||
* Dev-only oracle for Horizon's mod ports (docs/modpacks.md): once the server has started,
|
||||
* writes every block, item, recipe and tag of the namespaces named in {@code
|
||||
* horizon.oracle.namespaces} (plus the vanilla blocks, so templates can be compared) to {@code
|
||||
* horizon.oracle.out}, then stops the server. Never shipped to players.
|
||||
* horizon.oracle.out}, then stops the server. With {@code horizon.oracle.machines} (a scenario
|
||||
* file) it traces block entities instead ({@link MachineTrace}). Never shipped to players.
|
||||
*/
|
||||
@Mod("horizon_oracle")
|
||||
public final class FactsExporter {
|
||||
@@ -68,9 +69,14 @@ public final class FactsExporter {
|
||||
Set<String> namespaces =
|
||||
new TreeSet<>(Arrays.asList(System.getProperty("horizon.oracle.namespaces", "").split(",")));
|
||||
namespaces.remove("");
|
||||
String machines = System.getProperty("horizon.oracle.machines", "");
|
||||
try (Writer w = Files.newBufferedWriter(out, StandardCharsets.UTF_8);
|
||||
JsonWriter json = new JsonWriter(w)) {
|
||||
new Export(server, namespaces, json).run();
|
||||
if (machines.isEmpty()) {
|
||||
new Export(server, namespaces, json).run();
|
||||
} else {
|
||||
MachineTrace.run(server, Path.of(machines), json);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
e.printStackTrace();
|
||||
System.exit(3);
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package dev.horizonlab.neoforge;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.Container;
|
||||
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.BlockEntity;
|
||||
import net.minecraft.world.level.block.entity.BlockEntityTicker;
|
||||
import net.minecraft.world.level.block.entity.BlockEntityType;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.Property;
|
||||
|
||||
/**
|
||||
* Dev-only machine oracle for Horizon's code-mod harness (tools/mod_machine_harness.py): places
|
||||
* each scenario's block, fills its container's slots, runs its block entity ticker N times and
|
||||
* writes the slots, the asked block-state properties and the asked integer fields of its saved
|
||||
* tag after every tick. The same scenarios run on the Horizon port through
|
||||
* crates/horizon-client/examples/mod_machine_probe.rs. Never shipped.
|
||||
*/
|
||||
final class MachineTrace {
|
||||
private static final BlockPos AT = new BlockPos(0, 100, 0);
|
||||
|
||||
private MachineTrace() {}
|
||||
|
||||
static void run(MinecraftServer server, Path scenarios, JsonWriter json) throws IOException {
|
||||
ServerLevel level = server.overworld();
|
||||
JsonArray list = JsonParser.parseString(Files.readString(scenarios)).getAsJsonArray();
|
||||
json.beginArray();
|
||||
for (JsonElement e : list) {
|
||||
scenario(server, level, e.getAsJsonObject(), json);
|
||||
}
|
||||
json.endArray();
|
||||
}
|
||||
|
||||
private static ItemStack stack(JsonElement v) {
|
||||
if (v == null || v.isJsonNull()) {
|
||||
return ItemStack.EMPTY;
|
||||
}
|
||||
JsonArray a = v.getAsJsonArray();
|
||||
return new ItemStack(BuiltInRegistries.ITEM.get(ResourceLocation.parse(a.get(0).getAsString())),
|
||||
a.get(1).getAsInt());
|
||||
}
|
||||
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
private static BlockState with(BlockState state, String name, String value) {
|
||||
for (Property<?> p : state.getProperties()) {
|
||||
if (p.getName().equals(name)) {
|
||||
Property raw = p;
|
||||
java.util.Optional<Comparable> parsed = raw.getValue(value);
|
||||
return parsed.isPresent() ? (BlockState) state.setValue(raw, parsed.get()) : state;
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
private static String property(BlockState state, String name) {
|
||||
for (Property<?> p : state.getProperties()) {
|
||||
if (p.getName().equals(name)) {
|
||||
return String.valueOf(state.getValue(p));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void scenario(MinecraftServer server, ServerLevel level, JsonObject s, JsonWriter json)
|
||||
throws IOException {
|
||||
Block block = BuiltInRegistries.BLOCK.get(ResourceLocation.parse(s.get("block").getAsString()));
|
||||
BlockState state = block.defaultBlockState();
|
||||
JsonObject properties = s.has("properties") ? s.getAsJsonObject("properties") : new JsonObject();
|
||||
for (Map.Entry<String, JsonElement> p : properties.entrySet()) {
|
||||
state = with(state, p.getKey(), p.getValue().getAsJsonArray().get(0).getAsString());
|
||||
}
|
||||
level.setBlock(AT, state, 3);
|
||||
BlockEntity be = level.getBlockEntity(AT);
|
||||
if (!(be instanceof Container container)) {
|
||||
throw new IllegalStateException(s.get("block") + " has no container block entity");
|
||||
}
|
||||
if (s.has("slots")) {
|
||||
for (Map.Entry<String, JsonElement> slot : s.getAsJsonObject("slots").entrySet()) {
|
||||
container.setItem(Integer.parseInt(slot.getKey()), stack(slot.getValue()));
|
||||
}
|
||||
}
|
||||
JsonArray fields = s.has("fields") ? s.getAsJsonArray("fields") : new JsonArray();
|
||||
JsonArray events = s.has("events") ? s.getAsJsonArray("events") : new JsonArray();
|
||||
json.beginObject();
|
||||
json.name("name").value(s.get("name").getAsString());
|
||||
json.name("trace").beginArray();
|
||||
int ticks = s.get("ticks").getAsInt();
|
||||
for (int tick = 0; tick < ticks; tick++) {
|
||||
for (JsonElement e : events) {
|
||||
JsonObject ev = e.getAsJsonObject();
|
||||
if (ev.get("tick").getAsInt() == tick) {
|
||||
container.setItem(ev.get("slot").getAsInt(), stack(ev.get("stack")));
|
||||
}
|
||||
}
|
||||
BlockState now = level.getBlockState(AT);
|
||||
BlockEntityTicker<BlockEntity> ticker =
|
||||
now.getTicker(level, (BlockEntityType<BlockEntity>) be.getType());
|
||||
if (ticker != null) {
|
||||
ticker.tick(level, AT, now, be);
|
||||
}
|
||||
now = level.getBlockState(AT);
|
||||
CompoundTag tag = be.saveWithoutMetadata(server.registryAccess());
|
||||
json.beginObject();
|
||||
json.name("slots").beginObject();
|
||||
for (int i = 0; i < container.getContainerSize(); i++) {
|
||||
ItemStack item = container.getItem(i);
|
||||
if (!item.isEmpty()) {
|
||||
json.name(Integer.toString(i)).beginArray()
|
||||
.value(BuiltInRegistries.ITEM.getKey(item.getItem()).toString())
|
||||
.value(item.getCount()).endArray();
|
||||
}
|
||||
}
|
||||
json.endObject();
|
||||
json.name("properties").beginObject();
|
||||
for (String name : properties.keySet()) {
|
||||
json.name(name).value(property(now, name));
|
||||
}
|
||||
json.endObject();
|
||||
json.name("fields").beginObject();
|
||||
for (JsonElement f : fields) {
|
||||
json.name(f.getAsString()).value(tag.getInt(f.getAsString()));
|
||||
}
|
||||
json.endObject();
|
||||
json.endObject();
|
||||
}
|
||||
json.endArray();
|
||||
json.endObject();
|
||||
container.clearContent();
|
||||
level.setBlock(AT, Blocks.AIR.defaultBlockState(), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Machine harness for code-mod ports (docs/research/code-mods-plan.md): the same scenarios on the
|
||||
Java mod in the dev-only NeoForge oracle and on its Horizon port, compared tick by tick.
|
||||
|
||||
mod_machine_harness.py --jars DIR [--deps DIR] --hmod PORT.hmod --scenarios S.json --work DIR
|
||||
[--prepared .local/horizon-client/26.3] [--oracle TRACE.json]
|
||||
|
||||
A scenario places a block, fills slots, runs N ticks (with slot changes at given ticks) and
|
||||
records after every tick the slots, the listed block-state properties and the listed integer
|
||||
fields of the saved block entity (the Java tag's names; the port saves the same names as JSON).
|
||||
Writes WORK/oracle-trace.json, WORK/horizon-trace.json and WORK/result.json, and exits 1 on a
|
||||
difference. The jars and the port stay in the work folder: nothing here holds mod code.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def compare(oracle, horizon):
|
||||
"""Per scenario: ticks compared, ticks equal, the first differences."""
|
||||
out = []
|
||||
by_name = {s['name']: s['trace'] for s in horizon}
|
||||
for scenario in oracle:
|
||||
name, want = scenario['name'], scenario['trace']
|
||||
got = by_name.get(name, [])
|
||||
equal, diffs = 0, []
|
||||
for tick, (w, g) in enumerate(zip(want, got)):
|
||||
if w == g:
|
||||
equal += 1
|
||||
elif len(diffs) < 5:
|
||||
keys = sorted({*w, *g})
|
||||
diffs.append({'tick': tick, **{k: {'java': w.get(k), 'horizon': g.get(k)}
|
||||
for k in keys if w.get(k) != g.get(k)}})
|
||||
if len(want) != len(got):
|
||||
diffs.append({'ticks': {'java': len(want), 'horizon': len(got)}})
|
||||
out.append({'name': name, 'ticks': len(want), 'equal': equal, 'differences': diffs})
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument('--jars', help='the Java mod (and its namesakes); not needed with --oracle')
|
||||
p.add_argument('--deps')
|
||||
p.add_argument('--hmod', required=True)
|
||||
p.add_argument('--scenarios', required=True)
|
||||
p.add_argument('--work', required=True)
|
||||
p.add_argument('--prepared', default=str(ROOT / '.local' / 'horizon-client' / '26.3'))
|
||||
p.add_argument('--oracle', help='an oracle trace already written')
|
||||
args = p.parse_args()
|
||||
work = Path(args.work)
|
||||
work.mkdir(parents=True, exist_ok=True)
|
||||
oracle_path = Path(args.oracle) if args.oracle else work / 'oracle-trace.json'
|
||||
if not args.oracle:
|
||||
if not args.jars:
|
||||
sys.exit('--jars or --oracle')
|
||||
command = [sys.executable, str(ROOT / 'tools' / 'neoforge_oracle.py'), 'machines', '--jars', args.jars,
|
||||
'--scenarios', args.scenarios, '--out', str(oracle_path)]
|
||||
if args.deps:
|
||||
command += ['--deps', args.deps]
|
||||
subprocess.run(command, check=True)
|
||||
horizon_path = work / 'horizon-trace.json'
|
||||
subprocess.run(['cargo', 'run', '--release', '-q', '-p', 'horizon-client', '--example', 'mod_machine_probe',
|
||||
'--', args.prepared, args.hmod, args.scenarios, str(horizon_path)], cwd=ROOT, check=True)
|
||||
result = compare(json.loads(oracle_path.read_text()), json.loads(horizon_path.read_text()))
|
||||
(work / 'result.json').write_text(json.dumps(result, indent=1))
|
||||
failed = False
|
||||
for r in result:
|
||||
ok = not r['differences']
|
||||
failed |= not ok
|
||||
print(f"{'PASS' if ok else 'FAIL'} {r['name']}: {r['equal']}/{r['ticks']} ticks equal")
|
||||
for d in r['differences'][:3]:
|
||||
print(' ', json.dumps(d))
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -2,11 +2,14 @@
|
||||
"""Dev-only NeoForge oracle for Horizon's mod-port harnesses (docs/modpacks.md).
|
||||
|
||||
neoforge_oracle.py export --jars DIR [--deps DIR] [--namespaces a,b] --out FACTS.json
|
||||
neoforge_oracle.py machines --jars DIR [--deps DIR] --scenarios S.json --out TRACE.json
|
||||
|
||||
installs a NeoForge server under .local/neoforge-oracle (once), builds the exporter mod in
|
||||
mods/horizon/src/neoforge-oracle, starts the server headless with the given mod jars, and writes every
|
||||
block, item, recipe and tag of the mods' namespaces (and the vanilla blocks, for templates) to
|
||||
FACTS.json. The jars are the user's own; nothing here is shipped or committed.
|
||||
FACTS.json. `machines` instead places each scenario's block, ticks its block entity and writes its
|
||||
slots, block state and saved fields per tick (tools/mod_machine_harness.py). The jars are the user's
|
||||
own; nothing here is shipped or committed.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
@@ -226,6 +229,23 @@ def export(args):
|
||||
f'({namespaces}); server ran {seconds:.1f} s')
|
||||
|
||||
|
||||
def machines(args):
|
||||
jars = sorted(Path(args.jars).glob('*.jar'))
|
||||
deps = sorted(Path(args.deps).glob('*.jar')) if args.deps else []
|
||||
home = install()
|
||||
exporter = build(home)
|
||||
home, run = instance('machines', jars, [*deps, exporter])
|
||||
out = Path(args.out).resolve()
|
||||
out.unlink(missing_ok=True)
|
||||
seconds, log = start(home, run, {'horizon.oracle.out': out,
|
||||
'horizon.oracle.machines': Path(args.scenarios).resolve()})
|
||||
if not out.exists():
|
||||
tail = '\n'.join(log.splitlines()[-40:])
|
||||
sys.exit(f'the oracle wrote nothing; the end of {run / "server.log"}:\n{tail}')
|
||||
traces = json.loads(out.read_text())
|
||||
print(f'{out}: {len(traces)} scenarios; server ran {seconds:.1f} s')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = parser.add_subparsers(dest='command', required=True)
|
||||
@@ -234,8 +254,13 @@ def main():
|
||||
e.add_argument('--deps', help='their required libraries (loaded, not exported)')
|
||||
e.add_argument('--namespaces', help='comma-separated; default: the jars\' mod ids')
|
||||
e.add_argument('--out', required=True)
|
||||
m = sub.add_parser('machines', help='trace the block entities of the scenarios in --scenarios')
|
||||
m.add_argument('--jars', required=True, help='the mods')
|
||||
m.add_argument('--deps', help='their required libraries')
|
||||
m.add_argument('--scenarios', required=True)
|
||||
m.add_argument('--out', required=True)
|
||||
args = parser.parse_args()
|
||||
{'export': export}[args.command](args)
|
||||
{'export': export, 'machines': machines}[args.command](args)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in new issue
Block a user