Merge main into #7149, regenerating the mod API host

This commit is contained in:
Alexander Krylov committed 2026-11-02 16:29:32 +03:00
commit a7c3be8ebd
16 files changed
+604 -52

No files matched your search

+12
View File
@@ -1447,6 +1447,13 @@ fn instance_descriptor(
}
descriptor.backends = backends.with_env();
let dx12 = cfg!(windows) && descriptor.backends.contains(wgpu::Backends::DX12);
// FXC spends about 2.6 s on every start compiling the pipelines (DXC about
// 1 s); what it compiled once is kept in the game folder and read back
// after that (third_party/wgpu-hal/src/dx12/disk_cache.rs).
#[cfg(windows)]
if dx12 {
wgpu::hal::dx12::disk_cache::set_file(dx12_shader_cache(&crate::logging::game_home()));
}
if !cfg!(debug_assertions) && !dx12 {
descriptor
.flags
@@ -1460,6 +1467,11 @@ fn instance_descriptor(
}
descriptor
}
/// Where DX12 keeps the shaders it compiled, inside a game folder.
pub fn dx12_shader_cache(home: &std::path::Path) -> std::path::PathBuf {
home.join("cache").join("dx12-shaders.bin")
}
/// The window's display connection, so the OpenGL backend creates its EGL
/// display from the X11 or Wayland connection the window lives on. Without
/// one, wgpu-hal falls back to `EGL_MESA_platform_surfaceless`, whose configs
+26 -2
View File
@@ -8098,6 +8098,10 @@ impl Game<'_> {
self.settings.file.bool("hideSplashTexts"),
);
}
// ScreenEvent.Render.Post over the title's own screens: a mod draws
// over them here (a pack browser over Create New World, say), and the
// toasts still come after, as over any screen.
self.fire_title_screen_render_post(&mut batch);
self.draw_toasts(&mut batch, false);
self.draw_tutorial_toasts(&mut batch, false);
self.draw_pack_toasts(&mut batch, false);
@@ -8443,6 +8447,21 @@ impl Game<'_> {
let view = self.screen_view(batch, screen);
self.native_screen_render_post(batch, &view);
}
/// `native_screen_render_post` (`ScreenEvent.Render.Post`) for the title's
/// own screen (`title`, `world_select`, `world_create`, `multiplayer`…),
/// once a frame, after it has drawn and before the toasts. Not when a
/// screen of the game's own is over the title (Options draws and fires
/// its own), nor while a dialog, a prompt or a connect or load hides it.
/// Client only, read-only: what a mod draws is its own.
fn fire_title_screen_render_post(&self, batch: &mut HudBatch) {
if self.screen != Screen::Playing || self.screen_over_playing() || self.remote_loading() {
return;
}
let Some(view) = self.title_screen_view(batch) else {
return;
};
self.native_screen_render_post(batch, &view);
}
/// `CreativeModeInventoryScreen.hasPermissions` (`canUseGameMasterBlocks`
/// and the Operator Items Tab option) given to `tryRefreshInvalidatedTabs`,
/// as the screen's constructor and `containerTick` do.
@@ -16810,14 +16829,19 @@ impl Game<'_> {
// the screen that is open then (`ScreenEvent.CharacterTyped.Pre`).
// Options keeps its text on the key: its own key path routes a key's
// text between its boxes and its key-bind capture, and no key that
// carries text closes that screen or moves its focus.
// carries text closes that screen or moves its focus. The title's
// screens keep theirs for the same reason: their key path types into
// the world name, the seed and a server's address.
let mut typed: Option<String> = None;
if let WindowEvent::KeyboardInput {
event: key_event, ..
} = &mut event
&& key_event.state == ElementState::Pressed
{
if self.screen_hooks_open() && self.screen != Screen::Options {
if self.screen_hooks_open()
&& self.screen != Screen::Options
&& !control::title_owns_text(&self.screen, self.title.is_some())
{
typed = key_event.text.take().map(|text| text.to_string());
}
let canceled = match key_event.physical_key {
+22
View File
@@ -233,6 +233,14 @@ impl Game<'_> {
self.sign_input(None, Some(text));
return Ok(());
}
// The title's screens: the world name, the seed, a server's address.
// A screen opened over the title (Options, say) keeps its own text.
if title_owns_text(&self.screen, self.title.is_some())
&& let Some(title) = &mut self.title
{
title.type_text(text);
return Ok(());
}
match self.screen {
Screen::Chat => self.chat_type(text),
Screen::CommandBlock => self.command_block_type(text),
@@ -1459,10 +1467,24 @@ fn key_code(name: &str) -> Option<KeyCode> {
})
}
/// The title's own screens (not one opened over it, as Options is) take the
/// text: the world name, the seed, a server's address.
pub(super) fn title_owns_text(screen: &Screen, title_up: bool) -> bool {
title_up && *screen == Screen::Playing
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_title_takes_text_only_on_its_own_screens() {
assert!(title_owns_text(&Screen::Playing, true));
assert!(!title_owns_text(&Screen::Playing, false));
assert!(!title_owns_text(&Screen::Options, true));
assert!(!title_owns_text(&Screen::Chat, true));
}
#[test]
fn hello_names_the_protocol_the_release_and_the_command_groups() {
let reply = hello();
@@ -486,4 +486,7 @@ impl Host for super::Game<'_> {
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()
}
}
+58 -24
View File
@@ -2419,13 +2419,15 @@ impl Game<'_> {
/// `&self`, so it reads and draws only. The Win and Options screens call
/// it on their own path, after their drawing.
///
/// It does not fire on the title screen, which returns before its point,
/// nor for the screens drawn over gameplay after it (a server dialog, the
/// server pack, link and code-of-conduct prompts, a book, a sign editor):
/// those keep `Screen::Playing`, which it skips (the server pack prompt
/// hides an open screen too, and that screen does not fire under it),
/// and draw over what it drew, while vanilla's `ScreenEvent.Render.Post`
/// fires for them.
/// It does not fire for the screens drawn over gameplay after it (a
/// server dialog, the server pack, link and code-of-conduct prompts, a
/// book, a sign editor): those keep `Screen::Playing`, which it skips (the
/// server pack prompt hides an open screen too, and that screen does not
/// fire under it), and draw over what it drew, while vanilla's
/// `ScreenEvent.Render.Post` fires for them. The title's own screens
/// (`title`, `world_select`, `world_create`, `multiplayer`…) fire it too,
/// their `screen.id` from the title state machine and the whole window
/// their `rect` (API @API:native_instance_dir@), and no dialog or prompt over them.
/// API 78.
pub(super) fn native_screen_render_post(
&self,
@@ -2445,10 +2447,11 @@ impl Game<'_> {
/// no screen button, no drag, no hotbar key, no sort or header button.
///
/// It fires before anything the screen does with the press, on the window
/// only, and only while a screen of the game's is open (Options and the
/// pack list opened from the title screen count, with no player and no
/// `menu`). It does not fire on the title screen itself, the connect and
/// loading screens, nor for a server
/// only, and while a screen of the game's or the title's is open (Options
/// and the pack list opened from the title screen count, with no player
/// and no `menu`; the title's own screens since API @API:native_instance_dir@, their
/// `screen.id` from the title state machine). It does not fire on the
/// connect and loading screens, nor for a server
/// dialog, the server pack, link and code-of-conduct prompts, a book or a
/// sign editor drawn over gameplay, nor while a world loads or saves: the
/// click there stays the game's own. A gamepad click or a click the
@@ -2535,9 +2538,10 @@ impl Game<'_> {
///
/// It fires before the screen scrolls anything, for the window's wheel
/// (horizon-ctl's `scroll-at` sends it through the window too), and
/// only while a screen of the game's is open (Options and the pack list
/// opened from the title screen count, with no player and no `menu`). It
/// does not fire on the title screen itself, the connect and loading
/// while a screen of the game's or the title's is open (Options and the
/// pack list opened from the title screen count, with no player and no
/// `menu`; the title's own since API @API:native_instance_dir@). It
/// does not fire on the connect and loading
/// screens, nor for a server dialog, the server pack, link and
/// code-of-conduct prompts, a book or a sign editor drawn over gameplay,
/// nor while a world loads or saves: the scroll there stays the game's
@@ -2573,10 +2577,10 @@ impl Game<'_> {
/// It fires for the window's presses and their repeats, and for the
/// control socket's `key` (a release fires nothing), after the window's
/// own shortcuts have run — the fullscreen and screenshot keys, the debug
/// keys and the narrator — and only while a screen of the game's is open
/// (Options and the pack list opened from the title screen count, with no
/// player and no `menu`). It does not fire on the title screen itself, the
/// connect and loading screens, nor for a server dialog, the server pack,
/// keys and the narrator — and while a screen of the game's or the title's
/// is open (Options and the pack list opened from the title screen count,
/// with no player and no `menu`; the title's own since API @API:native_instance_dir@). It does
/// not fire on the connect and loading screens, nor for a server dialog, the server pack,
/// link and code-of-conduct prompts, a book or a sign editor drawn over
/// gameplay, nor while a world loads or saves: the key there stays the
/// game's own. A cancelled press's release still runs, so a `native_key`
@@ -2675,6 +2679,23 @@ impl Game<'_> {
partial_tick: self.clock.fraction() as f32,
}
}
/// The title's own screen as a native mod sees it (`NativeScreenView`):
/// its id from the title state machine (`title`, `world_select`,
/// `world_create`, `multiplayer`, `add_server`…), no menu and no menu
/// title, the whole window as its rectangle and no slots (no menu is
/// open). None while no title screen is up. API @API:native_instance_dir@.
pub(super) fn title_screen_view(&self, batch: &HudBatch) -> Option<NativeScreenView<'_>> {
let title = self.title.as_ref()?;
Some(NativeScreenView {
id: title.mode.id(),
menu: None,
title: None,
rect: [0.0, 0.0, batch.width, batch.height],
slots: 0,
mouse: self.native_cursor(),
partial_tick: self.clock.fraction() as f32,
})
}
/// The open screen's container panel in GUI pixels (`screen`'s, the one
/// this frame draws), or the whole window for a screen with none
/// (`NativeScreenView.rect`).
@@ -2721,15 +2742,16 @@ impl Game<'_> {
canceled
}
/// A screen the native mods' input hooks are for is open: the window has a
/// renderer, `Screen::Playing` is behind it (so not the title screen, nor a
/// book, a sign editor, a server dialog or a prompt drawn over gameplay)
/// and no connect or world load is in progress. The one gate they all ask
/// before they fire.
/// renderer, either the title's own (`title`, `world_select`,
/// `world_create`, `multiplayer`…) or a screen over the world
/// (`Screen::Playing` behind it, so not a book, a sign editor, a server
/// dialog or a prompt drawn over gameplay), and no connect or world load
/// is in progress. The one gate they all ask before they fire.
pub(super) fn screen_hooks_open(&self) -> bool {
self.renderer.is_some()
&& self.screen != Screen::Playing
&& !self.screen_over_playing()
&& !self.remote_loading()
&& (self.screen != Screen::Playing || self.title.is_some())
}
/// The open screen as an input hook sees it (`NativeScreenView`), or None
/// when `screen_hooks_open` says no screen is. `screen.mouse` is the
@@ -2743,7 +2765,10 @@ impl Game<'_> {
}
let renderer = self.renderer.as_ref()?;
let batch = HudBatch::new([renderer.config.width, renderer.config.height]);
Some(self.screen_view(&batch, self.screen))
if self.screen != Screen::Playing {
return Some(self.screen_view(&batch, self.screen));
}
self.title_screen_view(&batch)
}
/// Whether the key press `key` over the open screen went no further
/// (`native_screen_key_pressed_pre`): what `handle_window_event` and the
@@ -7398,6 +7423,15 @@ impl Game<'_> {
.chat
.add(atlas, line, None, ChatSource::Client, self.ticks, focused);
}
/// The instance (game) folder, which its players share: `saves/`,
/// `resourcepacks/`, `shaderpacks/` and `horizon-mods/` are in it, and the
/// title's screens create worlds and read packs there. A world's own
/// `datapacks/` is in that world's folder (`saves/<world>/datapacks`).
/// The folder the game runs in (`--player-dir` moves a player's options
/// and servers only, not the instance). Nothing is created. API @API:native_instance_dir@.
pub(super) fn native_instance_dir(&self) -> PathBuf {
Self::game_dir()
}
/// A native mod's own folder: `horizon-mods/.horizon-mod-data/<id>/`,
/// where the WebAssembly mod of the same id kept its storage, so a
/// player's settings carry over. Not created here.
+4 -1
View File
@@ -110,7 +110,10 @@ pub struct NativeScreenView<'a> {
/// on it. Every container, station and mod window draws on the inventory
/// screen and so reports `inventory`: tell them apart by `menu` and
/// `title` (both None on a window the client opened itself) and by
/// `slots`.
/// `slots`. The title's own screens report theirs (API @API:native_instance_dir@): `title`,
/// `world_select`, `world_create`, `world_create_flat`, `world_edit`,
/// `multiplayer`, `add_server`, `direct_connect`…, with no menu, no title
/// and no slots.
pub id: &'static str,
/// The type id of the menu the server opened for this screen
/// (`minecraft:generic_9x3`, `minecraft:furnace`, a mod's own id; a mount's
+22 -18
View File
@@ -1187,13 +1187,15 @@ pub trait Hooks: Send + Sync {
/// it is. Once a frame on the window, never while playing with no screen;
/// `&self`, so it reads and draws only. The Win and Options screens call
/// it on their own path, after their drawing.
/// It does not fire on the title screen, which returns before its point,
/// nor for the screens drawn over gameplay after it (a server dialog, the
/// server pack, link and code-of-conduct prompts, a book, a sign editor):
/// those keep `Screen::Playing`, which it skips (the server pack prompt
/// hides an open screen too, and that screen does not fire under it),
/// and draw over what it drew, while vanilla's `ScreenEvent.Render.Post`
/// fires for them.
/// It does not fire for the screens drawn over gameplay after it (a
/// server dialog, the server pack, link and code-of-conduct prompts, a
/// book, a sign editor): those keep `Screen::Playing`, which it skips (the
/// server pack prompt hides an open screen too, and that screen does not
/// fire under it), and draw over what it drew, while vanilla's
/// `ScreenEvent.Render.Post` fires for them. The title's own screens
/// (`title`, `world_select`, `world_create`, `multiplayer`…) fire it too,
/// their `screen.id` from the title state machine and the whole window
/// their `rect` (API @API:native_instance_dir@), and no dialog or prompt over them.
/// API 78.
fn screen_render_post(&self, host: &dyn Host, event: &mut ScreenRenderPostEvent<'_>) {
let _ = host;
@@ -1225,10 +1227,11 @@ pub trait Hooks: Send + Sync {
/// alt 4, super 8). Set `canceled` and the press goes no further: no slot,
/// no screen button, no drag, no hotbar key, no sort or header button.
/// It fires before anything the screen does with the press, on the window
/// only, and only while a screen of the game's is open (Options and the
/// pack list opened from the title screen count, with no player and no
/// `menu`). It does not fire on the title screen itself, the connect and
/// loading screens, nor for a server
/// only, and while a screen of the game's or the title's is open (Options
/// and the pack list opened from the title screen count, with no player
/// and no `menu`; the title's own screens since API @API:native_instance_dir@, their
/// `screen.id` from the title state machine). It does not fire on the
/// connect and loading screens, nor for a server
/// dialog, the server pack, link and code-of-conduct prompts, a book or a
/// sign editor drawn over gameplay, nor while a world loads or saves: the
/// click there stays the game's own. A gamepad click or a click the
@@ -1418,9 +1421,10 @@ pub trait Hooks: Send + Sync {
/// changes either.
/// It fires before the screen scrolls anything, for the window's wheel
/// (horizon-ctl's `scroll-at` sends it through the window too), and
/// only while a screen of the game's is open (Options and the pack list
/// opened from the title screen count, with no player and no `menu`). It
/// does not fire on the title screen itself, the connect and loading
/// while a screen of the game's or the title's is open (Options and the
/// pack list opened from the title screen count, with no player and no
/// `menu`; the title's own since API @API:native_instance_dir@). It
/// does not fire on the connect and loading
/// screens, nor for a server dialog, the server pack, link and
/// code-of-conduct prompts, a book or a sign editor drawn over gameplay,
/// nor while a world loads or saves: the scroll there stays the game's
@@ -1488,10 +1492,10 @@ pub trait Hooks: Send + Sync {
/// It fires for the window's presses and their repeats, and for the
/// control socket's `key` (a release fires nothing), after the window's
/// own shortcuts have run — the fullscreen and screenshot keys, the debug
/// keys and the narrator — and only while a screen of the game's is open
/// (Options and the pack list opened from the title screen count, with no
/// player and no `menu`). It does not fire on the title screen itself, the
/// connect and loading screens, nor for a server dialog, the server pack,
/// keys and the narrator — and while a screen of the game's or the title's
/// is open (Options and the pack list opened from the title screen count,
/// with no player and no `menu`; the title's own since API @API:native_instance_dir@). It does
/// not fire on the connect and loading screens, nor for a server dialog, the server pack,
/// link and code-of-conduct prompts, a book or a sign editor drawn over
/// gameplay, nor while a world loads or saves: the key there stays the
/// game's own. A cancelled press's release still runs, so a `native_key`
+9
View File
@@ -1193,4 +1193,13 @@ pub trait Host {
let _ = rgba;
unimplemented!("Host::mod_image")
}
/// The instance (game) folder, which its players share: `saves/`,
/// `resourcepacks/`, `shaderpacks/` and `horizon-mods/` are in it, and the
/// title's screens create worlds and read packs there. A world's own
/// `datapacks/` is in that world's folder (`saves/<world>/datapacks`).
/// The folder the game runs in (`--player-dir` moves a player's options
/// and servers only, not the instance). Nothing is created. API @API:native_instance_dir@.
fn instance_dir(&self) -> PathBuf {
unimplemented!("Host::instance_dir")
}
}
+18
View File
@@ -138,6 +138,24 @@ pub enum Outcome {
Back,
Connect { address: String, lan: bool },
}
impl Screen {
/// The id native mods see for this screen (`NativeScreenView.id`, the
/// screen hooks): one per variant, a hook's contract, so it stays the
/// same from release to release. A new variant must add one.
pub fn id(&self) -> &'static str {
match self {
Self::List { .. } => "multiplayer",
Self::Edit { index: None, .. } => "add_server",
Self::Edit { index: Some(_), .. } => "edit_server",
Self::Direct { .. } => "direct_connect",
Self::ConfirmDelete { .. } => "server_delete",
Self::Friend { .. } => "friends_join",
Self::Disconnected { .. } => "disconnected",
}
}
}
/// A button: x, y, width, label, enabled, id.
pub type ButtonSpec = (f32, f32, f32, String, bool, u8);
+29
View File
@@ -699,6 +699,35 @@ fn rolls_user_splash(text: &str, seed: u64, date: (u32, u32)) -> bool {
.is_ok_and(|size| size > 0 && LegacyRandom::new(seed as i64).next_int_bound(size) == Ok(42))
}
impl Mode {
/// The id native mods see for this screen (`NativeScreenView.id`, the
/// screen hooks): one per variant, a hook's contract, so it stays the
/// same from release to release. A new variant must add one.
pub fn id(&self) -> &'static str {
match self {
Self::Main => "title",
Self::Select { .. } => "world_select",
Self::Create { .. } => "world_create",
Self::CreateFlat { .. } => "world_create_flat",
Self::CreateBuffet { .. } => "world_create_buffet",
Self::Edit { .. } => "world_edit",
Self::Backup { .. } => "world_backup",
Self::BackupBeforeUpgrade { .. } => "world_backup_confirm",
Self::OptimizeConfirm { .. } => "world_optimize_confirm",
Self::Optimize { .. } => "world_optimize",
Self::LowDiskSpace { .. } => "low_disk_space",
Self::Delete { .. } => "world_delete",
Self::Recover { .. } => "world_recover",
Self::Recovered { .. } => "world_recovered",
Self::Error { .. } => "error",
Self::Multiplayer(screen) => screen.id(),
Self::MultiplayerWarning { .. } => "multiplayer_warning",
Self::Onboarding(_) => "onboarding",
Self::BanNotice(_) => "ban_notice",
}
}
}
impl Title {
/// Names the player for the `<NAME> IS YOU` splash; `name` is only asked for when that splash was rolled.
pub fn with_user(mut self, name: impl FnOnce() -> String) -> Self {
+73
View File
@@ -2278,3 +2278,76 @@ fn a_create_world_tab_label_wider_than_its_tab_stays_inside_it() {
);
}
}
/// The ids native mods see for the title's screens (`Mode::id`, the screen
/// hooks' `NativeScreenView.id`): one per screen, stable across releases.
#[test]
fn the_title_screens_have_their_own_ids() {
let mut f = Fixture::new();
assert_eq!(f.title.mode.id(), "title");
// An empty list opens Create New World (an empty list is why vanilla
// starts one), a world in it opens the list.
f.title.enter_singleplayer();
assert!(matches!(f.title.mode, Mode::Create { .. }));
assert_eq!(f.title.mode.id(), "world_create");
f.title.escape();
assert_eq!(f.title.mode.id(), "title");
f.world("World", "World");
f.select();
assert_eq!(f.title.mode.id(), "world_select");
f.click("Edit");
assert_eq!(f.title.mode.id(), "world_edit");
}
/// The multiplayer screens' ids, the ones the title's `Multiplayer` mode
/// passes through.
#[test]
fn the_multiplayer_screens_have_their_own_ids() {
use crate::multiplayer::Screen as M;
assert_eq!(
M::Direct {
address: String::new()
}
.id(),
"direct_connect"
);
assert_eq!(
M::Edit {
index: None,
name: String::new(),
address: String::new(),
focus: 0,
packs: None,
}
.id(),
"add_server"
);
assert_eq!(
M::Edit {
index: Some(0),
name: String::new(),
address: String::new(),
focus: 0,
packs: None,
}
.id(),
"edit_server"
);
assert_eq!(
M::Friend {
code: String::new(),
relay: String::new(),
focus: 0,
}
.id(),
"friends_join"
);
assert_eq!(
M::Disconnected {
reason: String::new(),
retry: None,
}
.id(),
"disconnected"
);
}
+22 -7
View File
@@ -424,14 +424,29 @@ impl super::Device {
key.shader_model.to_str()
);
let compiled_shader = self.compiler_container.compile(
self,
&key.source,
source_name,
// Horizon: a shader compiled in an earlier run comes from the disk.
let disk_key = super::disk_cache::key(
self.compiler_container.cache_name(),
key.shader_model.to_str(),
naga::back::hlsl::shader_stage_to_hlsl_str(naga_stage),
&key.entry_point,
stage_bit,
&full_stage,
)?;
&key.source,
);
let compiled_shader = match super::disk_cache::get(disk_key) {
Some(bytes) => super::CompiledShader::Precompiled(bytes),
None => {
let compiled = self.compiler_container.compile(
self,
&key.source,
source_name,
&key.entry_point,
stage_bit,
&full_stage,
)?;
super::disk_cache::put(disk_key, compiled.bytes());
compiled
}
};
{
let mut shader_cache = self.shader_cache.lock();
+275
View File
@@ -0,0 +1,275 @@
//! Horizon: compiled shaders kept on disk between runs.
//!
//! FXC takes about 2.6 s to compile Horizon's ~65 pipelines on every start
//! (DXC about 1 s), while the in-memory [`super::ShaderCache`] only helps
//! within one device. The application names one file with [`set_file`]. A
//! shader compiled once is appended there, and the next run reads its bytecode
//! back as [`super::CompiledShader::Precompiled`] instead of compiling it.
//!
//! The key is a 128-bit FNV-1a hash of the compiler, the shader model, the
//! stage, the entry point and the HLSL source naga wrote. Any change to
//! naga's output, the shader, or the compiler choice is a different key, so
//! the file never needs explicit invalidation. Each record carries a checksum
//! of its bytes; reading stops at the first record that is short or does not
//! match (a run killed mid-write, two games writing at once), and the next
//! append starts there. A file past [`MAX_BYTES`] is started over.
//!
//! File: `MAGIC`, then records of `key: u128 LE`, `len: u32 LE`,
//! `check: u64 LE` (FNV-1a 64 of the bytes), `bytes`.
use alloc::vec::Vec;
use std::{
fs,
io::{Seek as _, SeekFrom, Write as _},
path::PathBuf,
sync::{Mutex, OnceLock},
};
use hashbrown::HashMap;
const MAGIC: &[u8; 8] = b"HZDXSC1\n";
const HEADER: usize = 16 + 4 + 8;
/// Past this the file is started over: Horizon's shaders take about 1 MB.
pub const MAX_BYTES: u64 = 64 << 20;
static FILE: OnceLock<PathBuf> = OnceLock::new();
static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
/// Keep compiled shaders in `path` for the rest of the run. Only the first
/// call counts; without one nothing is kept on disk.
pub fn set_file(path: PathBuf) {
let _ = FILE.set(path);
}
pub(super) struct Cache {
path: PathBuf,
entries: HashMap<u128, Vec<u8>>,
/// Where the next record goes: the end of the last good record.
end: u64,
}
fn cache() -> Option<&'static Mutex<Cache>> {
let path = FILE.get()?;
Some(CACHE.get_or_init(|| Mutex::new(Cache::load(path.clone()))))
}
/// The bytecode compiled for `key` in an earlier run.
pub(super) fn get(key: u128) -> Option<Vec<u8>> {
let cache = cache()?.lock().ok()?;
cache.entries.get(&key).cloned()
}
/// Keep `bytes` for `key` for the next runs.
pub(super) fn put(key: u128, bytes: &[u8]) {
let Some(cache) = cache() else { return };
let Ok(mut cache) = cache.lock() else { return };
if let Err(error) = cache.append(key, bytes) {
log::warn!("DX12 shader cache {}: {error}", cache.path.display());
}
}
/// The key of one compile.
pub(super) fn key(
compiler: &str,
shader_model: &str,
stage: &str,
entry: &str,
source: &str,
) -> u128 {
let mut hash = Fnv128::new();
for part in [compiler, shader_model, stage, entry, source] {
hash.write(&(part.len() as u64).to_le_bytes());
hash.write(part.as_bytes());
}
hash.0
}
impl Cache {
fn load(path: PathBuf) -> Self {
let bytes = match fs::metadata(&path) {
Ok(meta) if meta.len() <= MAX_BYTES => fs::read(&path).unwrap_or_default(),
_ => Vec::new(),
};
let (entries, end) = parse(&bytes);
Self { path, entries, end }
}
fn append(&mut self, key: u128, bytes: &[u8]) -> std::io::Result<()> {
if self.entries.contains_key(&key) {
return Ok(());
}
let len = u32::try_from(bytes.len()).map_err(std::io::Error::other)?;
if self.end + (HEADER + bytes.len()) as u64 > MAX_BYTES {
self.end = 0;
self.entries.clear();
}
if let Some(dir) = self.path.parent() {
fs::create_dir_all(dir)?;
}
let mut file = fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&self.path)?;
let mut record = Vec::with_capacity(MAGIC.len() + HEADER + bytes.len());
if self.end == 0 {
record.extend_from_slice(MAGIC);
}
record.extend_from_slice(&key.to_le_bytes());
record.extend_from_slice(&len.to_le_bytes());
record.extend_from_slice(&fnv64(bytes).to_le_bytes());
record.extend_from_slice(bytes);
// Drop whatever follows the last good record, then write after it.
file.set_len(self.end)?;
file.seek(SeekFrom::Start(self.end))?;
file.write_all(&record)?;
self.end += record.len() as u64;
self.entries.insert(key, bytes.to_vec());
Ok(())
}
}
/// The records of a cache file and where its good part ends.
fn parse(bytes: &[u8]) -> (HashMap<u128, Vec<u8>>, u64) {
let mut entries = HashMap::new();
if bytes.get(..MAGIC.len()) != Some(MAGIC.as_slice()) {
return (entries, 0);
}
let mut at = MAGIC.len();
while let Some(header) = bytes.get(at..at + HEADER) {
let key = u128::from_le_bytes(header[..16].try_into().unwrap());
let len = u32::from_le_bytes(header[16..20].try_into().unwrap()) as usize;
let check = u64::from_le_bytes(header[20..28].try_into().unwrap());
let Some(body) = bytes.get(at + HEADER..at + HEADER + len) else {
break;
};
if fnv64(body) != check {
break;
}
entries.insert(key, body.to_vec());
at += HEADER + len;
}
(entries, at as u64)
}
struct Fnv128(u128);
impl Fnv128 {
fn new() -> Self {
Self(0x6c62272e07bb014262b821756295c58d)
}
fn write(&mut self, bytes: &[u8]) {
for &b in bytes {
self.0 ^= u128::from(b);
self.0 = self.0.wrapping_mul(0x0000000001000000000000000000013b);
}
}
}
fn fnv64(bytes: &[u8]) -> u64 {
let mut hash = 0xcbf29ce484222325u64;
for &b in bytes {
hash ^= u64::from(b);
hash = hash.wrapping_mul(0x100000001b3);
}
hash
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("hz-dx12-cache-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
dir.join("cache/dx12-shaders.bin")
}
#[test]
fn a_shader_kept_in_one_run_is_read_back_in_the_next() {
let path = scratch("next");
let vs = key(
"fxc",
"5_1",
"vs",
"main",
"float4 main() : SV_Position { return 0; }",
);
let fs_ = key(
"fxc",
"5_1",
"ps",
"main",
"float4 main() : SV_Target { return 1; }",
);
let mut run = Cache::load(path.clone());
run.append(vs, b"DXBC vertex").unwrap();
run.append(fs_, b"DXBC fragment").unwrap();
let next = Cache::load(path.clone());
assert_eq!(
next.entries.get(&vs).map(Vec::as_slice),
Some(&b"DXBC vertex"[..])
);
assert_eq!(
next.entries.get(&fs_).map(Vec::as_slice),
Some(&b"DXBC fragment"[..])
);
let _ = fs::remove_dir_all(path.parent().unwrap().parent().unwrap());
}
#[test]
fn each_part_of_the_compile_changes_the_key() {
let base = key("fxc", "5_1", "vs", "main", "src");
assert_ne!(base, key("dxc", "5_1", "vs", "main", "src"));
assert_ne!(base, key("fxc", "6_0", "vs", "main", "src"));
assert_ne!(base, key("fxc", "5_1", "ps", "main", "src"));
assert_ne!(base, key("fxc", "5_1", "vs", "main2", "src"));
assert_ne!(base, key("fxc", "5_1", "vs", "main", "src "));
// Lengths are hashed, so moving a byte between parts changes the key.
assert_ne!(key("ab", "c", "", "", ""), key("a", "bc", "", "", ""));
}
#[test]
fn a_torn_record_is_dropped_and_the_next_one_written_over_it() {
let path = scratch("torn");
let a = key("fxc", "5_1", "vs", "a", "a");
let b = key("fxc", "5_1", "vs", "b", "b");
let c = key("fxc", "5_1", "vs", "c", "c");
let mut run = Cache::load(path.clone());
run.append(a, b"first").unwrap();
run.append(b, b"second").unwrap();
// The run is killed while it writes `b`: its last bytes never land.
let full = fs::read(&path).unwrap();
fs::write(&path, &full[..full.len() - 3]).unwrap();
let mut next = Cache::load(path.clone());
assert_eq!(next.entries.len(), 1);
assert!(next.entries.contains_key(&a));
next.append(c, b"third").unwrap();
let last = Cache::load(path.clone());
assert_eq!(last.entries.len(), 2);
assert_eq!(last.entries.get(&c).map(Vec::as_slice), Some(&b"third"[..]));
// A flipped byte fails the checksum the same way.
let mut bytes = fs::read(&path).unwrap();
let at = bytes.len() - 1;
bytes[at] ^= 1;
fs::write(&path, bytes).unwrap();
assert_eq!(Cache::load(path.clone()).entries.len(), 1);
let _ = fs::remove_dir_all(path.parent().unwrap().parent().unwrap());
}
#[test]
fn a_file_that_is_not_a_cache_is_started_over() {
let path = scratch("foreign");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, b"not a shader cache at all").unwrap();
let mut run = Cache::load(path.clone());
assert!(run.entries.is_empty());
let k = key("dxc", "6_0", "cs", "main", "x");
run.append(k, b"DXIL").unwrap();
let next = Cache::load(path.clone());
assert_eq!(next.entries.get(&k).map(Vec::as_slice), Some(&b"DXIL"[..]));
let _ = fs::remove_dir_all(path.parent().unwrap().parent().unwrap());
}
}
+11
View File
@@ -83,6 +83,7 @@ mod conv;
mod dcomp;
mod descriptor;
mod device;
pub mod disk_cache;
mod device_creation;
mod instance;
mod pipeline_desc;
@@ -1353,6 +1354,16 @@ pub(super) enum CompiledShader {
}
impl CompiledShader {
/// The bytecode, as [`disk_cache`] keeps it.
fn bytes(&self) -> &[u8] {
let code = self.create_native_shader();
if code.BytecodeLength == 0 {
return &[];
}
// SAFETY: the blob or vector `code` points into lives as long as `self`.
unsafe { core::slice::from_raw_parts(code.pShaderBytecode.cast(), code.BytecodeLength) }
}
fn create_native_shader(&self) -> Direct3D12::D3D12_SHADER_BYTECODE {
match self {
CompiledShader::Dxc(shader) => Direct3D12::D3D12_SHADER_BYTECODE {
+9
View File
@@ -104,6 +104,15 @@ impl CompilerContainer {
}
}
/// Which compiler made a shader, for [`super::disk_cache`]'s keys.
pub(super) fn cache_name(&self) -> &'static str {
match self {
CompilerContainer::Fxc(_) => "fxc",
CompilerContainer::DynamicDxc(_) => "dxc",
CompilerContainer::StaticDxc(_) => "static-dxc",
}
}
pub(super) fn compile(
&self,
device: &super::Device,
@@ -0,0 +1,11 @@
{
"hooks": [],
"helpers": [
{
"name": "instance_dir",
"target": "horizon_client::game::native_hooks::Game<'_>::native_instance_dir"
}
],
"rust_summary": "the title's own screens to the screen hooks, and the instance folder helper (`native_instance_dir`).",
"doc_summary": "the title's own screens to the screen hooks. `NativeScreenView.id` gains one id per title state (`title`, `world_select`, `world_create`, `world_create_flat`, `world_create_buffet`, `world_edit`, `world_backup`, `world_backup_confirm`, `world_optimize_confirm`, `world_optimize`, `low_disk_space`, `world_delete`, `world_recover`, `world_recovered`, `error`, `multiplayer`, `add_server`, `edit_server`, `direct_connect`, `server_delete`, `friends_join`, `disconnected`, `multiplayer_warning`, `onboarding`, `ban_notice`), with no `menu`, no `title` and no `slots`, and the whole window as `rect`. `native_screen_render_post` fires after the title draws its screen (before its toasts), and the `_pre` input hooks — mouse buttons, drag, wheel, key press and release, character typed — fire on it and can cancel it as on any screen, so a mod draws its own button or browser over Create New World and takes the clicks. A dialog, a prompt or a server's pack prompt over the title keeps them away, and typing still goes to the title's own boxes (the key's text stays on the key there, as on Options, so `native_screen_char_typed_pre` does not fire from the keyboard on the title's own screens; the control socket's `type` now types into them too). `native_instance_dir()` is the instance folder, where `options.txt`, `saves/`, `resourcepacks/`, `shaderpacks/` and `horizon-mods/` are; a world's `datapacks/` is in that world's folder. Bundles that use them need `\"hooks\": @API:native_instance_dir@`."
}