Compare commits

...
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 23293b86b4 Keep the mod screen over other screens out of the big files
The new mod-screen code moves from native_hooks.rs to native_mod_screen.rs
(helpers retargeted in its fragment), its state rides the existing
native_mod_screen field, and draw_screen lives in screens.rs, so game.rs,
native_hooks.rs and remote.rs no longer grow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:41:59 +01:00
Horizon agent 4fa9c5b082 Merge remote-tracking branch 'origin/agent/hooks-outside-native-hooks' into fix/12272 2026-11-09 18:31:09 +01:00
Horizon agent a790d406c7 Merge remote-tracking branch 'origin/main' into fix/12272
# Conflicts:
#	crates/horizon-client/src/game/pointer_capture.rs
2026-11-09 18:31:06 +01:00
Horizon agentandClaude Opus 5.5 1d12ab67f5 mod-api-gen: committed render hooks ignore rustfmt's trailing commas
xtask mod-api writes the renderer's hooks through rustfmt, which adds a
trailing comma wherever it wraps a list. The build's staleness check compares
tokens with the generator's one-line code, so any render hook with a signature
long enough to wrap (#12277's client_sound) failed the build as "out of date"
right after being regenerated. The check now drops trailing commas before ),
] and } on both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:16:58 +01:00
Horizon agentandClaude Opus 5.5 98c8498c8b Native mods: a hook point may live in any module under game/
native_hooks.rs is over the 3000-line limit and may not grow, so every new
hook point was refused. The generator now accepts a hook whose target names
another game/ module, as helpers already do, provided the function there makes
its dispatch_<hook> call itself (xtask mod-api only fills native_hooks.rs) and
holds no macro or cfg, which horizon-mixins cannot patch.
catalog_matches_hook_points checks those hooks against the file their target
names.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:38:48 +01:00
Horizon agentandClaude Opus 5.5 507e48073a Merge main into #12272
main moved native_hooks.rs's tests to native_hooks_tests.rs: the new test moves there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:28:45 +01:00
Cheezer1656andClaude Code 85514bb934 Mod API: a mod's own screen over the options page, pause menu or container
native_mod_screen_open_over(mod_id, keep_drawing_previous) opens a mod screen
over Options, Paused or Inventory (a container) as well as the world. Escape or
native_mod_screen_close gives the replaced screen back (vanilla's lastScreen).
With keep_drawing_previous the pause title or container panel still draws under
it, pointer-less, and native_mod_screen_under_rect reports its panel. A server
ContainerClose closes the mod screen with the container; single-player stays
paused under a pausing screen. Ships as a hook fragment (mod_screen_over).

Co-Authored-By: Claude Code <[email protected]>
Claude-Session: https://claude.ai/code/session_011R7zRUK5udBTJmDgHhoXY2
2026-11-08 13:14:05 -07:00
10 changed files with 516 additions and 93 deletions

No files matched your search

+5 -53
View File
@@ -307,6 +307,7 @@ mod native_menu_stacks;
mod native_menus;
mod native_mob_draw;
mod native_mobs;
mod native_mod_screen;
mod native_motion;
mod native_mounts;
mod native_net;
@@ -2835,8 +2836,8 @@ struct Game<'a> {
native_player_fx: native_player_fx::NativePlayerFx,
/// Native mods' kicks waiting for the tick's end (`game/native_packets.rs`).
native_packets: native_packets::NativePackets,
/// The mod whose own screen is open (`Screen::Mod`).
native_mod_screen: Option<String>,
/// The mod whose own screen is open (`Screen::Mod`), and what it replaced.
native_mod_screen: native_mod_screen::ModScreen,
/// Meshed sections waiting for an upload budget (Horizon settings).
pending_uploads: Vec<((i32, i32, i32), horizon_client::mesher::SectionMesh)>,
/// This frame's view frustum, for culling entities and block entities.
@@ -6246,56 +6247,7 @@ impl Game<'_> {
} else {
self.screen
};
match screen {
Screen::Paused if self.pause_menu => self.draw_pause_menu(&mut batch),
// PauseScreen(false): only its title, at the top.
Screen::Paused => {
let title = self.translate("menu.paused", "Game Paused");
horizon_client::widgets::begin_screen(|| {
horizon_client::widgets::ScreenInfo::new("paused", title.clone())
});
let tw = HudBatch::text_width(&self.gui.atlas, &title);
batch.text(
&self.gui.atlas,
&title,
((batch.width / 2.0).floor() - (tw / 2.0).floor()).floor(),
10.0,
u32::MAX,
true,
);
}
Screen::GameModeSwitcher => self.draw_gamemode_switcher(&mut batch),
Screen::EmoteWheel => self.draw_emote_wheel(&mut batch),
Screen::Dead => self.draw_death_screen(&mut batch),
Screen::Stats => self.draw_stats(&mut batch),
Screen::Advancements => self.draw_advancements(&mut batch),
Screen::Mod => {}
Screen::WorldOptions => self.draw_world_options(&mut batch),
Screen::Friends => self.draw_friends_screen(&mut batch),
Screen::Social => self.draw_social_screen(&mut batch),
Screen::ServerLinks => self.draw_server_links(&mut batch),
Screen::ConfirmLink => self.draw_confirm_link(&mut batch),
Screen::DialogWarning => self.draw_dialog_warning(&mut batch),
Screen::CommandBlock => self.draw_command_block(&mut batch),
Screen::BlockEditor => self.draw_block_editor(&mut batch),
Screen::DebugOptions => self.draw_debug_options(&mut batch),
Screen::HudEditor => self.draw_hud_editor(&mut batch),
Screen::Studio => {}
Screen::Inventory => {
let from = batch.vertices.len();
self.draw_inventory(&mut batch);
self.draw_mod_browser(&mut batch);
self.draw_pad_menu_guide(&mut batch, from);
}
Screen::Creative => {
let from = batch.vertices.len();
self.draw_creative(&mut batch);
self.draw_mod_browser(&mut batch);
self.draw_pad_menu_guide(&mut batch, from);
}
Screen::Chat => self.draw_chat_screen(&mut batch),
Screen::Playing | Screen::Options | Screen::Win => {}
}
self.draw_screen(&mut batch, screen);
// ScreenEvent.Render.Post: the open screen has drawn itself and its
// tooltips, so a native mod draws over it here. Never `Playing`: the
// prompts, books and sign editors drawn over gameplay keep it, draw
@@ -12205,7 +12157,7 @@ impl<'a> Game<'a> {
native_net: Default::default(),
native_player_fx: Default::default(),
native_packets: Default::default(),
native_mod_screen: None,
native_mod_screen: Default::default(),
sky_darken_memo: Default::default(),
water_plane_memo: Default::default(),
water_plane_scan: Default::default(),
+3 -23
View File
@@ -11300,25 +11300,12 @@ impl Game<'_> {
/// dedicated server). A gamepad can close it (B, Start) but not click on
/// it: its clicks don't reach the mouse hooks. API 111.
pub(super) fn native_mod_screen_open(&mut self, mod_id: &str) -> bool {
// What `screen_hooks_open` would refuse over the world: the mod's
// screen would be up with no hook to draw or click it.
let blocked = self.title.is_some()
|| self.renderer.is_none()
|| self.lan_acting.is_some()
|| self.player.is_none()
|| self.screen_over_playing()
|| self.remote_loading();
let blocked = self.mod_screen_blocked();
let owner = self.native_mod_screen();
if let Some(open) = mod_screen_opens(self.screen, blocked, owner, mod_id) {
return open;
}
self.native_mod_screen = Some(mod_id.to_owned());
self.screen = Screen::Mod;
self.keys.clear();
self.left = false;
self.right = false;
self.mining = None;
self.capture(false);
self.open_mod_screen(mod_id, None);
true
}
/// Closes mod `mod_id`'s own screen, if it is open. API 111.
@@ -11331,17 +11318,10 @@ impl Game<'_> {
/// replacing it, closes it). API 111.
pub(super) fn native_mod_screen(&self) -> Option<&str> {
self.native_mod_screen
.id
.as_deref()
.filter(|_| self.screen == Screen::Mod)
}
/// A mod's own screen closes (Escape, or `native_mod_screen_close`).
pub(super) fn close_mod_screen(&mut self) {
self.native_mod_screen = None;
if self.screen == Screen::Mod {
self.screen = Screen::Playing;
self.capture(true);
}
}
/// The open menu's title (the block's name, or the one it was given),
/// as plain text. API 20.
pub(super) fn native_screen_title(&self) -> Option<String> {
@@ -685,6 +685,29 @@ fn catalog_matches_hook_points() {
let conn_hooks = free_hooks(include_str!("../lan/conn.rs"));
// The log writer's (`horizon_client_lib::native_client_hooks`).
let client_hooks = free_hooks(include_str!("../native_client_hooks.rs"));
// Hook points kept out of native_hooks.rs, in another module under game/: each is declared in
// the file its target names.
let mut game_hooks = std::collections::BTreeMap::new();
for hook in &hooks {
let target = hook["target"].as_str().unwrap();
let Some((module, name)) = target
.strip_prefix("horizon_client::game::")
.and_then(|rest| rest.split_once("::Game<'_>::native_"))
.filter(|(module, _)| *module != "native_hooks")
else {
continue;
};
let file = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("src/game")
.join(format!("{}.rs", module.replace("::", "/")));
let source = std::fs::read_to_string(&file).unwrap();
assert!(
source.contains(&format!("fn native_{name}(")),
"{} does not declare native_{name} (target {target})",
file.display()
);
game_hooks.insert(name.to_owned(), target.to_owned());
}
let declared: BTreeSet<_> = section
.lines()
.chain(level_section.lines())
@@ -709,6 +732,7 @@ fn catalog_matches_hook_points() {
.chain(mob_hooks.iter().copied())
.chain(conn_hooks.iter().copied())
.chain(client_hooks.iter().copied())
.chain(game_hooks.keys().map(String::as_str))
.collect();
let mut names = BTreeSet::new();
let mut contracts = BTreeSet::new();
@@ -720,7 +744,9 @@ fn catalog_matches_hook_points() {
"invalid or duplicate hook: {name} v{version}"
);
names.insert(name);
let target = if render_hooks.contains(name) {
let target = if let Some(target) = game_hooks.get(name) {
target.clone()
} else if render_hooks.contains(name) {
format!("horizon_client_render::gfx::native_render::native_{name}")
} else if item_hooks.contains(name) {
format!("horizon_world::item_defaults::native_{name}")
@@ -0,0 +1,250 @@
//! A native mod's own screen (`Screen::Mod`): opening it over the world or over a screen it
//! replaces and gives back, drawing that screen under it, closing it (docs/native-mods.md).
//! `native_mod_screen_open` and the screen hooks stay in `native_hooks`.
use super::{Game, Screen};
use horizon_client::hud::HudBatch;
/// The open mod screen (`Screen::Mod`): whose it is, and what it replaced.
#[derive(Default)]
pub(super) struct ModScreen {
/// The mod whose own screen is open.
pub(super) id: Option<String>,
/// The screen the open mod screen replaced (None: it was over the world).
under: Option<ModScreenUnder>,
}
/// The screen a native mod's own screen replaced
/// (`native_mod_screen_open_over`): what Escape returns to.
#[derive(Clone, Copy)]
pub(super) struct ModScreenUnder {
screen: Screen,
/// Drawn under the mod screen (the mod asked, and the screen is one that
/// can be drawn without its input).
drawn: bool,
/// The panel it drew this frame (`screen_rect`), for the mod to place
/// what it draws over it by.
rect: Option<[f32; 4]>,
}
impl Game<'_> {
/// Opens a screen of mod `mod_id`'s own like `native_mod_screen_open`, but
/// over the options page (Options > Mods), the pause menu or an open
/// inventory or container too, as vanilla's `setScreen` would replace
/// them. The replaced screen comes back when the mod screen closes
/// (Escape or `native_mod_screen_close`), as `lastScreen` does; a server
/// that closes the container meanwhile closes it for good. With
/// `keep_drawing_previous` the pause menu's title or the container's
/// panel is still drawn under the mod screen, with no pointer over it (no
/// slot highlight, no tooltip), and `native_mod_screen_under_rect` says
/// where; the container takes no clicks or keys. A single-player game
/// stays paused under a mod screen over a pausing screen. True when it is
/// open (`mod_id`'s was already), false for the same screens as
/// `native_mod_screen_open` and for the chat, the creative inventory and
/// the other screens of the game's own. Bundles need "hooks": @API:mod_screen_over@.
pub(super) fn native_mod_screen_open_over(
&mut self,
mod_id: &str,
keep_drawing_previous: bool,
) -> bool {
let blocked = self.mod_screen_blocked();
let owner = self.native_mod_screen();
if let Some(open) = mod_screen_opens_over(self.screen, blocked, owner, mod_id) {
return open;
}
let under = (self.screen != Screen::Playing).then_some(ModScreenUnder {
screen: self.screen,
drawn: keep_drawing_previous && mod_screen_draws_under(self.screen),
rect: None,
});
self.open_mod_screen(mod_id, under);
true
}
/// What `screen_hooks_open` would refuse over the world: the mod's screen
/// would be up with no hook to draw or click it.
pub(super) fn mod_screen_blocked(&self) -> bool {
self.title.is_some()
|| self.renderer.is_none()
|| self.lan_acting.is_some()
|| self.player.is_none()
|| self.screen_over_playing()
|| self.remote_loading()
}
pub(super) fn open_mod_screen(&mut self, mod_id: &str, under: Option<ModScreenUnder>) {
self.native_mod_screen = ModScreen {
id: Some(mod_id.to_owned()),
under,
};
self.screen = Screen::Mod;
self.keys.clear();
self.left = false;
self.right = false;
self.mining = None;
self.capture(false);
}
/// The panel of the screen under the open mod screen, in GUI pixels
/// (`NativeScreenView.rect` as that screen has it), as the last frame drew
/// it; None when the mod screen is over the world, or did not ask for the
/// screen under it to be drawn. A container's panel is where its slots
/// and overlays are. Bundles need "hooks": @API:mod_screen_over@.
pub(super) fn native_mod_screen_under_rect(&self) -> Option<[f32; 4]> {
if self.screen != Screen::Mod {
return None;
}
self.native_mod_screen.under.filter(|u| u.drawn)?.rect
}
/// A mod's own screen closes (Escape, or `native_mod_screen_close`).
pub(super) fn close_mod_screen(&mut self) {
let under = std::mem::take(&mut self.native_mod_screen).under;
if self.screen == Screen::Mod {
// `lastScreen`: the screen it replaced comes back; the mouse
// stays free, as that screen had it.
self.screen = mod_screen_returns_to(under.map(|u| u.screen));
if self.screen == Screen::Playing {
self.capture(true);
}
}
}
/// Draws the screen under the open mod screen, if the mod asked
/// (`native_mod_screen_open_over`): as itself, with the pointer far off so
/// no slot lights up and no tooltip shows (its clicks are the mod
/// screen's), then remembers its panel.
pub(super) fn draw_mod_screen_under(&mut self, batch: &mut HudBatch) {
let Some(under) = self.native_mod_screen.under.filter(|u| u.drawn) else {
return;
};
let (screen, cursor) = (self.screen, self.cursor);
self.screen = under.screen;
self.cursor = (-1.0e6, -1.0e6);
self.draw_screen(batch, under.screen);
let rect = self.screen_rect(batch, under.screen);
self.screen = screen;
self.cursor = cursor;
if let Some(under) = self.native_mod_screen.under.as_mut() {
under.rect = Some(rect);
}
}
/// A mod screen is up over the inventory or a container (a server's
/// closing it closes the mod screen with it).
fn mod_screen_covers_inventory(&self) -> bool {
mod_screen_over_inventory(self.screen, self.native_mod_screen.under.map(|u| u.screen))
}
/// The inventory or a container is open, or a mod screen is up over it:
/// a server's closing the container closes the mod screen with it.
pub(super) fn screen_shows_inventory(&self) -> bool {
self.screen == Screen::Inventory || self.mod_screen_covers_inventory()
}
/// The open screen, or the one an open mod screen replaced: a mod screen
/// over a pausing screen keeps the game paused.
pub(super) fn screen_under_mod(&self) -> Screen {
match self.native_mod_screen.under {
Some(under) if self.screen == Screen::Mod => under.screen,
_ => self.screen,
}
}
}
/// What `native_mod_screen_open_over(mod_id)` does over `screen` (as
/// `mod_screen_opens`, which it widens): the options page, the pause menu, and
/// the inventory or a container (a server's or a block's) are replaced and
/// come back; the others are not.
fn mod_screen_opens_over(
screen: Screen,
blocked: bool,
owner: Option<&str>,
mod_id: &str,
) -> Option<bool> {
if screen == Screen::Mod {
return Some(owner == Some(mod_id));
}
// No `matches!`: horizon-mixins refuses macros in a hook point's body.
let covered = screen == Screen::Playing
|| screen == Screen::Options
|| screen == Screen::Paused
|| screen == Screen::Inventory;
if !covered || blocked {
return Some(false);
}
None
}
/// Whether the open `screen`, with `under` the screen a mod screen replaced,
/// is the inventory or a container shown by a mod screen over it.
fn mod_screen_over_inventory(screen: Screen, under: Option<Screen>) -> bool {
screen == Screen::Mod && under == Some(Screen::Inventory)
}
/// The screen a mod screen gives back when it closes: the one it replaced, or
/// the world.
fn mod_screen_returns_to(under: Option<Screen>) -> Screen {
under.unwrap_or(Screen::Playing)
}
/// Whether `screen` can be drawn under a mod screen without its input: the
/// pause menu and the inventory or container panel, which draw from state
/// alone. The options page is built of widgets that need their own input.
fn mod_screen_draws_under(screen: Screen) -> bool {
screen == Screen::Paused || screen == Screen::Inventory
}
#[cfg(test)]
mod tests {
use super::{
Screen, mod_screen_draws_under, mod_screen_opens_over, mod_screen_over_inventory,
mod_screen_returns_to,
};
/// A mod's own screen can also replace the options page, the pause menu
/// and the inventory or a container (they come back), but still not
/// another mod's screen, an overlay or any other screen of the game's.
#[test]
fn a_mod_screen_opens_over_a_screen_it_can_give_back() {
for covered in [
Screen::Playing,
Screen::Options,
Screen::Paused,
Screen::Inventory,
] {
assert_eq!(mod_screen_opens_over(covered, false, None, "a"), None);
assert_eq!(mod_screen_opens_over(covered, true, None, "a"), Some(false));
}
for other in [
Screen::Chat,
Screen::Creative,
Screen::Dead,
Screen::Stats,
Screen::CommandBlock,
] {
assert_eq!(mod_screen_opens_over(other, false, None, "a"), Some(false));
}
assert_eq!(
mod_screen_opens_over(Screen::Mod, false, Some("a"), "a"),
Some(true)
);
assert_eq!(
mod_screen_opens_over(Screen::Mod, false, Some("b"), "a"),
Some(false)
);
// A server closing the container closes the mod screen over it; the
// screen it gives back is the replaced one, or the world.
assert!(mod_screen_over_inventory(
Screen::Mod,
Some(Screen::Inventory)
));
assert!(!mod_screen_over_inventory(
Screen::Mod,
Some(Screen::Paused)
));
assert!(!mod_screen_over_inventory(Screen::Mod, None));
assert!(!mod_screen_over_inventory(
Screen::Inventory,
Some(Screen::Inventory)
));
assert!(mod_screen_returns_to(Some(Screen::Options)) == Screen::Options);
assert!(mod_screen_returns_to(None) == Screen::Playing);
// Only what draws from state alone is kept on screen.
assert!(mod_screen_draws_under(Screen::Inventory));
assert!(mod_screen_draws_under(Screen::Paused));
assert!(!mod_screen_draws_under(Screen::Options));
assert!(!mod_screen_draws_under(Screen::Playing));
}
}
@@ -45,11 +45,13 @@ impl Game<'_> {
}
/// `Minecraft.pause`: a single-player game with a pausing screen open.
pub(super) fn pauses_game(&self) -> bool {
// A mod screen over a pausing screen keeps the game paused.
let screen = self.screen_under_mod();
self.has_singleplayer_server()
&& !self.is_published()
&& !self.has_split_players()
&& self.title.is_none()
&& (self.screen.is_pause_screen() || self.book.is_some() || self.win_screen_pauses())
&& (screen.is_pause_screen() || self.book.is_some() || self.win_screen_pauses())
}
/// A screen open over gameplay while `screen` stays `Playing`: a book or
/// lectern, a sign, a server dialog, the code of conduct, a link or
+1 -1
View File
@@ -5205,7 +5205,7 @@ impl Game<'_> {
self.book = None;
self.sign_edit = None;
}
if !self.remote_lectern_closed() && (self.screen == Screen::Inventory || dialog) {
if !self.remote_lectern_closed() && (self.screen_shows_inventory() || dialog) {
self.remote_close_local();
self.screen = Screen::Playing;
self.capture(true);
+53
View File
@@ -34,6 +34,59 @@ impl Game<'_> {
_ => {}
}
}
/// What `screen` draws of its own, once a frame (the match `hud_layers`
/// runs after the HUD): `Screen::Mod` draws only the screen it replaced,
/// when `native_mod_screen_open_over` asked for it.
pub(super) fn draw_screen(&mut self, batch: &mut HudBatch, screen: Screen) {
match screen {
Screen::Paused if self.pause_menu => self.draw_pause_menu(batch),
// PauseScreen(false): only its title, at the top.
Screen::Paused => {
let title = self.translate("menu.paused", "Game Paused");
widgets::begin_screen(|| ScreenInfo::new("paused", title.clone()));
let tw = HudBatch::text_width(&self.gui.atlas, &title);
batch.text(
&self.gui.atlas,
&title,
((batch.width / 2.0).floor() - (tw / 2.0).floor()).floor(),
10.0,
u32::MAX,
true,
);
}
Screen::GameModeSwitcher => self.draw_gamemode_switcher(batch),
Screen::EmoteWheel => self.draw_emote_wheel(batch),
Screen::Dead => self.draw_death_screen(batch),
Screen::Stats => self.draw_stats(batch),
Screen::Advancements => self.draw_advancements(batch),
Screen::Mod => self.draw_mod_screen_under(batch),
Screen::WorldOptions => self.draw_world_options(batch),
Screen::Friends => self.draw_friends_screen(batch),
Screen::Social => self.draw_social_screen(batch),
Screen::ServerLinks => self.draw_server_links(batch),
Screen::ConfirmLink => self.draw_confirm_link(batch),
Screen::DialogWarning => self.draw_dialog_warning(batch),
Screen::CommandBlock => self.draw_command_block(batch),
Screen::BlockEditor => self.draw_block_editor(batch),
Screen::DebugOptions => self.draw_debug_options(batch),
Screen::HudEditor => self.draw_hud_editor(batch),
Screen::Studio => {}
Screen::Inventory => {
let from = batch.vertices.len();
self.draw_inventory(batch);
self.draw_mod_browser(batch);
self.draw_pad_menu_guide(batch, from);
}
Screen::Creative => {
let from = batch.vertices.len();
self.draw_creative(batch);
self.draw_mod_browser(batch);
self.draw_pad_menu_guide(batch, from);
}
Screen::Chat => self.draw_chat_screen(batch),
Screen::Playing | Screen::Options | Screen::Win => {}
}
}
}
impl Screen {
+153 -13
View File
@@ -590,6 +590,65 @@ fn free_function_source(target: &str) -> Option<(&'static str, String)> {
})
}
/// The `Game` method a hook point `target` names. Most are in native_hooks.rs (`hooks`), where
/// `cargo run -p xtask -- mod-api` fills a new one's dispatch call. One may live in any module under
/// game/ (`game`, the target naming that module as a helper's does), to keep a big file from
/// growing: there it makes its `dispatch_<name>` call itself and holds no macro or `cfg`, which
/// horizon-mixins could not patch (what `hook_points_stay_hookable` checks in native_hooks.rs).
fn game_hook<'a>(
target: &str,
name: &str,
hooks: &'a [syn::ItemFn],
game: &'a [(String, syn::ItemFn)],
) -> Result<&'a syn::ItemFn, String> {
let native = format!("native_{name}");
if let Some(rest) = target.strip_prefix("horizon_client::game::native_hooks::") {
if !rest.ends_with(&format!("::{native}")) {
return Err(format!("wrong target for {name}"));
}
return one_function(hooks.iter(), &native);
}
let found: Vec<_> = game
.iter()
.filter(|(module, method)| {
method.sig.ident == native && !module.ends_with("::native_hooks")
})
.collect();
let [(module, method)] = found[..] else {
return Err(format!(
"wrong target for {name}: expected one {native} under game/"
));
};
let expected = format!("{module}::Game<'_>::{native}");
if target != expected {
return Err(format!(
"wrong target for {name}: {target}, it is {expected}"
));
}
if !has_call(&method.block, &format!("dispatch_{name}")) {
return Err(format!(
"{native}: a hook point outside native_hooks.rs makes its dispatch_{name} call itself"
));
}
struct Unhookable(bool);
impl<'ast> Visit<'ast> for Unhookable {
fn visit_macro(&mut self, _: &'ast syn::Macro) {
self.0 = true;
}
fn visit_attribute(&mut self, attr: &'ast syn::Attribute) {
self.0 |= attr.path().is_ident("cfg") || attr.path().is_ident("cfg_attr");
}
}
let mut unhookable = Unhookable(false);
unhookable.visit_item_fn(method);
if unhookable.0 {
return Err(format!(
"{native}: a macro or cfg in a hook point, which horizon-mixins cannot patch"
));
}
Ok(method)
}
/// Whether a hook function needs its dispatch `call` filled in: false when it
/// makes the call; true when it is a `Game` or `GameLevel` method whose body is
/// only the placeholder a new hook starts with, `let _ = (every, parameter);`
@@ -721,9 +780,17 @@ pub fn fill_hooks(root: &Path) -> Result<(), String> {
fn render_hooks_current(root: &Path, render: &str) -> Result<(), String> {
let path = root.join(RENDER_HOOKS);
let committed = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
// rustfmt adds a trailing comma where it wraps a list (`fill_hooks` formats the file), which
// the generator's one-line code lacks: the same code either way.
let tokens = |code: &str| {
syn::parse_file(code)
.map(|file| file.into_token_stream().to_string())
.map(|file| {
file.into_token_stream()
.to_string()
.replace(" ,)", ")")
.replace(" , }", " }")
.replace(" ,]", "]")
})
.map_err(|e| format!("{}: {e}", path.display()))
};
if tokens(&committed)? == tokens(render)? {
@@ -758,6 +825,13 @@ fn sources(root: &Path, fill: FillMode) -> Result<Sources, String> {
methods.extend(impl_functions(item));
}
}
// Every `impl Game` method under game/: helpers, and hook points kept out of native_hooks.rs.
let mut game = Vec::new();
game_methods(
&root.join("crates/horizon-client/src/game"),
"horizon_client::game",
&mut game,
)?;
let mut traits = String::new();
let mut dispatch = String::new();
let mut render_traits = String::new();
@@ -806,13 +880,8 @@ fn sources(root: &Path, fill: FillMode) -> Result<Sources, String> {
},
)
} else {
if !target.starts_with("horizon_client::game::native_hooks::")
|| !target.ends_with(&format!("::{native}"))
{
return Err(format!("wrong target for {name}"));
}
(
one_function(methods.iter(), &native)?.clone(),
game_hook(target, name, &methods, &game)?.clone(),
format!("dispatch_{name}"),
)
};
@@ -1026,12 +1095,6 @@ fn sources(root: &Path, fill: FillMode) -> Result<Sources, String> {
let api_source = fs::read_to_string(root.join("crates/horizon-client/src/mod_api.rs"))
.map_err(|e| e.to_string())?;
let types = api_types(&api_source, &code)?;
let mut game = Vec::new();
game_methods(
&root.join("crates/horizon-client/src/game"),
"horizon_client::game",
&mut game,
)?;
let mut host_traits = String::new();
let mut host_impl = String::new();
let mut names = HashSet::new();
@@ -1237,6 +1300,73 @@ mod tests {
std::fs::remove_dir_all(dir).unwrap();
}
/// A hook point may live in any module under game/ when it makes its own dispatch call and holds
/// nothing horizon-mixins cannot patch; in native_hooks.rs it may still wait to be filled.
#[test]
fn hook_points_outside_native_hooks() {
let method = |source: &str| -> syn::ItemFn { syn::parse_str(source).unwrap() };
let module = |name: &str| format!("horizon_client::game::{name}");
let game = vec![
(
module("native_net"),
method(
"fn native_sent(&self, cancel: &mut bool) { mod_api::dispatch_sent(self, mod_api::mods(), cancel); }",
),
),
(
module("native_menus"),
method("fn native_bare(&self) { let _ = (); }"),
),
(
module("native_menus"),
method(
"fn native_logged(&self) { log::info!(\"x\"); mod_api::dispatch_logged(self, mod_api::mods()); }",
),
),
(
module("native_menus"),
method(
"fn native_gated(&self) { #[cfg(test)] mod_api::dispatch_gated(self, mod_api::mods()); }",
),
),
];
let hooks = [method("fn native_shown(&self) { let _ = (); }")];
let found = |target: &str, name: &str| {
super::game_hook(
&format!("horizon_client::game::{target}"),
name,
&hooks,
&game,
)
.map(|m| m.sig.ident.to_string())
};
assert_eq!(
found("native_net::Game<'_>::native_sent", "sent").unwrap(),
"native_sent"
);
assert_eq!(
found("native_hooks::Game<'_>::native_shown", "shown").unwrap(),
"native_shown"
);
assert_eq!(
found("native_menus::Game<'_>::native_sent", "sent").unwrap_err(),
"wrong target for sent: horizon_client::game::native_menus::Game<'_>::native_sent, \
it is horizon_client::game::native_net::Game<'_>::native_sent"
);
assert_eq!(
found("native_menus::Game<'_>::native_bare", "bare").unwrap_err(),
"native_bare: a hook point outside native_hooks.rs makes its dispatch_bare call itself"
);
for name in ["logged", "gated"] {
assert!(
found(&format!("native_menus::Game<'_>::native_{name}"), name)
.unwrap_err()
.ends_with("which horizon-mixins cannot patch")
);
}
assert!(found("native_net::Game<'_>::native_missing", "missing").is_err());
}
/// The committed render hooks pass while they hold the catalog's code in any layout, and fail
/// the build once a hook changes.
#[test]
@@ -1253,6 +1383,16 @@ mod tests {
)
.unwrap();
super::render_hooks_current(&root, code).unwrap();
// rustfmt's trailing commas, where it wraps a long list, change nothing.
let wrapped = "/// Drawn.\npub trait RenderHooks { fn drawn(&self, a: u8, b: u8) {} }\n\
pub fn go() { let e = E { a: 1, b: [2, 3] }; }\n";
std::fs::write(
&path,
"/// Drawn.\npub trait RenderHooks {\n fn drawn(&self, a: u8, b: u8,) {}\n}\n\
pub fn go() {\n let e = E {\n a: 1,\n b: [2, 3,],\n };\n}\n",
)
.unwrap();
super::render_hooks_current(&root, wrapped).unwrap();
let changed = code.replace("drawn(&self)", "drawn(&self, partial: f32)");
assert!(
super::render_hooks_current(&root, &changed)
+5 -1
View File
@@ -5350,7 +5350,11 @@ A new hook needs its fragment in `tools/native-hook-fragments/` with its `mod_ap
`{"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`.
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,
that source change and the fragment. `native_hooks.rs` is over the 3000 lines a file may have
([layers.md](layers.md#file-size)), so it may not grow: a new hook's function can instead go in a small module
under `game/` (`native_net.rs`, `native_menu_stacks.rs`…), its fragment's `target` naming that module. There it
writes its `dispatch_<hook>` call itself (the command fills only `native_hooks.rs`) and, like every hook point,
holds no macro or `cfg`, which horizon-mixins cannot patch; the build refuses it otherwise. The `horizon-mod-api-gen` build dependency generates the hook traits,
`Host` methods and dispatch code into Cargo's `OUT_DIR` when the game builds, 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. The one exception is the renderer's (`horizon-client-render`, an engine crate, which
@@ -0,0 +1,16 @@
{
"hooks": [],
"helpers": [
{
"name": "mod_screen_open_over",
"target": "horizon_client::game::native_mod_screen::Game<'_>::native_mod_screen_open_over"
},
{
"name": "mod_screen_under_rect",
"target": "horizon_client::game::native_mod_screen::Game<'_>::native_mod_screen_under_rect"
}
],
"rust_summary": "a mod's own screen over the options page, the pause menu or an open container (`native_mod_screen_open_over`, `native_mod_screen_under_rect`).",
"doc_summary": "`native_mod_screen_open_over(mod_id, keep_drawing_previous)` opens a mod's own screen over the options page (Options > Mods), the pause menu or the inventory or a container, not only over the world: Escape or `native_mod_screen_close` gives the replaced screen back, a server closing the container closes it for good, and a single-player game stays paused under a pausing screen. With `keep_drawing_previous` the pause menu's title or the container's panel is still drawn under the mod screen with no pointer over it, and `native_mod_screen_under_rect()` gives its panel (`[left, top, width, height]` in GUI pixels) for the mod to place its overlays by. It takes no clicks or keys while the mod screen is up. Bundles need \"hooks\": @API:mod_screen_over@.",
"comments": ["crates/horizon-client/src/game/native_mod_screen.rs"]
}