Generate the native mod API into Cargo OUT_DIR

Build every profile from the catalog, fragments and handwritten signatures. Keep xtask for filling handwritten placeholders, and stamping for catalog data. Stage launcher-owned declarations as build input, retaining shared injection validation for older source trees with committed generated traits.
This commit is contained in:
arismoko committed 2026-11-03 02:14:54 +00:00
1 parent b193a87711
commit 9116ac3911
34 files changed
+2533 -18557

No files matched your search

-5
View File
@@ -4,8 +4,3 @@ docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
crates/horizon-theme/tokens.css text eol=lf
docs/api/*-reference.md text eol=lf
# Generated by `cargo run -p xtask -- mod-api`; `--check` compares them byte for byte.
crates/horizon-client/src/mod_api/generated.rs linguist-generated=true text eol=lf
crates/horizon-client/src/mod_api/generated_host.rs linguist-generated=true text eol=lf
crates/horizon-client/src/game/mod_api_host_generated.rs linguist-generated=true text eol=lf
crates/horizon-client-render/src/gfx/native_render/generated.rs linguist-generated=true text eol=lf
Generated
+13 -2
View File
@@ -1324,6 +1324,7 @@ dependencies = [
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mod-api-gen",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
@@ -1455,6 +1456,7 @@ dependencies = [
"horizon-cosmetics",
"horizon-hdr",
"horizon-items",
"horizon-mod-api-gen",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
@@ -1566,6 +1568,16 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-mod-api-gen"
version = "0.1.0"
dependencies = [
"quote",
"serde",
"serde_json",
"syn 2.0.119",
]
[[package]]
name = "horizon-mods"
version = "0.1.0"
@@ -5144,10 +5156,9 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
name = "xtask"
version = "0.1.0"
dependencies = [
"horizon-mod-api-gen",
"horizon-theme",
"quote",
"serde_json",
"syn 2.0.119",
]
[[package]]
+1
View File
@@ -31,6 +31,7 @@ members = [
"crates/horizon-friends",
"crates/horizon-relay",
"crates/horizon-mods",
"crates/horizon-mod-api-gen",
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
+3
View File
@@ -71,6 +71,9 @@ audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
[build-dependencies]
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
[lints]
workspace = true
+7
View File
@@ -0,0 +1,7 @@
fn main() {
let root =
std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("../..");
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
horizon_mod_api_gen::generate(&root, &out_dir, horizon_mod_api_gen::Target::Render)
.expect("generating native render mod API");
}
@@ -11,7 +11,9 @@ use std::sync::{
atomic::{AtomicBool, Ordering},
};
mod generated;
mod generated {
include!(concat!(env!("OUT_DIR"), "/native_render_generated.rs"));
}
pub use generated::{HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
pub use wgpu;
@@ -1,52 +0,0 @@
// @generated by cargo run -p xtask -- mod-api; do not edit.
use super::*;
pub trait RenderHooks: Send + Sync {
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
fn world_drawn(&self, event: &mut WorldDrawnEvent<'_, '_>) {
let _ = event;
}
/// Asked as a frame is built and drawn: set what your mod needs.
///
/// Every mod gets `event.needs` in order and may add, change or remove needs.
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
let _ = event;
}
/// The first-person hand is drawn (see [`HandDrawn`]), on every frame [`native_world_drawn`]
/// runs, with no hand too. API @API:hand_drawn@.
fn hand_drawn(&self, event: &mut HandDrawnEvent<'_, '_>) {
let _ = event;
}
}
/// What [`RenderHooks::world_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct WorldDrawnEvent<'a, 'b> {
pub world: &'a mut WorldDrawn<'b>,
}
pub fn dispatch_world_drawn(mods: &[&dyn RenderHooks], world: &mut WorldDrawn<'_>) {
let mut event = WorldDrawnEvent { world };
for mod_hook in mods {
mod_hook.world_drawn(&mut event);
}
}
/// What [`RenderHooks::render_needs`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct RenderNeedsEvent<'a> {
pub needs: &'a mut RenderNeeds,
}
pub fn dispatch_render_needs(mods: &[&dyn RenderHooks], needs: &mut RenderNeeds) {
let mut event = RenderNeedsEvent { needs };
for mod_hook in mods {
mod_hook.render_needs(&mut event);
}
}
/// What [`RenderHooks::hand_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct HandDrawnEvent<'a, 'b> {
pub hand: &'a mut HandDrawn<'b>,
}
pub fn dispatch_hand_drawn(mods: &[&dyn RenderHooks], hand: &mut HandDrawn<'_>) {
let mut event = HandDrawnEvent { hand };
for mod_hook in mods {
mod_hook.hand_drawn(&mut event);
}
}
+3
View File
@@ -142,6 +142,9 @@ path = "tests/launch_api.rs"
name = "mod_api_hooks"
path = "tests/mod_api_hooks.rs"
[build-dependencies]
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
[lints]
workspace = true
+5
View File
@@ -2,6 +2,11 @@
//! resource 1: Explorer and the task bar show it, and the window takes it as its icon.
//! The optional Segment Heap manifest is embedded in the same resource compilation.
fn main() {
let root =
std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("../..");
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
horizon_mod_api_gen::generate(&root, &out_dir, horizon_mod_api_gen::Target::Client)
.expect("generating native mod API");
let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
let ico = format!("{dir}/../../packaging/icons/horizon.ico");
println!("cargo:rerun-if-changed={ico}");
@@ -151,8 +151,9 @@ impl Game<'_> {
}
}
#[path = "mod_api_host_generated.rs"]
mod generated;
mod generated {
include!(concat!(env!("OUT_DIR"), "/mod_api_host_generated.rs"));
}
impl horizon_client::mod_api::LevelHost for super::GameLevel<'_> {
fn block_state(&self, pos: [i32; 3]) -> BlockStateId {
@@ -1,567 +0,0 @@
// @generated by cargo run -p xtask -- mod-api; do not edit.
use horizon_client::mod_api::*;
use std::path::PathBuf;
use std::sync::Arc;
impl Host for super::Game<'_> {
fn draw_quad(
&self,
draw: &mut BlockEntityDraw,
sprite: &str,
corners: [[f32; 3]; 4],
uv: [f32; 4],
tint: u32,
) -> bool {
self.native_draw_quad(draw, sprite, corners, uv, tint)
}
fn draw_cube(
&self,
draw: &mut BlockEntityDraw,
from: [f32; 3],
to: [f32; 3],
faces: [Option<CubeFace<'_>>; 6],
tint: u32,
) -> bool {
self.native_draw_cube(draw, from, to, faces, tint)
}
fn draw_block(
&self,
draw: &mut BlockEntityDraw,
block: &str,
properties: &[(&str, &str)],
) -> bool {
self.native_draw_block(draw, block, properties)
}
fn draw_item(
&self,
draw: &mut BlockEntityDraw,
stack: &ItemStack,
display: DisplayContext,
owner_yaw: Option<f32>,
seed: i32,
) -> bool {
self.native_draw_item(draw, stack, display, owner_yaw, seed)
}
fn attachments(&mut self, holder: Holder) -> AttachmentResult<&mut Attachments> {
self.native_attachments(holder)
}
fn teleport_mob(&mut self, dimension: &str, id: u32, pos: [f64; 3]) -> bool {
self.native_teleport_mob(dimension, id, pos)
}
fn push_mob(&mut self, dimension: &str, id: u32, velocity: [f64; 3]) -> bool {
self.native_push_mob(dimension, id, velocity)
}
fn set_mob_health(&mut self, dimension: &str, id: u32, health: f32) -> bool {
self.native_set_mob_health(dimension, id, health)
}
fn kill_mob(&mut self, dimension: &str, id: u32) -> bool {
self.native_kill_mob(dimension, id)
}
fn remove_mob(&mut self, dimension: &str, id: u32) -> bool {
self.native_remove_mob(dimension, id)
}
fn summon(&mut self, dimension: &str, kind: &str, pos: [f64; 3], nbt: Compound) -> Option<u32> {
self.native_summon(dimension, kind, pos, nbt)
}
fn move_entity(
&mut self,
dimension: &str,
id: u32,
pos: [f64; 3],
yaw: f32,
pitch: f32,
) -> bool {
self.native_move_entity(dimension, id, pos, yaw, pitch)
}
fn remove_entity(&mut self, dimension: &str, id: u32) -> bool {
self.native_remove_entity(dimension, id)
}
fn server_players(&self) -> Vec<NativeServerPlayer> {
self.native_server_players()
}
fn tell(&mut self, uuid: u128, line: Text) -> bool {
self.native_tell(uuid, line)
}
fn action_bar(&mut self, uuid: u128, line: Text) -> bool {
self.native_action_bar(uuid, line)
}
fn tab_list(&mut self, uuid: u128, header: &Text, footer: &Text) -> bool {
self.native_tab_list(uuid, header, footer)
}
fn tick_stats(&self) -> (f32, f64) {
self.native_tick_stats()
}
fn sleep_ignored(&mut self, uuid: u128, ignored: bool) -> bool {
self.native_sleep_ignored(uuid, ignored)
}
fn set_nickname(&mut self, uuid: u128, nickname: Option<&str>) -> bool {
self.native_set_nickname(uuid, nickname)
}
fn text_allowed(&self, text: &str) -> Option<bool> {
self.native_text_allowed(text)
}
fn broadcast(&self, line: Text) {
self.native_broadcast(line)
}
fn feedback(&mut self, line: Text) {
self.native_feedback(line)
}
fn acting_player(&self) -> Option<NativeServerPlayer> {
self.native_acting_player()
}
fn run_command(&mut self, line: &str) -> bool {
self.native_run_command(line)
}
fn world_dir(&self) -> Option<PathBuf> {
self.native_world_dir()
}
fn held(&self) -> Option<(String, u32, bool)> {
self.native_held()
}
fn count_items(&mut self, item: &str) -> u32 {
self.native_count_items(item)
}
fn carried_items(&self) -> Vec<(String, u32)> {
self.native_carried_items()
}
fn take_items(&mut self, item: &str, count: u32) -> u32 {
self.native_take_items(item, count)
}
fn room_for(&mut self, item: &str) -> u32 {
self.native_room_for(item)
}
fn give_items(&mut self, item: &str, count: u32) -> u32 {
self.native_give_items(item, count)
}
fn give_stack(&mut self, uuid: u128, stack: ItemStack) -> Option<ItemStack> {
self.native_give_stack(uuid, stack)
}
fn give_experience(&mut self, uuid: u128, points: u32) -> bool {
self.native_give_experience(uuid, points)
}
fn give_levels(&mut self, uuid: u128, levels: i32) -> bool {
self.native_give_levels(uuid, levels)
}
fn lit_block_name(&self, pos: [i32; 3]) -> Option<&str> {
self.native_lit_block_name(pos)
}
fn flame_particle(&mut self, at: [f64; 3], soul: bool) {
self.native_flame_particle(at, soul)
}
fn carried_stack(&mut self, slot: i32) -> Option<&mut ItemStack> {
self.native_carried_stack(slot)
}
fn set_block(
&mut self,
dimension: &str,
pos: [i32; 3],
state: BlockStateId,
flags: i32,
) -> bool {
self.native_set_block(dimension, pos, state, flags)
}
fn edit_blocks(&mut self, dimension: &str, edit: &NativeBlockEdit) -> Result<u64, String> {
self.native_edit_blocks(dimension, edit)
}
fn surface_heights(
&self,
dimension: &str,
columns: &[(i32, i32)],
heightmap: NativeHeightmap,
) -> Result<Vec<Option<i32>>, String> {
self.native_surface_heights(dimension, columns, heightmap)
}
fn read_columns(
&self,
dimension: &str,
columns: &[(i32, i32)],
heightmap: NativeHeightmap,
range: (u32, u32),
) -> Result<NativeColumnBlocks, String> {
self.native_read_columns(dimension, columns, heightmap, range)
}
fn refresh_biome_colors(&mut self) {
self.native_refresh_biome_colors()
}
fn neighbor_signal(&mut self, dimension: &str, pos: [i32; 3]) -> i32 {
self.native_neighbor_signal(dimension, pos)
}
fn light(&self, pos: [i32; 3]) -> (u8, u8) {
self.native_light(pos)
}
fn weather(&self) -> (f32, f32) {
self.native_weather()
}
fn day_time(&self) -> i64 {
self.native_day_time()
}
fn world_height(&self) -> (i32, i32) {
self.native_world_height()
}
fn max_stack_size(&self, item: &str) -> Option<u32> {
self.native_max_stack_size(item)
}
fn item_model(
&self,
batch: &mut HudBatch,
item: &str,
model: &str,
at: [f32; 2],
size: f32,
) -> bool {
self.native_item_model(batch, item, model, at, size)
}
fn quad(&self, batch: &mut HudBatch, points: [[f32; 2]; 4], argb: u32) {
self.native_quad(batch, points, argb)
}
fn mod_screen_open(&mut self, mod_id: &str) -> bool {
self.native_mod_screen_open(mod_id)
}
fn mod_screen_close(&mut self, mod_id: &str) {
self.native_mod_screen_close(mod_id)
}
fn mod_screen(&self) -> Option<&str> {
self.native_mod_screen()
}
fn set_experience_orb(
&mut self,
dimension: &str,
id: u32,
value: Option<u32>,
count: Option<u32>,
age: Option<u32>,
) -> bool {
self.native_set_experience_orb(dimension, id, value, count, age)
}
fn experience_orbs(
&self,
dimension: &str,
min: [f64; 3],
max: [f64; 3],
) -> Vec<NativeExperienceOrb> {
self.native_experience_orbs(dimension, min, max)
}
fn clock(&self) -> Option<NativeClock> {
self.native_clock()
}
fn spawn_falling_block(
&mut self,
dimension: &str,
state: BlockStateId,
pos: [f64; 3],
velocity: [f64; 3],
falling: NativeFalling,
) -> bool {
self.native_spawn_falling_block(dimension, state, pos, velocity, falling)
}
fn block_state_property(&self, state: BlockStateId, key: &str) -> Option<&str> {
self.native_block_state_property(state, key)
}
fn raycast(&self, distance: f32) -> Option<NativeHit> {
self.native_raycast(distance)
}
fn view_hit(&self) -> Option<(f32, NativeHit)> {
self.native_view_hit()
}
fn claim_items(&mut self, mod_id: &str, ids: &[String]) {
self.native_claim_items(mod_id, ids)
}
fn poll(&mut self, mod_id: &str) -> Vec<NativeModEvent> {
self.native_poll(mod_id)
}
fn set_item_entity(&mut self, dimension: &str, id: u32, stack: ItemStack) -> bool {
self.native_set_item_entity(dimension, id, stack)
}
fn update_output_neighbors(&mut self, dimension: &str, pos: [i32; 3]) -> bool {
self.native_update_output_neighbors(dimension, pos)
}
fn update_signal_neighbors(&mut self, dimension: &str, pos: [i32; 3]) -> bool {
self.native_update_signal_neighbors(dimension, pos)
}
fn block_entity_sync(&mut self, pos: [i32; 3]) {
self.native_block_entity_sync(pos)
}
fn play_local_sound(
&mut self,
sound: &str,
at: [f64; 3],
volume: f32,
pitch: f32,
delay: i32,
) -> bool {
self.native_play_local_sound(sound, at, volume, pitch, delay)
}
fn fill(&self, batch: &mut HudBatch, rect: [f32; 4], argb: u32) {
self.native_fill(batch, rect, argb)
}
fn sprite(&self, batch: &mut HudBatch, name: &str, src: [f32; 4], dst: [f32; 4]) -> bool {
self.native_sprite(batch, name, src, dst)
}
fn item(&self, batch: &mut HudBatch, item: &str, at: [f32; 2], size: f32) -> bool {
self.native_item(batch, item, at, size)
}
fn screen_title(&self) -> Option<String> {
self.native_screen_title()
}
fn data(&self, path: &str) -> Option<Vec<u8>> {
self.native_data(path)
}
fn block(&self, pos: [i32; 3]) -> String {
self.native_block(pos)
}
fn block_property(&self, pos: [i32; 3], key: &str) -> Option<String> {
self.native_block_property(pos, key)
}
fn insert_item(
&mut self,
pos: [i32; 3],
face: Direction,
stack: ItemStack,
) -> Option<ItemStack> {
self.native_insert_item(pos, face, stack)
}
fn cursor(&self) -> [f32; 2] {
self.native_cursor()
}
fn text(&self, batch: &mut HudBatch, text: &str, at: [f32; 2], rgb: u32, shadow: bool) {
self.native_text(batch, text, at, rgb, shadow)
}
fn built_data(&self, mod_id: &str) -> Option<PathBuf> {
self.native_built_data(mod_id)
}
fn on(&self, mod_id: &str) -> bool {
self.native_on(mod_id)
}
fn mod_key(&self, mod_id: &str, name: &str, default: &str) -> String {
self.native_mod_key(mod_id, name, default)
}
fn setting(&self, mod_id: &str, key: &str) -> Option<String> {
self.native_setting(mod_id, key)
}
fn set_setting(&self, mod_id: &str, key: &str, value: &str) {
self.native_set_setting(mod_id, key, value)
}
fn set_block_property(&mut self, pos: [i32; 3], key: &str, value: &str) -> bool {
self.native_set_block_property(pos, key, value)
}
fn set_block_property_with(
&mut self,
pos: [i32; 3],
key: &str,
value: &str,
flags: i32,
) -> bool {
self.native_set_block_property_with(pos, key, value, flags)
}
fn config_dir(&self) -> Result<PathBuf, String> {
Self::native_config_dir()
}
fn panel_items(&mut self, mod_id: &str, items: Vec<NativeStack>) {
self.native_panel_items(mod_id, items)
}
fn recipes_of_type(&self, kind: &str) -> &[CustomRecipe] {
self.native_recipes_of_type(kind)
}
fn recipe_ingredient(&self, json: &serde_json::Value) -> Option<Ingredient> {
self.native_recipe_ingredient(json)
}
fn recipe_result(&self, json: &serde_json::Value) -> Option<ItemStack> {
self.native_recipe_result(json)
}
fn known_recipes(&mut self) -> Arc<[i32]> {
self.native_known_recipes()
}
fn place_recipe(&mut self, book_id: i32, all: bool) -> Result<(), String> {
self.native_place_recipe(book_id, all)
}
fn give(&mut self, item: &str, count: u32) -> Result<(), String> {
self.native_give(item, count)
}
fn push_player(&mut self, kick: [f32; 3]) {
self.native_push_player(kick)
}
fn set_flying_speed(&mut self, player: u128, speed: Option<f32>) -> bool {
self.native_set_flying_speed(player, speed)
}
fn net_send_to_player(
&mut self,
player: u128,
channel: &str,
data: &[u8],
) -> Result<(), String> {
self.native_net_send_to_player(player, channel, data)
}
fn net_send_to_server(&mut self, channel: &str, data: &[u8]) -> Result<(), String> {
self.native_net_send_to_server(channel, data)
}
fn dispense_default(
&mut self,
dimension: &str,
pos: [i32; 3],
facing: Direction,
stack: ItemStack,
) -> Option<ItemStack> {
self.native_dispense_default(dimension, pos, facing, stack)
}
fn open_menu(
&mut self,
uuid: u128,
rows: u8,
title: Text,
items: Vec<Option<ItemStack>>,
) -> Option<i32> {
self.native_open_menu(uuid, rows, title, items)
}
fn set_menu_items(&mut self, uuid: u128, items: Vec<Option<ItemStack>>) {
self.native_set_menu_items(uuid, items)
}
fn close_menu(&mut self, uuid: u128) {
self.native_close_menu(uuid)
}
fn player_stacks(&self, uuid: u128) -> Option<Vec<Option<ItemStack>>> {
self.native_player_stacks(uuid)
}
fn exchange_items(
&mut self,
a: u128,
a_offer: &[(usize, ItemStack)],
b: u128,
b_offer: &[(usize, ItemStack)],
) -> Result<(), String> {
self.native_exchange_items(a, a_offer, b, b_offer)
}
fn fluid_insert(&mut self, pos: [i32; 3], face: Direction, fluid: &str, amount: i32) -> i32 {
self.native_fluid_insert(pos, face, fluid, amount)
}
fn fluid_extract(
&mut self,
dimension: &str,
pos: [i32; 3],
face: Direction,
amount: i32,
) -> Option<(String, i32)> {
self.native_fluid_extract(dimension, pos, face, amount)
}
fn move_fluids(
&mut self,
dimension: &str,
from: [i32; 3],
from_face: Direction,
to: [i32; 3],
to_face: Direction,
max: i32,
) -> Option<(String, i32)> {
self.native_move_fluids(dimension, from, from_face, to, to_face, max)
}
fn dimension(&self) -> &'static str {
self.native_dimension()
}
fn extract_item(
&mut self,
dimension: &str,
pos: [i32; 3],
face: Direction,
slot: usize,
count: u32,
) -> Option<ItemStack> {
self.native_extract_item(dimension, pos, face, slot, count)
}
fn move_items(
&mut self,
dimension: &str,
from: [i32; 3],
from_face: Direction,
to: [i32; 3],
to_face: Direction,
item: Option<&str>,
max: u32,
) -> u32 {
self.native_move_items(dimension, from, from_face, to, to_face, item, max)
}
fn hide_player(&mut self, uuid: u128, level: Option<u8>) -> bool {
self.native_hide_player(uuid, level)
}
fn item_stack(&self, batch: &mut HudBatch, stack: &ItemStack, at: [f32; 2], size: f32) -> bool {
self.native_item_stack(batch, stack, at, size)
}
fn mod_image(&mut self, width: u32, height: u32, rgba: &[u8]) -> bool {
self.native_mod_image(width, height, rgba)
}
fn instance_dir(&self) -> PathBuf {
self.native_instance_dir()
}
fn camera(&self) -> Option<NativeCamera> {
self.native_camera()
}
fn block_collision(&self, pos: [i32; 3]) -> Vec<[f64; 6]> {
self.native_block_collision(pos)
}
fn entity(&self, id: u32) -> Option<EntityRef> {
self.native_entity(id)
}
fn level(&self, dimension: &str) -> Option<LevelRef> {
self.native_level(dimension)
}
fn entity_position(&self, entity: EntityRef) -> Option<[f64; 3]> {
self.native_entity_position(entity)
}
fn entity_block_position(&self, entity: EntityRef) -> Option<[i32; 3]> {
self.native_entity_block_position(entity)
}
fn entity_rotation(&self, entity: EntityRef) -> Option<[f32; 2]> {
self.native_entity_rotation(entity)
}
fn entity_kind(&self, entity: EntityRef) -> Option<String> {
self.native_entity_kind(entity)
}
fn entity_uuid(&mut self, entity: EntityRef) -> Option<u128> {
self.native_entity_uuid(entity)
}
fn entity_level(&self, entity: EntityRef) -> Option<LevelRef> {
self.native_entity_level(entity)
}
fn entity_velocity(&self, entity: EntityRef) -> Option<[f64; 3]> {
self.native_entity_velocity(entity)
}
fn entity_set_velocity(&mut self, entity: EntityRef, velocity: [f64; 3]) -> bool {
self.native_entity_set_velocity(entity, velocity)
}
fn entity_on_ground(&self, entity: EntityRef) -> Option<bool> {
self.native_entity_on_ground(entity)
}
fn entity_bounding_box(&self, entity: EntityRef) -> Option<[[f64; 3]; 2]> {
self.native_entity_bounding_box(entity)
}
fn entity_eye_height(&self, entity: EntityRef) -> Option<f64> {
self.native_entity_eye_height(entity)
}
fn entity_eye_position(&self, entity: EntityRef) -> Option<[f64; 3]> {
self.native_entity_eye_position(entity)
}
fn entity_view_vector(&self, entity: EntityRef) -> Option<[f64; 3]> {
self.native_entity_view_vector(entity)
}
fn entity_distance_to_sqr(&self, entity: EntityRef, pos: [f64; 3]) -> Option<f64> {
self.native_entity_distance_to_sqr(entity, pos)
}
fn item_container_slots(&self, stack: &ItemStack) -> Option<Vec<Option<ItemStack>>> {
self.native_item_container_slots(stack)
}
fn item_container_insert(
&self,
stack: &ItemStack,
item: &ItemStack,
) -> Option<(u32, ItemStack)> {
self.native_item_container_insert(stack, item)
}
fn item_container_extract(
&self,
stack: &ItemStack,
slot: usize,
max: u32,
) -> Option<(Option<ItemStack>, ItemStack)> {
self.native_item_container_extract(stack, slot, max)
}
fn recipe_book_category(&mut self, kind: &str) -> Option<RecipeBookCategory> {
self.native_recipe_book_category(kind)
}
}
+7 -3
View File
@@ -2,7 +2,7 @@
//! The executable passes the ordered `Mods` to `entry::main_with`.
//! Trait mods are process-wide, like mixin module statics.
//! Hooks use `&self + Send + Sync` for shared and reentrant calls; see `docs/native-mods.md`.
//! Generate the trait and dispatch with `cargo run -p xtask -- mod-api`.
//! Cargo generates the trait and dispatch from the catalog and source signatures into OUT_DIR.
use std::sync::{Arc, OnceLock};
pub use crate::biome::BiomeColors;
@@ -528,7 +528,9 @@ pub enum Flow {
Cancel,
}
mod generated_host;
mod generated_host {
include!(concat!(env!("OUT_DIR"), "/mod_api_generated_host.rs"));
}
pub use generated_host::Host;
/// Result of accessing or decoding an attachment.
@@ -607,7 +609,9 @@ macro_rules! level_attachment_host {
}
level_attachment_host!(LevelRandomHost, LevelWriteHost);
mod generated;
mod generated {
include!(concat!(env!("OUT_DIR"), "/mod_api_generated.rs"));
}
pub use generated::Hooks;
#[doc(hidden)]
pub use generated::*;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "horizon-mod-api-gen"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
description = "Build-time typed native mod API generation"
[dependencies]
syn = { version = "2", features = ["full", "visit", "visit-mut"] }
quote = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[lints]
workspace = true
+143
View File
@@ -0,0 +1,143 @@
//! Cargo-facing generation and the launcher's trait injection boundary.
use super::{Target, catalog_hooks, free_function_source, sources};
use quote::ToTokens;
use std::{collections::HashSet, fs, path::Path};
/// Generate only this crate's files, tracking every source read by the generator.
/// `root` is supplied at run time by the consuming crate's build script.
pub fn generate(root: &Path, out_dir: &Path, target: Target) -> Result<(), String> {
track(root)?;
let generated = sources(root, super::FillMode::Require)?;
let outputs = match target {
Target::Render => vec![("native_render_generated.rs", generated.render)],
Target::Client => {
println!("cargo:rerun-if-env-changed=HORIZON_MOD_API_INJECTIONS");
let hooks = if let Some(path) = std::env::var_os("HORIZON_MOD_API_INJECTIONS") {
let path = Path::new(&path);
println!("cargo:rerun-if-changed={}", path.display());
let injections =
fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
inject(&generated.hooks, &injections)?
} else {
generated.hooks
};
vec![
("mod_api_generated.rs", hooks),
("mod_api_generated_host.rs", generated.host),
("mod_api_host_generated.rs", generated.implementation),
]
}
};
for (name, code) in outputs {
syn::parse_file(&code).map_err(|e| format!("generated Rust: {e}"))?;
fs::write(out_dir.join(name), code).map_err(|e| e.to_string())?;
}
Ok(())
}
pub(super) fn track(root: &Path) -> Result<(), String> {
fn directory(path: &Path, extension: &str, recursive: bool) -> Result<(), String> {
// Track the directory as well: adding a helper source or fragment changes the inputs.
println!("cargo:rerun-if-changed={}", path.display());
for entry in fs::read_dir(path).map_err(|e| e.to_string())? {
let path = entry.map_err(|e| e.to_string())?.path();
if path.is_dir() && recursive {
directory(&path, extension, recursive)?;
} else if path.extension().is_some_and(|ext| ext == extension) {
println!("cargo:rerun-if-changed={}", path.display());
}
}
Ok(())
}
for path in ["native-hooks.json", "crates/horizon-client/src/mod_api.rs"] {
println!("cargo:rerun-if-changed={}", root.join(path).display());
}
directory(&root.join("tools/native-hook-fragments"), "json", false)?;
directory(&root.join("crates/horizon-client/src/game"), "rs", true)?;
let catalog = serde_json::from_str(
&fs::read_to_string(root.join("native-hooks.json")).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
for hook in catalog_hooks(root, &catalog)? {
if let Some((krate, path)) =
free_function_source(hook["target"].as_str().ok_or("target missing")?)
{
let (module, _) = path.rsplit_once("::").ok_or("target missing module")?;
let path = root
.join("crates")
.join(krate)
.join("src")
.join(format!("{}.rs", module.replace("::", "/")));
println!("cargo:rerun-if-changed={}", path.display());
}
}
Ok(())
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Injection {
name: String,
event: String,
declaration: String,
event_declaration: String,
}
/// Add the launcher's trait methods and event structs to the generated catalog API.
/// The JSON file is the boundary: parse declarations once, then work with typed Rust items.
pub fn inject(code: &str, injections: &str) -> Result<String, String> {
let injections: Vec<Injection> =
serde_json::from_str(injections).map_err(|e| format!("trait injections: {e}"))?;
if injections.is_empty() {
return Ok(code.to_owned());
}
let mut file = syn::parse_file(code).map_err(|e| format!("generated API: {e}"))?;
let mut events: HashSet<String> = file
.items
.iter()
.filter_map(|item| match item {
syn::Item::Struct(item) => Some(item.ident.to_string()),
syn::Item::Trait(item) => Some(item.ident.to_string()),
_ => None,
})
.collect();
let hooks = file
.items
.iter_mut()
.find_map(|item| match item {
syn::Item::Trait(item) if item.ident == "Hooks" => Some(item),
_ => None,
})
.ok_or("generated API missing Hooks trait")?;
let mut methods: HashSet<_> = hooks
.items
.iter()
.filter_map(|item| match item {
syn::TraitItem::Fn(method) => Some(method.sig.ident.to_string()),
_ => None,
})
.collect();
let mut structs = Vec::new();
for injection in injections {
let method: syn::TraitItemFn = syn::parse_str(&injection.declaration)
.map_err(|e| format!("trait injection {}: {e}", injection.name))?;
let event: syn::ItemStruct = syn::parse_str(&injection.event_declaration)
.map_err(|e| format!("trait injection {}: {e}", injection.event))?;
if method.sig.ident != injection.name || event.ident != injection.event {
return Err("trait injection declaration does not match its name".into());
}
if !methods.insert(injection.name.clone()) {
return Err(format!(
"duplicate generated trait method {}",
injection.name
));
}
if !events.insert(injection.event.clone()) {
return Err(format!("duplicate generated event {}", injection.event));
}
hooks.items.push(syn::TraitItem::Fn(method));
structs.push(syn::Item::Struct(event));
}
file.items.extend(structs);
Ok(file.into_token_stream().to_string())
}
@@ -0,0 +1,44 @@
use super::inject;
fn injection(name: &str, event: &str) -> serde_json::Value {
serde_json::json!({
"name": name,
"event": event,
"declaration": format!("fn {name}(&self) {{}}"),
"event_declaration": format!("pub struct {event};"),
})
}
#[test]
fn rejects_duplicate_methods_and_event_names_across_injections() {
let api = "pub trait Hooks: Send + Sync {}";
for declarations in [
vec![
injection("tick", "TickEvent"),
injection("tick", "OtherEvent"),
],
vec![
injection("tick", "TickEvent"),
injection("other", "TickEvent"),
],
] {
assert!(inject(api, &serde_json::to_string(&declarations).unwrap()).is_err());
}
}
#[test]
fn rejects_malformed_or_misnamed_declarations_at_the_boundary() {
let api = "pub trait Hooks: Send + Sync {}";
let mut wrong_method = injection("tick", "TickEvent");
wrong_method["declaration"] = "fn other(&self) {}".into();
let mut wrong_event = injection("tick", "TickEvent");
wrong_event["event_declaration"] = "pub struct OtherEvent;".into();
let mut invalid_rust = injection("tick", "TickEvent");
invalid_rust["event_declaration"] = "not a struct".into();
for declaration in [wrong_method, wrong_event, invalid_rust] {
assert!(inject(api, &serde_json::json!([declaration]).to_string()).is_err());
}
for invalid in ["{}", "[{}]", "null", "not json"] {
assert!(inject(api, invalid).is_err());
}
}
File diff suppressed because it is too large. Load diff
+9 -5
View File
@@ -2979,14 +2979,18 @@ assert!(claimed);
A new hook needs its fragment in `tools/native-hook-fragments/` with its `mod_api` entry (for example
`{"contract": "cancel", "out": "cancel"}`), its `native_*` function with the placeholder body
`let _ = (every, parameter);` (all its parameters, in order), and a run of `cargo run -p xtask -- mod-api`.
The generator adds the hook's trait method and replaces the placeholder with the `dispatch_<hook>` call.
Every hook needs a `mod_api` entry: the check fails naming any hook without one. A body that is neither the
That command only replaces the placeholder in `native_hooks.rs` with the `dispatch_<hook>` call; commit
that source change and the fragment. The `horizon-mod-api-gen` build dependency generates the hook traits,
`Host` methods and dispatch code into Cargo's `OUT_DIR` when the game and renderer build, including release
builds. These generated Rust files are not kept in the source tree or committed, and no manual regeneration
is needed after a merge. Every hook needs a `mod_api` entry: the build fails naming any hook without one.
A body that is neither the
placeholder nor already calls the dispatch is an error, and the generator never changes it. Hooks that are
free functions outside `Game` are wired by hand: those crates can't see the mod list, so each calls a callback
the executable installs once, such as `NativeLoadHooks` in `horizon-world` and `NativeClientHooks` in the
client library (`menu_slot_rules`, `living_breathe`, `ingredient`). `cargo run -p xtask -- mod-api --check`
and the `generated_output_is_current` test report a placeholder still to fill instead of filling it. Mixin
injections go at the head of the body, before the dispatch, so they still run first.
client library (`menu_slot_rules`, `living_breathe`, `ingredient`). A build reports a placeholder still to
fill instead of changing hand-written source; run `cargo run -p xtask -- mod-api` to fill it. Mixin injections
go at the head of the body, before the dispatch, so they still run first.
### Server attachments
+11
View File
@@ -2684,6 +2684,7 @@ dependencies = [
"horizon-core",
"horizon-friends",
"horizon-mcp",
"horizon-mod-api-gen",
"horizon-mods",
"horizon-port-pack",
"horizon-prepare",
@@ -2727,6 +2728,16 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-mod-api-gen"
version = "0.1.0"
dependencies = [
"quote",
"serde",
"serde_json",
"syn 2.0.119",
]
[[package]]
name = "horizon-mods"
version = "0.1.0"
+2
View File
@@ -8,6 +8,8 @@ description = "Downloads, prepares and starts Horizon builds"
[workspace]
[dependencies]
# The catalog and mod-owned trait injections share their generation rules.
horizon-mod-api-gen = { path = "../crates/horizon-mod-api-gen" }
# GPUI Fast (longbridge/gpui-fast): Zed's GPUI with retained views and cached scroll layers, under
# its own name. wgpu renders on Linux, not blade (#3812).
gpui = { package = "gpui-fast", version = "0.1.13" }
+43
View File
@@ -1525,6 +1525,13 @@ fn cargo(rust: &Toolchain, dir: &Path, subcommand: &str) -> Command {
cmd.arg("--config").arg(config);
}
cmd.arg("--manifest-path").arg(dir.join("Cargo.toml"));
// Only this stage's declarations apply, never a path inherited from the launcher's shell.
let injections = dir.join(inject::INPUT);
if injections.is_file() {
cmd.env(inject::ENV, injections);
} else {
cmd.env_remove(inject::ENV);
}
cmd
}
@@ -3218,6 +3225,42 @@ mod tests {
);
}
#[test]
fn staged_cargo_uses_only_its_own_injections_including_cached_output() {
let d = dir("injection-env");
let fresh = d.join("fresh");
let snapshot = d.join("snapshot");
let stage = d.join("stage");
fs::create_dir_all(fresh.join("crates/horizon-client")).unwrap();
fs::write(
fresh.join("crates/horizon-client/Cargo.toml"),
"[package]\nname = 'horizon-client'\nversion = '0.1.0'\n[build-dependencies]\nhorizon-mod-api-gen = { path = '../horizon-mod-api-gen' }\n",
)
.unwrap();
let rust = Toolchain {
dir: d.join("rust"),
};
let env = |dir: &Path| {
cargo(&rust, dir, "build")
.get_envs()
.find(|(key, _)| *key == inject::ENV)
.map(|(_, value)| value.map(std::ffi::OsStr::to_owned))
};
assert_eq!(env(&stage), Some(None));
inject::stage(&fresh, &[]).unwrap();
sync(&fresh, &snapshot).unwrap();
sync(&snapshot, &stage).unwrap();
assert_eq!(fs::read(stage.join(inject::INPUT)).unwrap(), b"[]");
assert_eq!(
env(&stage),
Some(Some(stage.join(inject::INPUT).into_os_string()))
);
fs::remove_file(fresh.join(inject::INPUT)).unwrap();
sync(&fresh, &stage).unwrap();
assert_eq!(env(&stage), Some(None));
fs::remove_dir_all(d).unwrap();
}
/// The server build (docs/server.md): compiled headless off the same source, with its own
/// key, and the game build's flags unchanged.
#[test]
+19 -2
View File
@@ -12,7 +12,7 @@ pub(super) fn valid_id(id: &str) -> bool {
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
fn read(path: &Path) -> Result<toml::Table, String> {
pub(super) fn read(path: &Path) -> Result<toml::Table, String> {
let text = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
text.parse().map_err(|e| format!("{}: {e}", path.display()))
}
@@ -242,6 +242,12 @@ pub(super) fn stage(
("build".into(), "../horizon-client/build.rs".into()),
])),
);
// This sibling package uses the client's build.rs, so its build dependencies apply too;
// relative paths still point to the same crates from crates/native-game.
let client = read(&stage.join("crates/horizon-client/Cargo.toml"))?;
if let Some(dependencies) = client.get("build-dependencies") {
manifest.insert("build-dependencies".into(), dependencies.clone());
}
let windows = toml::Table::from_iter([(
"build-dependencies".into(),
toml::Value::Table(toml::Table::from_iter([(
@@ -372,8 +378,13 @@ mod tests {
let dir = fixture("[mod]\nid = 'example'\nversion = '1'\n[api]\npath = 'engine'\n");
let bundle = super::super::bundle(&dir, None).unwrap();
let stage_dir = dir.join("workspace");
fs::create_dir_all(&stage_dir).unwrap();
fs::create_dir_all(stage_dir.join("crates/horizon-client")).unwrap();
fs::write(stage_dir.join("Cargo.toml"), "[workspace]\nmembers = []\n").unwrap();
fs::write(
stage_dir.join("crates/horizon-client/Cargo.toml"),
"[package]\nname = 'horizon-client'\nversion = '0.1.0'\n",
)
.unwrap();
let resolution = super::super::dependencies::Resolution { order: vec![0] };
assert!(stage(&stage_dir, &[bundle], &[vec![]], &resolution).unwrap());
let root = read(&stage_dir.join("Cargo.toml")).unwrap();
@@ -431,9 +442,15 @@ mod tests {
"pub fn name_mods(ids: &'static [&'static str]) {}\n",
)
.unwrap();
fs::write(stage_dir.join("crates/horizon-client/Cargo.toml"), "[package]\nname = 'horizon-client'\nversion = '0.1.0'\n[build-dependencies]\nhorizon-mod-api-gen = { path = '../horizon-mod-api-gen' }\n").unwrap();
let bundle = super::super::bundle(&dir, None).unwrap();
stage(&stage_dir, &[bundle], &[vec![]], &resolution).unwrap();
assert!(main().contains(r#"name_mods(&["example"]);"#));
let game = read(&stage_dir.join(GAME).join("Cargo.toml")).unwrap();
assert_eq!(
game["build-dependencies"]["horizon-mod-api-gen"]["path"].as_str(),
Some("../horizon-mod-api-gen")
);
fs::remove_dir_all(dir).unwrap();
}
+109 -88
View File
@@ -31,7 +31,7 @@ enum Contract {
/// Turns a binding's type into its event field's: a `&mut` primitive scalar becomes the value,
/// written back after the call; every elided reference lifetime becomes `'a`, or `'b` inside a
/// `&mut`, which can't shorten it. A copy of xtask's rule for catalog hooks
/// (`xtask/src/mod_api.rs`, `EventLifetimes`); both test the same types.
/// (`horizon-mod-api-gen/src/lib.rs`, `EventLifetimes`); both test the same types.
#[derive(Default)]
struct EventLifetimes {
a: bool,
@@ -384,51 +384,42 @@ impl Injection {
}
/// Add declarations to the existing generated trait, not its dispatch implementations.
pub(super) fn stage(stage: &Path, injections: &[Injection]) -> Result<(), String> {
if injections.is_empty() {
return Ok(());
impl Injection {
fn input(&self) -> serde_json::Value {
serde_json::json!({
"name": self.name.to_string(),
"event": self.event.to_string(),
"declaration": self.declaration(),
"event_declaration": self.event(),
})
}
}
pub(super) const INPUT: &str = ".horizon-mod-api-injections.json";
pub(super) const ENV: &str = "HORIZON_MOD_API_INJECTIONS";
/// New sources consume declarations in their build script; released sources still commit the
/// generated trait. Both paths use the same generation and collision checks, without rustfmt.
pub(super) fn stage(stage: &Path, injections: &[Injection]) -> Result<(), String> {
let client = api::read(&stage.join("crates/horizon-client/Cargo.toml"))?;
let build_time = client
.get("build-dependencies")
.and_then(toml::Value::as_table)
.is_some_and(|deps| deps.contains_key("horizon-mod-api-gen"));
let declarations = injections.iter().map(Injection::input).collect::<Vec<_>>();
let input = serde_json::to_string(&declarations).map_err(|e| e.to_string())?;
if build_time {
// This file travels with cached mixin output; generated Rust lives in Cargo's OUT_DIR.
let path = stage.join(INPUT);
fs::write(&path, input).map_err(|e| format!("{}: {e}", path.display()))
} else if injections.is_empty() {
Ok(())
} else {
let path = stage.join("crates/horizon-client/src/mod_api/generated.rs");
let source = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let file = syn::parse_file(&source).map_err(|e| format!("{}: {e}", path.display()))?;
let hooks = file
.items
.iter()
.find_map(|i| match i {
syn::Item::Trait(t) if t.ident == "Hooks" => Some(t),
_ => None,
})
.ok_or("generated Hooks trait missing")?;
for injection in injections {
if hooks
.items
.iter()
.any(|i| matches!(i, syn::TraitItem::Fn(f) if f.sig.ident == injection.name))
{
return Err(format!("trait method {} already exists", injection.name));
let output = horizon_mod_api_gen::inject(&source, &input)?;
fs::write(&path, output).map_err(|e| format!("{}: {e}", path.display()))
}
if file
.items
.iter()
.any(|i| matches!(i, syn::Item::Struct(s) if s.ident == injection.event))
{
return Err(format!("event {} already exists", injection.event));
}
}
let marker = "pub trait Hooks: Send + Sync {\n";
if source.matches(marker).count() != 1 {
return Err("generated Hooks marker missing or ambiguous".into());
}
let declarations = injections
.iter()
.map(Injection::declaration)
.collect::<String>();
let events = injections.iter().map(Injection::event).collect::<String>();
fs::write(
path,
source.replacen(marker, &format!("{marker}{declarations}"), 1) + &events,
)
.map_err(|e| e.to_string())
}
#[cfg(test)]
@@ -455,6 +446,42 @@ mod tests {
(root, bundle)
}
/// Emulate a released source tree or the current build script, including its inject() call.
fn staged_hooks(
stage_dir: &Path,
injections: &[Injection],
build_time: bool,
existing: &str,
) -> Result<String, String> {
let client = stage_dir.join("crates/horizon-client");
fs::create_dir_all(client.join("src/mod_api")).unwrap();
let dependencies = if build_time {
"[build-dependencies]\nhorizon-mod-api-gen = { path = '../horizon-mod-api-gen' }\n"
} else {
""
};
fs::write(
client.join("Cargo.toml"),
format!("[package]\nname = 'horizon-client'\nversion = '0.1.0'\n{dependencies}"),
)
.unwrap();
let generated = client.join("src/mod_api/generated.rs");
if !build_time {
fs::write(&generated, existing).unwrap();
}
stage(stage_dir, injections)?;
if build_time {
assert!(!generated.exists());
horizon_mod_api_gen::inject(
existing,
&fs::read_to_string(stage_dir.join(INPUT)).unwrap(),
)
} else {
assert!(!stage_dir.join(INPUT).exists());
Ok(fs::read_to_string(generated).unwrap())
}
}
fn compile_and_run(root: &Path, program: &str) {
let source = root.join("proof.rs");
let binary = root.join(format!("proof-bin{}", std::env::consts::EXE_SUFFIX));
@@ -486,15 +513,15 @@ mod tests {
let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
assert!(!manifest.contains("trait_method"));
assert!(!manifest.contains("[api]"));
let stage_dir = root.join("stage/crates/horizon-client/src/mod_api");
fs::create_dir_all(&stage_dir).unwrap();
fs::write(
stage_dir.join("generated.rs"),
for build_time in [false, true] {
let stage_dir = root.join(if build_time { "build-time" } else { "legacy" });
let output = staged_hooks(
&stage_dir,
&injections,
build_time,
"pub trait Hooks: Send + Sync {\n}\n",
)
.unwrap();
stage(&root.join("stage"), &injections).unwrap();
let output = fs::read_to_string(stage_dir.join("generated.rs")).unwrap();
assert!(syn::parse_file(&output).is_ok());
let program = format!(
r#"
@@ -527,6 +554,7 @@ mod tests {
"#
);
compile_and_run(&root, &program);
}
fs::remove_dir_all(root).unwrap();
}
@@ -535,15 +563,15 @@ mod tests {
let (root, bundle) = fixture("value", "[[injection]]\nid = 'hook'\ntarget = 'horizon_client::game::tick'\nkind = 'RETURN'\ntrait_method = 'total'\nbindings = [{ name = 'total', ty = 'i32', value = '$value' }, { name = 'calls', ty = '&mut u32', value = '&mut *calls' }]\n");
let injections = prepare(&[bundle], &root).unwrap();
let helper = fs::read_to_string(root.join("horizon-trait-injection-0.rs")).unwrap();
let stage_dir = root.join("stage/crates/horizon-client/src/mod_api");
fs::create_dir_all(&stage_dir).unwrap();
fs::write(
stage_dir.join("generated.rs"),
for build_time in [false, true] {
let stage_dir = root.join(if build_time { "build-time" } else { "legacy" });
let output = staged_hooks(
&stage_dir,
&injections,
build_time,
"pub trait Hooks: Send + Sync {\n}\n",
)
.unwrap();
stage(&root.join("stage"), &injections).unwrap();
let output = fs::read_to_string(stage_dir.join("generated.rs")).unwrap();
let program = format!(
r#"
mod mod_api {{
@@ -582,50 +610,43 @@ mod tests {
"#
);
compile_and_run(&root, &program);
}
fs::remove_dir_all(root).unwrap();
}
#[test]
fn event_fields_name_their_lifetimes() {
use quote::ToTokens;
for (ty, field, scalar, a, b) in [
fn staged_input_checks_conflicts_and_clears_removed_injections() {
let (root, bundle) = fixture("conflicts", "[[injection]]\nid = 'hook'\ntarget = 'horizon_client::game::tick'\nkind = 'HEAD'\ntrait_method = 'tick'\n");
let injections = prepare(&[bundle], &root).unwrap();
for build_time in [false, true] {
let stage_dir = root.join(if build_time { "build-time" } else { "legacy" });
for (existing, expected) in [
(
"& mut WorldDrawn < '_ >",
"& 'a mut WorldDrawn < 'b >",
false,
true,
true,
),
("& str", "& 'a str", false, true, false),
(
"& mut Vec < (& 'static str , u32) >",
"& 'a mut Vec < (& 'static str , u32) >",
false,
true,
false,
"pub trait Hooks: Send + Sync { fn inject_example_tick(&self) {} }",
"duplicate generated trait method",
),
(
"Option < & ItemStack >",
"Option < & 'a ItemStack >",
false,
true,
false,
),
("u32", "u32", false, false, false),
("& mut f32", "f32", true, false, false),
(
"& mut Option < bool >",
"& 'a mut Option < bool >",
false,
true,
false,
"pub trait Hooks: Send + Sync {} pub struct InjectExampleTickEvent;",
"duplicate generated event",
),
] {
let mut lifetimes = EventLifetimes::default();
let (field_type, is_scalar) = lifetimes.field(&syn::parse_str(ty).unwrap());
assert_eq!(field_type.to_token_stream().to_string(), field);
assert_eq!((is_scalar, lifetimes.a, lifetimes.b), (scalar, a, b));
let error =
staged_hooks(&stage_dir, &injections, build_time, existing).unwrap_err();
assert!(error.contains(expected), "{error}");
}
let output = staged_hooks(
&stage_dir,
&[],
build_time,
"pub trait Hooks: Send + Sync {}",
)
.unwrap();
assert!(!output.contains("inject_example_tick"));
if build_time {
assert_eq!(fs::read_to_string(stage_dir.join(INPUT)).unwrap(), "[]");
}
}
fs::remove_dir_all(root).unwrap();
}
#[test]
+1 -1
View File
@@ -34,7 +34,7 @@ pub(super) fn key(
let mut hash = Sha256::new();
for part in [
// Bump when launcher generation or validation changes the snapshot's contents.
"trait-injection-v2",
"trait-injection-v3",
revision,
mixins,
// The Cargo profile: `Performance` and `Dev` stage the same source.
+13 -7
View File
@@ -107,14 +107,20 @@ class PrReadyTests(unittest.TestCase):
" --features horizon-mods/index")
self.assertEqual(pr_ready.mod_features(None), MODS)
def test_mod_api_check_tracks_hook_inputs(self):
for path in ("xtask/src/mod_api.rs", "native-hooks.json",
"crates/horizon-client/src/game/native_hooks.rs", "README.md"):
def test_mod_api_data_inputs_build_and_test_the_consuming_crates(self):
packages = {**PACKAGES, "horizon-client-render": "crates/horizon-client-render",
"horizon-mod-api-gen": "crates/horizon-mod-api-gen"}
for path in ("native-hooks.json", "tools/native-hook-fragments/player.json"):
with self.subTest(path=path), mock.patch.object(pr_ready, "affected_here", return_value=[]):
steps = pr_ready.plan(args(quick=True), "origin/main", {path}, PACKAGES, LIBRARIES, DEPENDENCIES)
step = {step.name: step for step in steps}["mod API up to date"]
self.assertEqual(step.command, "cargo test --locked -p xtask mod_api")
self.assertEqual(step.skip, "native hooks untouched" if path == "README.md" else None)
steps = pr_ready.plan(args(quick=True), "origin/main", {path}, packages,
LIBRARIES | {"horizon-client-render", "horizon-mod-api-gen"}, DEPENDENCIES)
by_name = {step.name: step for step in steps}
self.assertIsNone(by_name["clippy"].skip)
self.assertIsNone(by_name["rustfmt"].skip)
tests = next(step for step in steps if step.name.startswith("tests:"))
self.assertIsNone(tests.skip)
self.assertEqual(tests.command, "cargo test --locked -p horizon-client -p horizon-client-render "
"-p horizon-mod-api-gen --tests")
@unittest.skipUnless(POSIX_BASH, "needs a bash that is not WSL's")
def test_the_launcher_fallback_asks_for_a_feature_the_launcher_has(self):
+26 -7
View File
@@ -102,16 +102,35 @@ class StampTests(unittest.TestCase):
self.assertEqual(hooks, [{"name": "served", "version": 1, "target": "target::served",
"side": "server", "cancellable": True, "mod_api": contract}])
def test_markers_are_stamped_in_the_mod_api_files(self):
def test_markers_are_stamped_in_hand_written_api_types(self):
self.git("add", ".")
self.git("commit", "-qm", "base")
self.fragment("drops")
for path in stamp.MOD_API:
(self.root / path).parent.mkdir(parents=True, exist_ok=True)
(self.root / path).write_text("/// Since API @API:drops@.\n")
path = self.root / stamp.MOD_API
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("/// Since API @API:drops@.\n")
self.assertFalse(stamp.stamp(False))
for path in stamp.MOD_API:
self.assertEqual((self.root / path).read_text(), "/// Since API 8.\n")
self.assertEqual(path.read_text(), "/// Since API 8.\n")
def test_stamping_leaves_stale_generated_copies_alone(self):
# Old checkouts may still have these files; only the generator's inputs are stamped now.
generated = (
"crates/horizon-client/src/mod_api/generated.rs",
"crates/horizon-client/src/mod_api/generated_host.rs",
"crates/horizon-client/src/game/mod_api_host_generated.rs",
"crates/horizon-client-render/src/gfx/native_render/generated.rs",
)
self.git("add", ".")
self.git("commit", "-qm", "base")
self.fragment("drops")
for name in generated:
path = self.root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("/// Since API @API:drops@.\n")
self.assertFalse(stamp.stamp(False))
self.assertIn("NATIVE_HOOKS_API: u32 = 8;", (self.root / stamp.SOURCE).read_text())
for name in generated:
self.assertEqual((self.root / name).read_text(), "/// Since API @API:drops@.\n")
def test_duplicate_fails_before_writes(self):
self.git("add", ".")
@@ -129,7 +148,7 @@ class StampTests(unittest.TestCase):
repo = self.original
fragments = [p.relative_to(repo) for p in (repo / stamp.FRAGMENTS).glob("*.json")]
comments = {Path(c) for p in fragments for c in json.loads((repo / p).read_text()).get("comments", [])}
stamped = [stamp.SOURCE, stamp.DOCS, *stamp.MOD_API, *comments]
stamped = [stamp.SOURCE, stamp.DOCS, stamp.MOD_API, *comments]
for path in [stamp.CATALOG, *stamped, *fragments]:
if (repo / path).exists():
(self.root / path).parent.mkdir(parents=True, exist_ok=True)
+8 -13
View File
@@ -75,14 +75,8 @@ in_shell() { if [ "$(uname)" = Linux ] && ! pkg-config --exists xkbcommon 2>/dev
nix-shell shell.nix --run "$1"; else bash -c "$1"; fi; }"""
# What `cargo run -p xtask -- mod-api` reads: the catalog, the hook points and helpers (every file of
# the game), the mod API's own types, and the free hook points of other crates.
MOD_API_INPUTS = (
"xtask/", "native-hooks.json", "crates/horizon-client/src/game/",
"crates/horizon-client/src/mod_api", "crates/horizon-world/src/loot.rs",
"crates/horizon-world/src/item_defaults.rs",
"crates/horizon-client-render/src/gfx/native_render",
)
# Build-script inputs outside the crates: catalog-only edits still need to build and test their consumers.
MOD_API_INPUTS = ("native-hooks.json", "tools/native-hook-fragments/")
def is_doc(path):
return path.endswith(".md")
@@ -90,7 +84,8 @@ def is_doc(path):
def workspace_rust(path):
"""Whether a change to path is built by the main Cargo workspace."""
return not is_doc(path) and (path.startswith(("crates/", "xtask/", ".cargo/")) or path in ROOT_RUST)
return not is_doc(path) and (path.startswith(("crates/", "xtask/", ".cargo/", *MOD_API_INPUTS))
or path in ROOT_RUST)
def launcher_rust(path):
@@ -106,6 +101,9 @@ def touched_crates(paths, packages):
for path in paths:
if is_doc(path):
continue
if path.startswith(MOD_API_INPUTS):
crates.update({"horizon-client", "horizon-client-render", "horizon-mod-api-gen"})
continue
if "horizon-client-lib" in packages and path.startswith("crates/horizon-client/"):
relative = path.removeprefix("crates/horizon-client/")
gameplay = relative in {"Cargo.toml", "build.rs", "windows-segment-heap.manifest",
@@ -383,9 +381,6 @@ def plan(args, base, changed, crates_of, libraries, dependencies):
Step("content root tests", CONTENT_ROOT_TESTS, summary=tests_passed,
skip=None if crates is None or any(p.startswith(CONTENT_ROOT) for p in changed)
else "content root untouched"),
Step("mod API up to date", "cargo test --locked -p xtask mod_api",
skip=None if any(p.startswith(MOD_API_INPUTS) for p in changed)
else "native hooks untouched"),
Step("tokens.css up to date", "cargo test --locked -p horizon-theme --lib tokens_css_is_up_to_date",
skip=None if any(p.startswith(("crates/horizon-theme/", "xtask/")) for p in changed)
else "theme untouched"),
@@ -483,7 +478,7 @@ def run_windows(steps, verbose):
command = shlex.split(CI_CLIPPY) # the server build's clippy runs in CI (Linux)
elif step.name.startswith("tests"):
command = shlex.split(step.command)
elif step.name in ("tokens.css up to date", "mod API up to date"):
elif step.name == "tokens.css up to date":
command = shlex.split(step.command)
elif step.name == "launcher rustfmt":
command = ["cargo", "fmt", "--check"]
+7 -16
View File
@@ -12,15 +12,8 @@ FRAGMENTS = Path("tools/native-hook-fragments")
SOURCE = Path("crates/horizon-client/src/game/native_hooks.rs")
DOCS = Path("docs/native-mods.md")
CATALOG = Path("native-hooks.json")
# The mod API's types and the files `cargo run -p xtask -- mod-api` generates: they copy hook and
# helper comments, markers included, so every marker is stamped in them too and `--check` holds.
MOD_API = (
Path("crates/horizon-client/src/mod_api.rs"),
Path("crates/horizon-client/src/mod_api/generated.rs"),
Path("crates/horizon-client/src/mod_api/generated_host.rs"),
Path("crates/horizon-client/src/game/mod_api_host_generated.rs"),
Path("crates/horizon-client-render/src/gfx/native_render/generated.rs"),
)
# Hand-written API types can carry markers too; generated copies are built from stamped sources.
MOD_API = Path("crates/horizon-client/src/mod_api.rs")
def stamp(check):
@@ -52,8 +45,7 @@ def stamp(check):
names = {hook["name"] for hook in catalog["hooks"]}
helper_names = {helper["name"] for helper in catalog.get("helpers", [])}
extra = {}
# The catalog takes new entries in file name order, the order `cargo run -p xtask -- mod-api`
# reads pending fragments in, so stamping leaves the generated mod API as it was.
# Match the build-time generator's fragment order so stamping preserves the generated API.
for path in sorted(ordered):
fragment = json.loads(path.read_text())
for hook in fragment["hooks"]:
@@ -87,7 +79,7 @@ def stamp(check):
docs = docs.replace(docs_marker, f"\nAPI {version} adds {doc_summary}\n" + docs_marker, 1)
for filename in fragment.get("comments", []):
file = Path(filename)
if file.is_absolute() or ".." in file.parts or file in (SOURCE, DOCS, CATALOG, *MOD_API):
if file.is_absolute() or ".." in file.parts or file in (SOURCE, DOCS, CATALOG, MOD_API):
raise ValueError(f"invalid comment path: {filename}")
text = extra.get(file, (ROOT / file).read_text())
if marker not in text:
@@ -96,10 +88,9 @@ def stamp(check):
# Most hook comments live in the source itself, and some hook lists in the docs.
source = source.replace(marker, str(version))
docs = docs.replace(marker, str(version))
for file in MOD_API:
if (ROOT / file).exists():
text = extra.get(file, (ROOT / file).read_text())
extra[file] = text.replace(marker, str(version))
if (ROOT / MOD_API).exists():
text = extra.get(MOD_API, (ROOT / MOD_API).read_text())
extra[MOD_API] = text.replace(marker, str(version))
source = re.sub(
r"^(pub\(crate\) const NATIVE_HOOKS_API: u32 = )\d+;$",
+4 -2
View File
@@ -7,10 +7,12 @@ publish = false
description = "Repository tasks: cargo run -p xtask -- package BINARY OUTDIR | tokens"
[dependencies]
syn = { version = "2", features = ["full", "visit", "visit-mut"] }
quote = "1"
horizon-mod-api-gen = { path = "../crates/horizon-mod-api-gen" }
serde_json = "1"
horizon-theme = { path = "../crates/horizon-theme" }
[build-dependencies]
horizon-mod-api-gen = { path = "../crates/horizon-mod-api-gen" }
[lints]
workspace = true
+6
View File
@@ -0,0 +1,6 @@
fn main() {
let root = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("..");
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
horizon_mod_api_gen::generate_test_hooks(&root, &out_dir)
.expect("generating dispatch test fixture");
}
+1 -2
View File
@@ -50,8 +50,7 @@ fn main() -> ExitCode {
[task, engine, out] if task == "package-server" => {
package_server(Path::new(engine), Path::new(out))
}
[task] if task == "mod-api" => mod_api::run(false),
[task, check] if task == "mod-api" && check == "--check" => mod_api::run(true),
[task] if task == "mod-api" => mod_api::run(),
[task] if task == "tokens" => tokens(),
[task] if task == "icons" => icons(),
_ => Err(USAGE.to_string()),
+10 -2003
View File
File diff suppressed because it is too large. Load diff
+439
View File
@@ -0,0 +1,439 @@
mod tests {
use std::sync::{Arc, Mutex};
use std::time::Duration;
struct ItemStack;
struct BlockEntityDraw(Vec<u32>);
#[allow(dead_code)]
mod stubs {
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct BlockStateId;
#[derive(Clone, Copy)]
pub(super) struct FluidState;
pub(super) struct PlacementContext;
#[derive(Clone, Copy)]
pub(super) struct BlockPos;
#[derive(Clone, Copy)]
pub(super) struct BlockId;
#[derive(Clone, Copy)]
pub(super) struct Direction;
#[derive(Clone, Copy)]
pub(super) struct LevelDimension;
pub(super) struct Ticks;
pub(super) trait LevelHost {}
pub(super) trait LevelTicksHost: LevelHost {}
pub(super) trait LevelRandomHost: LevelHost {}
pub(super) trait LevelWriteHost: LevelTicksHost + LevelRandomHost {}
pub(super) struct Compound;
pub(super) type NativeDispensed = (Option<super::ItemStack>, i32);
pub(super) struct NativeLootDrops<'a>(pub &'a ());
pub(super) struct NativeScreenView<'a>(pub &'a ());
pub(super) struct Corners;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) struct ItemEnergyBuffer;
pub(super) struct Suggestion;
pub(super) struct HudBatch;
pub(super) struct BiomeColors;
pub(super) struct UseAnimation;
#[derive(Clone, Copy)]
pub(super) struct Hand;
pub(super) struct NativeMobRender<'a>(pub &'a ());
pub(super) struct NativeShake;
pub(super) struct Music;
pub(super) struct MusicLayer;
pub(super) struct NativeBlockRim;
pub(super) struct NativeColoredLight;
pub(super) struct NativeMinimap;
pub(super) struct NativeMod;
pub(super) struct NativePanelEvent;
pub(super) struct NativeScreen;
pub(super) struct ModBlockEntity;
pub(super) struct ModContainer;
pub(super) struct MerchantOffer;
pub(super) struct ModFluidTanks;
pub(super) struct ModEnergyBuffer;
pub(super) struct LootStack;
pub(super) struct NativeLootContext<'a>(pub &'a ());
pub(super) struct NativeDataMapType;
pub(super) struct RecipeBookCategory;
pub(super) struct NativeMapDecoration;
pub(super) struct NativeMapInfo<'a>(pub &'a ());
pub(super) struct NativePlayerRender<'a>(pub &'a ());
pub(super) struct NativeCommand;
pub(super) struct NativeLifecycleContext;
pub(super) struct NativeServerPlayer;
#[derive(Clone, Copy)]
pub(super) struct ServerTickPhase;
#[derive(Clone, Copy)]
pub(super) struct WorldPhase;
}
use serde_json::{Map, Value};
use stubs::*;
struct Text;
#[derive(Debug, PartialEq)]
struct NativeZoom(u32);
enum Flow {
Continue,
Cancel,
}
trait Host {}
trait RenderHooks {}
struct TestHost;
impl Host for TestHost {}
mod winit {
pub mod event {
pub struct KeyEvent;
}
}
#[allow(dead_code)]
mod generated {
mod horizon_mods {
pub mod content {
pub struct Block;
pub struct Item;
}
}
mod horizon_client {
pub mod camera {
pub struct CameraEntity;
pub struct FovPlayer;
}
pub mod menu {
pub struct AnvilCraft;
}
pub mod fog {
#[derive(Clone, Copy)]
pub struct FogType;
pub struct FogData;
}
pub mod first_person {
#[derive(Clone, Copy)]
pub struct Hand;
}
pub mod hud_layout {
pub struct HealthInputs;
pub struct GuiDraw;
}
}
include!(concat!(env!("OUT_DIR"), "/mod_api_generated.rs"));
}
use generated::*;
thread_local! {
/// The hooks each dispatch entered, a mod at a time.
static ENTERED: std::cell::RefCell<Vec<(usize, &'static str)>> = const { std::cell::RefCell::new(Vec::new()) };
}
fn entered<'a>(
mods: &'a [Box<dyn Hooks>],
hook: &'static str,
) -> impl Iterator<Item = &'a Box<dyn Hooks>> {
mods.iter().enumerate().map(move |(index, hooks)| {
ENTERED.with(|entered| entered.borrow_mut().push((index, hook)));
hooks
})
}
struct Mod {
id: u32,
calls: Arc<Mutex<Vec<u32>>>,
}
impl Hooks for Mod {
fn block_entity_submit(&self, _: &dyn Host, event: &mut BlockEntitySubmitEvent<'_>) {
assert_eq!(event.pos, [1, 2, 3]);
assert_eq!(event.block, "example:machine");
event.draw.0.push(self.id);
}
fn living_entity_use_item_finish(
&self,
_: &mut dyn Host,
event: &mut LivingEntityUseItemFinishEvent<'_>,
) {
*event.result = None;
}
fn mouse_button_pre(&self, _: &mut dyn Host, _: &mut MouseButtonPreEvent) -> Flow {
self.calls.lock().unwrap().push(self.id);
if self.id == 1 {
Flow::Cancel
} else {
Flow::Continue
}
}
fn menu_slot_rules(&self, event: &mut MenuSlotRulesEvent<'_>) {
event.may_place = false;
event.max_stack = self.id;
}
fn living_entity_use_item_tick(
&self,
_: &mut dyn Host,
event: &mut LivingEntityUseItemTickEvent<'_>,
) -> Flow {
event.duration -= 1;
if self.id == 1 {
Flow::Cancel
} else {
Flow::Continue
}
}
fn loot_table_load(&self, event: &mut LootTableLoadEvent<'_>) {
event.table.as_array_mut().unwrap().push(self.id.into());
}
fn modify_default_components(&self, event: &mut ModifyDefaultComponentsEvent<'_>) {
let value = event
.components
.entry("order")
.or_insert_with(|| serde_json::json!([]));
value.as_array_mut().unwrap().push(self.id.into());
}
fn level_tick_pre(&self, _: &mut dyn Host, _: &mut LevelTickPreEvent<'_>) {
self.calls.lock().unwrap().push(self.id);
}
fn tooltip(&self, _: &dyn Host, event: &mut TooltipEvent<'_>) {
event.lines.push((Text, self.id));
}
fn zoom(&self, _: &dyn Host, _: &mut ZoomEvent) -> Option<NativeZoom> {
self.calls.lock().unwrap().push(self.id);
Some(NativeZoom(self.id))
}
fn should_apply_effect_tick_this_tick(
&self,
_: &dyn Host,
event: &mut ShouldApplyEffectTickThisTickEvent<'_>,
) {
event.apply = !event.apply;
self.calls.lock().unwrap().push(self.id);
}
fn living_incoming_damage(&self, event: &mut LivingIncomingDamageEvent<'_>) -> Flow {
event.amount = event.amount * 2.0 + self.id as f32;
if self.id == 2 {
Flow::Cancel
} else {
Flow::Continue
}
}
fn use_item_on(&self, _: &mut dyn Host, _: &mut UseItemOnEvent<'_>) -> Flow {
self.calls.lock().unwrap().push(self.id);
if self.id == 1 {
Flow::Cancel
} else {
Flow::Continue
}
}
}
#[test]
fn block_entity_submit_dispatches_in_order_with_shared_draw() {
let calls = Arc::new(Mutex::new(Vec::new()));
let mods: Vec<Box<dyn Hooks>> = vec![
Box::new(Mod {
id: 1,
calls: calls.clone(),
}),
Box::new(Mod { id: 2, calls }),
];
let mut draw = BlockEntityDraw(Vec::new());
dispatch_block_entity_submit(
&TestHost,
&mods,
[1, 2, 3],
"example:machine",
BlockStateId,
&ModBlockEntity,
&mut draw,
);
assert_eq!(draw.0, [1, 2]);
}
#[test]
fn slot_rules_mutate_and_item_tick_can_cancel() {
let mods: Vec<Box<dyn Hooks>> = vec![Box::new(Mod {
id: 1,
calls: Arc::new(Mutex::new(Vec::new())),
})];
let (mut may_place, mut max_stack) = (true, 64);
dispatch_menu_slot_rules(
&mods,
"test:menu",
0,
&ItemStack,
&mut may_place,
&mut max_stack,
);
assert_eq!((may_place, max_stack), (false, 1));
let (mut duration, mut canceled) = (10, false);
dispatch_living_entity_use_item_tick(
&mut TestHost,
&mods,
&ItemStack,
None,
Hand,
&mut duration,
&mut canceled,
);
assert_eq!((duration, canceled), (9, true));
}
#[test]
fn item_finish_can_clear_vanilla_result() {
let mods: Vec<Box<dyn Hooks>> = vec![Box::new(Mod {
id: 1,
calls: Arc::new(Mutex::new(Vec::new())),
})];
let mut result = Some(ItemStack);
dispatch_living_entity_use_item_finish(
&mut TestHost,
&mods,
&ItemStack,
None,
Hand,
0,
&mut result,
);
assert!(result.is_none());
}
#[test]
fn fragment_cancel_stops_later_mods() {
let calls = Arc::new(Mutex::new(Vec::new()));
let mods: Vec<Box<dyn Hooks>> = vec![
Box::new(Mod {
id: 1,
calls: calls.clone(),
}),
Box::new(Mod {
id: 2,
calls: calls.clone(),
}),
];
let mut canceled = false;
dispatch_mouse_button_pre(&mut TestHost, &mods, 0, true, 0, &mut canceled);
assert!(canceled);
assert_eq!(*calls.lock().unwrap(), [1]);
// Each call is entered under the hook's name; the cancelled one's is the last.
let entered = ENTERED.with(|entered| entered.take());
assert_eq!(entered, [(0, "mouse_button_pre")]);
}
#[test]
fn placement_replacement_can_refuse_and_stops_later_mods() {
struct Level;
impl LevelHost for Level {}
struct Refuse(Arc<Mutex<Vec<u32>>>, u32);
impl Hooks for Refuse {
fn state_for_placement(
&self,
_: &dyn LevelHost,
_: &mut StateForPlacementEvent<'_>,
) -> Option<Option<BlockStateId>> {
self.0.lock().unwrap().push(self.1);
Some(None)
}
}
let calls = Arc::new(Mutex::new(Vec::new()));
let mods: Vec<Box<dyn Hooks>> = vec![
Box::new(Refuse(calls.clone(), 1)),
Box::new(Refuse(calls.clone(), 2)),
];
let mut answer = None;
dispatch_state_for_placement(
&Level,
&mods,
LevelDimension,
BlockStateId,
&PlacementContext,
FluidState,
&mut answer,
);
assert_eq!(answer, Some(None));
assert_eq!(*calls.lock().unwrap(), vec![1]);
dispatch_state_for_placement(
&Level,
&mods,
LevelDimension,
BlockStateId,
&PlacementContext,
FluidState,
&mut answer,
);
assert_eq!(*calls.lock().unwrap(), vec![1]);
}
#[test]
fn dispatch_contracts() {
let calls = Arc::new(Mutex::new(Vec::new()));
let mods: Vec<Box<dyn Hooks>> = (1..=2)
.map(|id| {
Box::new(Mod {
id,
calls: calls.clone(),
}) as Box<dyn Hooks>
})
.collect();
let mut table = serde_json::json!([]);
dispatch_loot_table_load(&mods, "table", &mut table);
assert_eq!(table, serde_json::json!([1, 2]));
let mut components = Map::new();
dispatch_modify_default_components(&mods, "item", &mut components);
assert_eq!(components["order"], serde_json::json!([1, 2]));
let mut host = TestHost;
dispatch_level_tick_pre(&mut host, &mods, "overworld");
assert_eq!(*calls.lock().unwrap(), [1, 2]);
calls.lock().unwrap().clear();
let mut zoom = None;
dispatch_zoom(&host, &mods, &mut zoom);
assert_eq!(zoom, Some(NativeZoom(1)));
assert_eq!(*calls.lock().unwrap(), [1]);
calls.lock().unwrap().clear();
dispatch_zoom(&host, &mods, &mut zoom);
assert!(calls.lock().unwrap().is_empty());
let mut lines = Vec::new();
dispatch_tooltip(&host, &mods, &ItemStack, &mut lines);
assert_eq!(lines.iter().map(|line| line.1).collect::<Vec<_>>(), [1, 2]);
let mut apply = false;
calls.lock().unwrap().clear();
dispatch_should_apply_effect_tick_this_tick(&host, &mods, "effect", 0, 0, &mut apply);
assert!(!apply);
assert_eq!(*calls.lock().unwrap(), [1, 2]);
let mut amount = 1.0;
let mut canceled = false;
dispatch_living_incoming_damage(
&mods,
0,
"kind",
"damage",
0,
0,
&mut amount,
&mut canceled,
);
// Each mod sees the one before: (1 × 2 + 1) × 2 + 2, kept though mod 2 cancels.
assert_eq!(amount, 8.0);
assert!(canceled);
let chained: Vec<Box<dyn Hooks>> = [1, 3]
.map(|id| {
Box::new(Mod {
id,
calls: calls.clone(),
}) as Box<dyn Hooks>
})
.into();
let mut amount = 1.0;
let mut canceled = false;
dispatch_living_incoming_damage(
&chained,
0,
"kind",
"damage",
0,
0,
&mut amount,
&mut canceled,
);
assert_eq!(amount, 9.0);
assert!(!canceled);
calls.lock().unwrap().clear();
let mut claimed = false;
dispatch_use_item_on(&mut host, &mods, &ItemStack, [0; 3], [0; 3], &mut claimed);
assert!(claimed);
assert_eq!(*calls.lock().unwrap(), [1]);
calls.lock().unwrap().clear();
dispatch_use_item_on(&mut host, &mods, &ItemStack, [0; 3], [0; 3], &mut claimed);
assert!(calls.lock().unwrap().is_empty());
}
}