mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
Respect compiled standard-pack entity shadow ownership
Use the running pack's compiled non-player entity caster capability to avoid adding vanilla mob blobs beside its shadows. Preserve fallback for player-only, disabled, absent and failed paths and native dimensions. Add original compiled-pipeline and production sprite-selection controls. Co-Authored-By: Codex GPT-6.1-sol <[email protected]>
This commit is contained in:
1 parent
bc761767d3
commit
d3117ec15d
5 files changed
+397
-2
No files matched your search
@@ -0,0 +1,220 @@
|
||||
//! Original synthetic capability regression; no game resources or player world.
|
||||
//! Run with fresh HORIZON_HOME, HORIZON_LAUNCHER_HOME, XDG_DATA_HOME and XDG_CONFIG_HOME,
|
||||
//! HORIZON_NO_AUDIO=1 and HORIZON_LAUNCHER_NO_SELF_UPDATE=1.
|
||||
use horizon_client_render::{gfx::Renderer, graphics::GraphicsApi};
|
||||
use std::{io::Write, sync::Arc};
|
||||
use winit::{
|
||||
application::ApplicationHandler,
|
||||
event::WindowEvent,
|
||||
event_loop::{ActiveEventLoop, EventLoop},
|
||||
window::{Window, WindowId},
|
||||
};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvoid main() { gl_Position = ftransform(); }\n";
|
||||
const COLOUR: &str = "#version 120\nvoid main() { gl_FragColor = vec4(1.0); }\n";
|
||||
// Reading the actual shadow sampler activates the shadow pass, rather than just
|
||||
// placing a shadow program in the archive and assuming the pass runs.
|
||||
const SHADOW_READ: &str = "#version 120\nuniform sampler2D shadowtex0;\n\
|
||||
void main() { gl_FragColor = vec4(vec3(texture2D(shadowtex0, vec2(0.5)).r), 1.0); }\n";
|
||||
|
||||
fn pack(properties: &str, shadow: Option<&str>, reads_shadow: bool) -> horizon_shaders::Pack {
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut write = |name: &str, text: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(text.as_bytes()).unwrap();
|
||||
};
|
||||
write("shaders.properties", properties);
|
||||
write("final.vsh", VERTEX);
|
||||
write("final.fsh", if reads_shadow { SHADOW_READ } else { COLOUR });
|
||||
write("gbuffers_entities.vsh", VERTEX);
|
||||
write("gbuffers_entities.fsh", COLOUR);
|
||||
if let Some(shadow) = shadow {
|
||||
write("shadow.vsh", VERTEX);
|
||||
write("shadow.fsh", shadow);
|
||||
}
|
||||
horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-shadow-ownership",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let white = [255; 4];
|
||||
let panorama = Arc::new(horizon_client_render::gfx::panorama::Faces::colour([0; 4]));
|
||||
let mut renderer = pollster::block_on(Renderer::new(
|
||||
window,
|
||||
true,
|
||||
&white,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
&[],
|
||||
&[],
|
||||
(&white, 1, 1),
|
||||
(&white, 1, 1),
|
||||
(&white, 1, 1),
|
||||
(&white, 1),
|
||||
panorama,
|
||||
None,
|
||||
None,
|
||||
GraphicsApi::Vulkan,
|
||||
false,
|
||||
true,
|
||||
))?;
|
||||
assert!(!renderer.pack_entity_shadows(), "no selected pack");
|
||||
renderer.set_shader_pack(0, None, &[]);
|
||||
assert!(
|
||||
!renderer.pack_entity_shadows(),
|
||||
"selected pack without a compiled post"
|
||||
);
|
||||
|
||||
let cases = [
|
||||
("full", "", Some(COLOUR), true, false, true),
|
||||
(
|
||||
"player-only",
|
||||
"shadowEntities=false\nshadowPlayer=true",
|
||||
Some(COLOUR),
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
// Block-entity shadows alone must not suppress a mob's blob.
|
||||
(
|
||||
"blocks-only",
|
||||
"shadowEntities=false",
|
||||
Some(COLOUR),
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"disabled",
|
||||
"shadow.enabled=false",
|
||||
Some(COLOUR),
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
("missing-program", "", None, true, false, false),
|
||||
(
|
||||
"failed-translation",
|
||||
"",
|
||||
Some("#version 120\nvoid main() { gl_FragColor = deliberately_undefined; }"),
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
("failed-pipeline", "", Some(COLOUR), true, true, false),
|
||||
(
|
||||
"unused-shadow-program",
|
||||
"",
|
||||
Some(COLOUR),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"forced-shadow-program",
|
||||
"shadow.enabled=true",
|
||||
Some(COLOUR),
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
];
|
||||
for dimension in [0, -1, 1] {
|
||||
for (name, properties, shadow, reads_shadow, break_pipeline, expected) in cases {
|
||||
let pack = pack(properties, shadow, reads_shadow);
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
renderer.define_pack_macros(&mut macros);
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let mut post = horizon_shaders::load_post(
|
||||
&pack,
|
||||
"",
|
||||
&properties,
|
||||
¯os,
|
||||
&renderer.pack_inputs(),
|
||||
);
|
||||
if name == "failed-translation" {
|
||||
assert!(post.shadow_entities.is_none());
|
||||
assert!(post.notes.iter().any(|note| note.contains("skipped")));
|
||||
} else if name == "missing-program" {
|
||||
assert!(post.shadow_entities.is_none());
|
||||
assert!(post.notes.iter().any(|note| note.contains("no program")));
|
||||
} else if break_pipeline {
|
||||
// A translated stage can still fail device pipeline creation. Inject a
|
||||
// missing entry point into only the entity caster; terrain/block stages
|
||||
// and the settings stay valid. PackPost must report the compiled result.
|
||||
post.shadow_entities
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.program
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.vertex
|
||||
.entry_points[0]
|
||||
.name = "deliberately_missing_main".into();
|
||||
} else {
|
||||
assert!(post.notes.is_empty(), "{name}: {:?}", post.notes);
|
||||
}
|
||||
let (_, notes) = renderer
|
||||
.set_shader_pack(dimension, Some(post), &[])
|
||||
.unwrap();
|
||||
if break_pipeline {
|
||||
assert!(
|
||||
notes.iter().any(|note| note.contains("skipped")),
|
||||
"{notes:?}"
|
||||
);
|
||||
} else if name != "missing-program" && name != "failed-translation" {
|
||||
assert!(
|
||||
!notes.iter().any(|note| note.contains("skipped")),
|
||||
"{name}: {notes:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
renderer.pack_entity_shadows(),
|
||||
expected,
|
||||
"{name}, dimension {dimension}: {notes:?}"
|
||||
);
|
||||
assert!(!renderer.native_shadow());
|
||||
println!("{name}, dimension {dimension}: replaces_mob_blob={expected}");
|
||||
}
|
||||
}
|
||||
renderer.drop_shader_packs();
|
||||
assert!(
|
||||
!renderer.pack_entity_shadows(),
|
||||
"dropped pack restores fallback"
|
||||
);
|
||||
println!("ENTITY_SHADOW_OWNERSHIP_PASSED: 27 compiled controls and no/pending/dropped pack");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct App;
|
||||
impl ApplicationHandler for App {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
let window = Arc::new(
|
||||
event_loop
|
||||
.create_window(
|
||||
Window::default_attributes()
|
||||
.with_visible(false)
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(32., 32.)),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
if let Err(error) = fixture(window) {
|
||||
eprintln!("Shadow ownership fixture failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
event_loop.exit();
|
||||
}
|
||||
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
EventLoop::new().unwrap().run_app(&mut App).unwrap();
|
||||
}
|
||||
@@ -4422,6 +4422,18 @@ impl Renderer {
|
||||
((c.entities && !c.only_player) || c.blocks, c.entities)
|
||||
}
|
||||
|
||||
/// A standard pack replaces mobs' vanilla blob shadows only when its compiled
|
||||
/// shadow path actually draws entities other than the player.
|
||||
pub fn pack_entity_shadows(&self) -> bool {
|
||||
self.pack_post
|
||||
.as_ref()
|
||||
.and_then(|(_, post)| post.as_ref())
|
||||
.is_some_and(|post| {
|
||||
let casters = post.shadow_casters();
|
||||
casters.entities && !casters.only_player
|
||||
})
|
||||
}
|
||||
|
||||
pub fn has_section(&self, key: SectionKey) -> bool {
|
||||
self.sections.contains(key)
|
||||
}
|
||||
|
||||
@@ -10620,8 +10620,9 @@ impl Game<'_> {
|
||||
self.mark_translucent_entity(out.len(), "minecraft:entity_shadow");
|
||||
// A native shader pack's sun shadows already fall under the entities in the Overworld:
|
||||
// the round blob would be a second one. The Nether and the End have no sun to cast them.
|
||||
let pack_shadows =
|
||||
self.dim == Dim::Overworld && self.renderer.as_ref().is_some_and(|r| r.native_shadow());
|
||||
let pack_shadows = self.renderer.as_ref().is_some_and(|r| {
|
||||
r.pack_entity_shadows() || (self.dim == Dim::Overworld && r.native_shadow())
|
||||
});
|
||||
let shadow_sprite = (self.settings.entity_shadows && !pack_shadows)
|
||||
.then(|| self.gui.atlas.sprites.get("misc/shadow").copied())
|
||||
.flatten();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Game blob fallback through the actual production selection/caster methods."""
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class EntityShadowOwnership(unittest.TestCase):
|
||||
@unittest.skipUnless(shutil.which("rustc"), "requires Rust to execute production methods")
|
||||
def test_compiled_caster_paths_select_the_blob_fallback(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(ROOT / "tools/test_entity_shadow_ownership.py")],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute the production blob selection and compiled-caster methods in isolation.
|
||||
|
||||
This does not assert source spelling or duplicate the decision under test. The
|
||||
Rust statements are extracted unchanged; stand-ins supply the surrounding GPU
|
||||
handles and atlas. The companion entity_shadow_ownership example builds actual
|
||||
pipelines. Run this against an unfixed checkout with --source-root for the
|
||||
before control. No game, assets, launcher or player data are used.
|
||||
"""
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
def method(source, name):
|
||||
start = source.index(f"pub fn {name}(")
|
||||
opening = source.index("{", start)
|
||||
depth = 1
|
||||
end = opening + 1
|
||||
while depth:
|
||||
depth += (source[end] == "{") - (source[end] == "}")
|
||||
end += 1
|
||||
return source[start:end]
|
||||
|
||||
|
||||
def program(root):
|
||||
gfx = (root / "crates/horizon-client-render/src/gfx.rs").read_text()
|
||||
post = (root / "crates/horizon-client-render/src/gfx/pack_post.rs").read_text()
|
||||
game = (root / "crates/horizon-client/src/game.rs").read_text()
|
||||
geometry = game.index("fn entity_geometry(")
|
||||
start = game.index("let pack_shadows =", geometry)
|
||||
end = game.index("if let Some(sprite) = shadow_sprite", start)
|
||||
guard = game[start:end]
|
||||
getter = method(gfx, "pack_entity_shadows") if "pub fn pack_entity_shadows(" in gfx else ""
|
||||
support = r'''
|
||||
#![allow(dead_code)]
|
||||
mod pack_entities {
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct Casters {
|
||||
pub entities: bool, pub only_player: bool, pub blocks: bool, pub only_lit: bool,
|
||||
}
|
||||
}
|
||||
mod post {
|
||||
pub struct Entities { pub shadow: [Option<()>; 2], pub only: (bool, bool) }
|
||||
pub struct PackPost { pub entities: Option<Entities>, pub shadow_targets: Option<()> }
|
||||
impl PackPost { __CASTERS__ }
|
||||
}
|
||||
struct Native { shadow: Option<()> }
|
||||
struct Renderer { pack_post: Option<(i32, Option<post::PackPost>)>, native: Option<Native> }
|
||||
impl Renderer { __NATIVE__ __GETTER__ }
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Dim { Overworld, Nether, End }
|
||||
struct Settings { entity_shadows: bool }
|
||||
struct Atlas { sprites: std::collections::HashMap<&'static str, usize> }
|
||||
struct Gui { atlas: Atlas }
|
||||
struct Game { dim: Dim, renderer: Option<Renderer>, settings: Settings, gui: Gui }
|
||||
impl Game {
|
||||
fn selected_blob(&self) -> Option<usize> { __GUARD__ shadow_sprite }
|
||||
}
|
||||
fn scene(kind: &str, dim: Dim, enabled: bool, sprite: bool) -> Game {
|
||||
let casters = |entities: bool, only_player: bool, blocks: bool, targets: bool| {
|
||||
Some((0, Some(post::PackPost {
|
||||
entities: Some(post::Entities {
|
||||
shadow: [entities.then_some(()), blocks.then_some(())], only: (only_player, false),
|
||||
}),
|
||||
shadow_targets: targets.then_some(()),
|
||||
})))
|
||||
};
|
||||
let pack_post = match kind {
|
||||
"full" => casters(true, false, true, true),
|
||||
"player_only" => casters(true, true, true, true),
|
||||
"blocks_only" => casters(false, false, true, true),
|
||||
"failed_pipeline" => casters(false, false, false, true),
|
||||
"disabled_targets" => casters(true, false, true, false),
|
||||
"missing_entities" => Some((0, Some(post::PackPost { entities: None, shadow_targets: Some(()) }))),
|
||||
"pending" => Some((0, None)),
|
||||
_ => None,
|
||||
};
|
||||
Game {
|
||||
dim,
|
||||
renderer: (kind != "no_renderer").then_some(Renderer {
|
||||
pack_post,
|
||||
native: (kind == "native").then_some(Native { shadow: Some(()) }),
|
||||
}),
|
||||
settings: Settings { entity_shadows: enabled },
|
||||
gui: Gui { atlas: Atlas { sprites: if sprite {
|
||||
[("misc/shadow", 73)].into_iter().collect()
|
||||
} else { Default::default() } } },
|
||||
}
|
||||
}
|
||||
'''
|
||||
source = (support.replace("__CASTERS__", method(post, "shadow_casters"))
|
||||
.replace("__NATIVE__", method(gfx, "native_shadow"))
|
||||
.replace("__GETTER__", getter).replace("__GUARD__", guard))
|
||||
# Independent expected outcomes: a full standard entity caster owns the
|
||||
# mob shadow in each dimension. Native shadows own it only in the Overworld.
|
||||
rows = [
|
||||
("full", [None, None, None]),
|
||||
("player_only", [73, 73, 73]),
|
||||
("blocks_only", [73, 73, 73]),
|
||||
("failed_pipeline", [73, 73, 73]),
|
||||
("disabled_targets", [73, 73, 73]),
|
||||
("missing_entities", [73, 73, 73]),
|
||||
("pending", [73, 73, 73]),
|
||||
("no_pack", [73, 73, 73]),
|
||||
("no_renderer", [73, 73, 73]),
|
||||
("native", [None, 73, 73]),
|
||||
]
|
||||
for kind, expected in rows:
|
||||
for dimension, wanted in zip(["Overworld", "Nether", "End"], expected):
|
||||
result = "None" if wanted is None else f"Some({wanted})"
|
||||
source += f'''
|
||||
#[test] fn {kind}_{dimension.lower()}() {{
|
||||
assert_eq!(scene("{kind}", Dim::{dimension}, true, true).selected_blob(), {result});
|
||||
assert_eq!(scene("{kind}", Dim::{dimension}, false, true).selected_blob(), None);
|
||||
assert_eq!(scene("{kind}", Dim::{dimension}, true, false).selected_blob(), None);
|
||||
}}
|
||||
'''
|
||||
return source
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source-root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
args = parser.parse_args()
|
||||
with tempfile.TemporaryDirectory(prefix="horizon-shadow-ownership-") as folder:
|
||||
source = Path(folder) / "ownership.rs"
|
||||
source.write_text(program(args.source_root))
|
||||
binary = Path(folder) / "ownership"
|
||||
subprocess.run(["rustc", "--edition=2024", "--test", str(source), "-o", str(binary)], check=True)
|
||||
return subprocess.run([str(binary), "--nocapture"]).returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user