Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions crates/fpsmaster_app/src/game.rs
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,9 @@ pub struct GameState {
/// Player chunk at the last `enforce_render_distance` pass, so the scan runs
/// only when crossing a chunk boundary.
last_residency_chunk: Option<ChunkPos>,
/// Set when a dimension-changing Respawn discarded the whole world; the host
/// must then drop every GPU chunk mesh (see `take_world_reset`).
world_reset: bool,
/// Set for a built-in single-player world: the terrain generator that
/// streams chunks around the player (`stream_local_world`). `None` for demo
/// worlds and server sessions, which get their chunks elsewhere.
Expand Down Expand Up @@ -1332,6 +1335,7 @@ impl GameState {
urgent_remesh: HashSet::new(),
evicted_columns: HashSet::new(),
last_residency_chunk: None,
world_reset: false,
local_worldgen: None,
pending_block_changes: std::collections::HashMap::new(),
chest_lid_angles: std::collections::HashMap::new(),
Expand Down Expand Up @@ -5928,6 +5932,15 @@ impl GameState {
}

fn handle_respawn(&mut self, dimension: i32, game_mode: u8) -> bool {
// Vanilla builds a fresh WorldClient when the dimension changes. Bungee
// style proxies switch servers by sending a Respawn into another
// dimension and then the target one, and the old server never sends
// ChunkUnload — so without this the previous server's terrain and
// entities stay in memory and linger as ghosts in the new world.
// A same-dimension Respawn (death) keeps the world, as in vanilla.
if dimension as i8 != self.dimension {
self.reset_world();
}
// The server resends chunks plus a fresh PlayerPositionLook at the
// respawn point; wait for that position before reporting movement
// again so we don't send a stale (death-location) position.
Expand All @@ -5948,6 +5961,47 @@ impl GameState {
false
}

/// Discard every chunk, entity (except the local player) and per-world side
/// table, as vanilla does by replacing the WorldClient on a dimension
/// change. Flags `world_reset` so the host also drops all GPU meshes.
fn reset_world(&mut self) {
let mut world = World::new();
world.set_has_sky_light(self.world.has_sky_light());
world.upsert_entity(self.player.clone());
self.world = world;
self.world_reset = true;
self.dirty_chunks.clear();
self.urgent_remesh.clear();
self.evicted_columns.clear();
self.last_residency_chunk = None;
self.pending_block_changes.clear();
self.entity_uuids.clear();
self.entity_items.clear();
self.entity_xp.clear();
self.falling_blocks.clear();
self.entity_equipment.clear();
self.vehicles.clear();
self.entity_prev_render_pos.clear();
for s in self.entity_sounds.drain(..) {
self.moving_sound_commands
.push(MovingSoundCommand::Stop { id: s.sound_id });
}
self.chest_lid_angles.clear();
self.chest_open_targets.clear();
self.signs.clear();
self.skulls.clear();
self.lightning_bolts.clear();
self.weather = Weather::default();
self.particles.clear();
}

/// Whether the world was discarded (dimension change) since the last call;
/// the host then drops every GPU chunk mesh in one go instead of trickling
/// thousands of removals through the per-frame dirty budget.
pub fn take_world_reset(&mut self) -> bool {
std::mem::take(&mut self.world_reset)
}

fn handle_player_position_look(
&mut self,
x: f64,
Expand Down Expand Up @@ -6426,6 +6480,23 @@ impl GameState {
}
}

/// uuids whose skin is drawn in the loaded world: spawned players, block
/// skull owners and the local player (looked up by `local_name` in the tab
/// list). The skin manager keeps atlas rows for these first.
pub fn skin_owners_in_view(&self, local_name: &str) -> HashSet<[u8; 16]> {
let local = self
.player_list
.iter()
.find(|(_, info)| info.name == local_name)
.map(|(uuid, _)| *uuid);
self.entity_uuids
.values()
.copied()
.chain(self.skulls.values().filter_map(|skull| skull.owner))
.chain(local)
.collect()
}

/// Owner profiles seen on player skulls that carry a skin texture, for the
/// host to hand to the skin downloader (the same route the tab-list roster
/// takes). Skulls can name players who were never in the player list, so
Expand Down Expand Up @@ -9258,6 +9329,58 @@ mod interaction_tests {
assert_eq!(gs.hud_vitals().absorption, 0.0);
}

/// A proxy server switch (Respawn into another dimension, then the target
/// one) discards the previous server's chunks and entities; a same-dimension
/// Respawn (death) keeps them.
#[test]
fn dimension_change_respawn_discards_world() {
let mut gs = GameState::empty_for_server(1.0);
gs.apply_play_packet(ClientboundPlayPacket::JoinGame {
entity_id: 7,
game_mode: 0,
dimension: 0,
difficulty: 0,
max_players: 20,
level_type: "default".to_owned(),
reduced_debug_info: false,
});
gs.world.set_block(0, 64, 0, BlockState::new(1, 0));
gs.world.set_block(40, 64, 40, BlockState::new(1, 0));
gs.apply_play_packet(ClientboundPlayPacket::SpawnPlayer {
entity_id: 8,
uuid: [1u8; 16],
x: 0.5,
y: 65.0,
z: 0.5,
yaw: 0.0,
pitch: 0.0,
});
gs.pending_block_changes
.insert(ChunkPos::new(9, 9), vec![(144, 64, 144, BlockState::new(1, 0))]);
let respawn = |dimension| ClientboundPlayPacket::Respawn {
dimension,
difficulty: 0,
game_mode: 0,
level_type: "default".to_owned(),
};

// Death respawn in the same dimension keeps the world.
gs.apply_play_packet(respawn(0));
assert_eq!(gs.loaded_chunk_count(), 2);
assert!(!gs.take_world_reset());

gs.apply_play_packet(respawn(-1));
gs.apply_play_packet(respawn(0));
assert_eq!(gs.loaded_chunk_count(), 0);
assert!(gs.world.entity(EntityId(8)).is_none());
assert!(gs.world.entity(EntityId(7)).is_some(), "local player survives");
assert!(gs.entity_uuids.is_empty());
assert!(gs.pending_block_changes.is_empty());
assert!(gs.dirty_chunks.is_empty());
assert!(gs.take_world_reset());
assert!(!gs.take_world_reset());
}

#[test]
fn respawn_clears_effects_and_resets_max_health() {
use fpsmaster_protocol::v1_8_9::packets::EntityProperty;
Expand Down
4 changes: 4 additions & 0 deletions crates/fpsmaster_app/src/gui/ingame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,11 @@ fn draw_debug_overlay(
format!("submit {}us acquire {}us", s.submit_us, s.acquire_us),
format!("draws {} visible {}", s.draw_calls, s.visible_chunks),
format!("tris {}", s.chunk_indices / 3),
format!("mesh queue {}", s.mesh_in_flight),
];
if s.mesh_failures > 0 {
right.push(format!("mesh failures {} (see log)", s.mesh_failures));
}
// Per-phase CPU frame breakdown (from the frame profiler): which subsystem owns
// the frame. `avg/max ms` over the last ~1s. Phases under 0.05ms max are hidden.
let p = &info.phases;
Expand Down
18 changes: 9 additions & 9 deletions crates/fpsmaster_app/src/gui/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,14 +184,14 @@ impl GuiScreen for GuiOptions {
self.sensitivity_rect,
s,
ctx.settings.sensitivity,
&format!("{}: {:.0}%", tr("options.sensitivity"), ctx.settings.clone().sensitivity_percent()),
&format!("{}: {:.0}%", tr("options.sensitivity"), ctx.settings.sensitivity_percent()),
);
draw_slider(
ui,
self.brightness_rect,
s,
ctx.settings.clone().brightness_fraction(),
&format!("{}: {}%", tr("options.gamma"), ctx.settings.clone().brightness_percent()),
ctx.settings.brightness_fraction(),
&format!("{}: {}%", tr("options.gamma"), ctx.settings.brightness_percent()),
);
if let Some(b) = &self.video_btn {
b.draw(ui, s, ctx.mouse, ctx.mouse_down);
Expand All @@ -210,7 +210,7 @@ impl GuiScreen for GuiOptions {
}
if let Some(gui_scale) = &mut self.gui_scale_btn {
gui_scale.label =
format!("{}: {}", tr("options.guiScale"), ctx.settings.clone().gui_scale_label());
format!("{}: {}", tr("options.guiScale"), ctx.settings.gui_scale_label());
gui_scale.draw(ui, s, ctx.mouse, ctx.mouse_down);
}
if let Some(done) = &self.done {
Expand Down Expand Up @@ -442,7 +442,7 @@ impl GuiScreen for GuiVideoSettings {
ui,
self.render_distance_rect,
s,
ctx.settings.clone().render_distance_fraction(),
ctx.settings.render_distance_fraction(),
&format!(
"{}: {} {}",
tr("options.renderDistance"),
Expand All @@ -454,8 +454,8 @@ impl GuiScreen for GuiVideoSettings {
ui,
self.fps_rect,
s,
ctx.settings.clone().fps_fraction(),
&format!("{}: {}", tr("options.framerateLimit"), ctx.settings.clone().fps_label()),
ctx.settings.fps_fraction(),
&format!("{}: {}", tr("options.framerateLimit"), ctx.settings.fps_label()),
);
if let Some(vsync) = &mut self.vsync {
vsync.label = format!("{}: {}", tr("options.vsync"), on_off(ctx.settings.vsync));
Expand All @@ -475,14 +475,14 @@ impl GuiScreen for GuiVideoSettings {
}
if let Some(mipmaps) = &mut self.mipmaps {
mipmaps.label =
format!("{}: {}", tr("options.mipmapLevels"), ctx.settings.clone().mipmap_label());
format!("{}: {}", tr("options.mipmapLevels"), ctx.settings.mipmap_label());
mipmaps.draw(ui, s, ctx.mouse, ctx.mouse_down);
}
if let Some(resolution) = &mut self.resolution {
resolution.label = format!(
"{}: {}",
tr("fpsmaster.options.resolution"),
ctx.settings.clone().resolution_label()
ctx.settings.resolution_label()
);
resolution.draw(ui, s, ctx.mouse, ctx.mouse_down);
}
Expand Down
6 changes: 3 additions & 3 deletions crates/fpsmaster_app/src/gui/performance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,15 +190,15 @@ impl GuiScreen for GuiPerformance {
ui,
self.render_scale_rect,
s,
st.clone().render_scale_fraction(),
&format!("{}: {}%", tr("fpsmaster.options.renderScale"), st.clone().render_scale_percent()),
st.render_scale_fraction(),
&format!("{}: {}%", tr("fpsmaster.options.renderScale"), st.render_scale_percent()),
);
if cfg!(feature = "dlss") {
draw_slider(
ui,
self.dlss_quality_rect,
s,
st.clone().dlss_quality_fraction(),
st.dlss_quality_fraction(),
&format!("DLSS {}: {}", tr("fpsmaster.perf.rt.quality"), dlss_quality_label(st.dlss_quality)),
);
}
Expand Down
63 changes: 44 additions & 19 deletions crates/fpsmaster_app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -713,7 +713,7 @@ impl ApplicationHandler for WinitApp {
app.game.rotate_view(
delta.0 as f32,
delta.1 as f32,
app.settings.clone().mouse_factor(),
app.settings.mouse_factor(),
);
}
}
Expand Down Expand Up @@ -909,7 +909,7 @@ impl ApplicationHandler for WinitApp {
}

if self.pass_bench.is_none() {
if let Some(cap) = app.settings.clone().fps_limit() {
if let Some(cap) = app.settings.fps_limit() {
let deadline = self.last_frame + Duration::from_secs_f64(1.0 / cap as f64);
let now = Instant::now();
if now < deadline {
Expand All @@ -924,10 +924,13 @@ impl ApplicationHandler for WinitApp {
// Per-frame extension hook (on_frame), e.g. native render geometry.
app.ext.dispatch_frame(&GameViews(&app.game));
apply_ext_commands(app);
// Apply pending extension block-tint overrides (re-meshes the loaded
// world once; future chunks read the renderer's global tint table).
// Apply pending extension block-tint overrides: install the global tint
// table the mesher reads, then re-mesh the loaded world through the
// budgeted background path (a synchronous rebuild froze the frame for
// hundreds of ms to seconds when tint mods reloaded in game).
if app.tints_dirty && app.in_world {
renderer.set_block_tints(app.block_tints.clone(), &app.game.world);
fpsmaster_render::set_block_tints(app.block_tints.clone());
app.game.mark_all_sections_dirty();
app.tints_dirty = false;
}
// Upload native render-hook geometry when a mod changed it.
Expand Down Expand Up @@ -1034,7 +1037,7 @@ impl ApplicationHandler for WinitApp {
}
let now = Instant::now();
if self.adaptive_gpu_ms > 0.0 && (now - self.adaptive_last_adjust).as_secs_f32() >= 1.0 {
let target_fps = app.settings.clone().fps_limit().unwrap_or(60).min(120) as f32;
let target_fps = app.settings.fps_limit().unwrap_or(60).min(120) as f32;
let budget = 1000.0 / target_fps;
let max_scale = app.settings.render_scale;
let new_scale = if self.adaptive_gpu_ms > budget * 0.95 {
Expand Down Expand Up @@ -1576,7 +1579,7 @@ fn apply_ext_commands(app: &mut App) {
fpsmaster_ext::ExtCommand::SetRotation { yaw, pitch, silent } => {
// The silent look is snapped to the player's own mouse-rotation
// quantum (sensitivity factor) so its deltas pass Grim's GCD check.
let step = app.settings.clone().mouse_factor();
let step = app.settings.mouse_factor();
app.game.ext_set_rotation(yaw, pitch, silent, step);
}
fpsmaster_ext::ExtCommand::ClearSilentRotation => {
Expand Down Expand Up @@ -2014,7 +2017,15 @@ fn pump_network(app: &mut App, window: &winit::window::Window, cursor_captured:
disconnect = Some(message);
break;
}
Err(_) => break,
Err(std::sync::mpsc::TryRecvError::Empty) => break,
// Every sender is gone without a Disconnected event: the network
// threads died (a panic). Treat it as a lost connection instead of
// leaving the player frozen in a world that no longer updates.
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
log::error!("network thread exited without a disconnect event");
disconnect = Some(i18n::tr("fpsmaster.network.threadStopped"));
break;
}
}
}

Expand Down Expand Up @@ -2420,6 +2431,11 @@ fn render_frame(
// Single-player world streaming: generate the columns newly in range and
// drop far ones (a no-op for demo/server sessions). New columns are marked
// dirty and meshed through the budget below; dropped ones free their meshes.
// A dimension change discarded the whole world (server switch behind a
// proxy); drop every GPU mesh at once so the old terrain never draws.
if app.game.take_world_reset() {
renderer.clear_chunk_meshes();
}
let stream_removed = app.game.stream_local_world(app.settings.render_distance);
if !stream_removed.is_empty() {
renderer.drop_chunk_sections(&stream_removed);
Expand All @@ -2440,7 +2456,12 @@ fn render_frame(
if !urgent_chunks.is_empty() {
renderer.queue_chunk_meshes(&app.game.world, urgent_chunks.iter().copied());
}
let dirty_budget = MESH_SUBMITS_PER_FRAME.saturating_sub(urgent_chunks.len());
// Backpressure: each queued job pins a column snapshot, so stop pulling
// ordinary dirty sections once the worker backlog is full (they stay in the
// dirty set). Urgent local edits above always go through.
let dirty_budget = MESH_SUBMITS_PER_FRAME
.saturating_sub(urgent_chunks.len())
.min(renderer.mesh_submit_capacity());
let dirty_chunks = app.game.take_dirty_chunks_budget(dirty_budget);
if !dirty_chunks.is_empty() {
renderer.queue_chunk_meshes(&app.game.world, dirty_chunks);
Expand Down Expand Up @@ -2509,22 +2530,26 @@ fn render_frame(
// so the entity model can sample their atlas rows. Skull owners need
// their own pass: the head can name someone who never joined, so the
// tab-list roster alone would leave it on the default skin.
let new_skins: Vec<([u8; 16], String)> = app
// Filter before copying the base64 property, so a steady roster costs
// no per-frame allocation.
let profiles = app
.game
.player_list
.iter()
.filter_map(|(uuid, info)| info.texture_property().map(|t| (*uuid, t.to_owned())))
.filter_map(|(uuid, info)| info.texture_property().map(|t| (uuid, t)))
.chain(
app.game
.skull_skin_profiles()
.map(|(uuid, property)| (*uuid, property.clone())),
)
.filter(|(uuid, _)| !app.skin_manager.is_requested(uuid))
.collect();
for (uuid, property) in new_skins {
app.skin_manager.request(uuid, &property);
}
app.skin_manager.poll(renderer);
.map(|(uuid, property)| (uuid, property.as_str())),
);
for (uuid, property) in profiles {
if !app.skin_manager.is_requested(uuid) {
app.skin_manager.request(*uuid, property);
}
}
let wanted = app.game.skin_owners_in_view(&app.username);
app.skin_manager
.poll(&mut |row, rgba| renderer.upload_player_skin(row, rgba), &wanted);

// Skip the whole entity-model + hand + nametag rebuild and its GPU upload
// when nothing that feeds them changed since last frame — the renderer
Expand Down
Loading
Loading