mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
List worlds with an unreadable level.dat and offer recovery
A world whose level.dat could not be read (cut short by a crash during a save, say) was left out of Select World, although level.dat_old would load it, so the player believed the world was gone. list_worlds now follows LevelStorageSource.findLevelCandidates and readLevelSummary: any folder with a level.dat or level.dat_old is a world, and when level.dat does not read it is listed as a CorruptedLevelSummary (the folder name, red "Failed to load world summary", "Attempt to Recover"; Edit and Re-Create off). Opening it, like any world whose level.dat does not read (WorldOpenFlows.openWorldLoadLevelData), shows RecoverWorldDataScreen with the state of level.dat and level.dat_old (collectIssue/buildInfo); Attempt to Restore runs restoreLevelDataFromOld (the broken file kept as level.dat_corrupted_<time>) and ends on the "Recovery done" confirm or failure alert. Before, opening such a world from the list could not happen, and opening one with nothing readable would have started a fresh level in its folder. Part of #3517 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
68f036059e
commit
9bdb5ec7fd
5 files changed
+529
-61
No files matched your search
@@ -549,7 +549,7 @@ impl Game<'_> {
|
||||
selected,
|
||||
} => json!({
|
||||
"mode": "select-world",
|
||||
"worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1 })).collect::<Vec<_>>(),
|
||||
"worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1, "corrupted": w.corrupted })).collect::<Vec<_>>(),
|
||||
"selected": selected,
|
||||
}),
|
||||
Mode::Create {
|
||||
@@ -603,6 +603,17 @@ impl Game<'_> {
|
||||
Mode::BackupBeforeUpgrade { world, .. } => {
|
||||
json!({ "mode": "backup-before-upgrade", "folder": world.folder, "name": world.name })
|
||||
}
|
||||
Mode::Recover { world, issues } => {
|
||||
let issue = |i: &Option<horizon_client::save::DatIssue>| match i {
|
||||
None => json!(null),
|
||||
Some(horizon_client::save::DatIssue::Missing) => json!("missing file"),
|
||||
Some(horizon_client::save::DatIssue::Unreadable(why)) => json!(why),
|
||||
};
|
||||
json!({ "mode": "recover-world", "folder": world.folder, "level_dat": issue(&issues[0]), "level_dat_old": issue(&issues[1]) })
|
||||
}
|
||||
Mode::Recovered { world, success } => {
|
||||
json!({ "mode": "recovery-done", "folder": world.folder, "success": success })
|
||||
}
|
||||
Mode::Error { message, .. } => {
|
||||
json!({ "mode": "world-operation-error", "message": message })
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ impl Game<'_> {
|
||||
"last_played": w.modified.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64),
|
||||
"commands": w.commands,
|
||||
"version": w.version,
|
||||
"corrupted": w.corrupted,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -2411,12 +2411,58 @@ impl Drop for SaveDir {
|
||||
}
|
||||
|
||||
pub fn read_dat(path: &Path) -> Option<Tag> {
|
||||
let bytes = fs::read(path).ok()?;
|
||||
try_read_dat(path).ok()
|
||||
}
|
||||
/// `read_dat`, with why the file did not read.
|
||||
fn try_read_dat(path: &Path) -> std::result::Result<Tag, DatIssue> {
|
||||
let bytes = fs::read(path).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => DatIssue::Missing,
|
||||
_ => DatIssue::Unreadable(e.to_string()),
|
||||
})?;
|
||||
let raw = match Compression::Gzip.decode(&bytes, Limits::saved().max_bytes) {
|
||||
Ok(raw) => raw,
|
||||
Err(_) => bytes,
|
||||
};
|
||||
Some(nbt::read_named(&raw, Limits::saved()).ok()?.0.tag)
|
||||
nbt::read_named(&raw, Limits::saved())
|
||||
.map(|(named, _)| named.tag)
|
||||
.map_err(|e| DatIssue::Unreadable(e.to_string()))
|
||||
}
|
||||
/// What `LevelStorageAccess.collectIssues` finds wrong with a level.dat.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum DatIssue {
|
||||
/// No such file (`FileNotFoundException`).
|
||||
Missing,
|
||||
/// It exists but does not read as a level: why.
|
||||
Unreadable(String),
|
||||
}
|
||||
/// `LevelStorageAccess.collectIssues`: why level.dat (level.dat_old with
|
||||
/// `old`) can't be read as the world's level data, `None` when it can.
|
||||
pub fn level_dat_issue(dir: &Path, old: bool) -> Option<DatIssue> {
|
||||
let file = if old { "level.dat_old" } else { "level.dat" };
|
||||
match try_read_dat(&dir.join(file)) {
|
||||
Ok(root) if matches!(root.get("Data"), Some(Tag::Compound(_))) => None,
|
||||
Ok(_) => Some(DatIssue::Unreadable("Invalid root tag".into())),
|
||||
Err(issue) => Some(issue),
|
||||
}
|
||||
}
|
||||
/// `LevelStorageAccess.restoreLevelDataFromOld` (`Util.safeReplaceOrMoveFile`):
|
||||
/// level.dat moves aside to `level.dat_corrupted_<time>` and level.dat_old
|
||||
/// takes its place. Whether it worked. Vanilla passes `noRollback` and reports
|
||||
/// success even when level.dat_old could not be moved in; here the broken
|
||||
/// level.dat goes back and the failure is reported.
|
||||
pub fn restore_level_data_from_old(dir: &Path) -> bool {
|
||||
let (current, latest) = (dir.join("level.dat"), dir.join("level.dat_old"));
|
||||
let backup = dir.join(format!("level.dat_corrupted_{}", backup_stamp()));
|
||||
if current.exists() && fs::rename(¤t, &backup).is_err() {
|
||||
return false;
|
||||
}
|
||||
if fs::rename(&latest, ¤t).is_err() {
|
||||
if backup.exists() {
|
||||
let _ = fs::rename(&backup, ¤t);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `FileNameDateFormatter`: the time in the names of backed-up unreadable files.
|
||||
fn backup_stamp() -> String {
|
||||
@@ -3020,7 +3066,14 @@ impl Level {
|
||||
}
|
||||
}
|
||||
pub fn read(dir: &Path) -> Option<Self> {
|
||||
let root = read_level_dat(dir)?;
|
||||
Self::read_root(dir, read_level_dat(dir)?)
|
||||
}
|
||||
/// `read` from level.dat alone, without the level.dat_old fallback: what
|
||||
/// `LevelStorageSource.readLevelSummary` reads for the world list.
|
||||
pub fn read_summary(dir: &Path) -> Option<Self> {
|
||||
Self::read_root(dir, read_dat(&dir.join("level.dat"))?)
|
||||
}
|
||||
fn read_root(dir: &Path, root: Tag) -> Option<Self> {
|
||||
let data = root.get("Data")?;
|
||||
let n = |t: Option<&Tag>| number(t).unwrap_or(0);
|
||||
let mut level = Self {
|
||||
|
||||
@@ -34,6 +34,54 @@ fn edit_top(h: f32) -> f32 {
|
||||
pub(crate) fn confirm_top(h: f32, lines: usize) -> f32 {
|
||||
((h.ceil() - 61.0 - lines as f32 * 9.0) / 2.0).floor()
|
||||
}
|
||||
/// RecoverWorldDataScreen's column (title, message, issues, the restore row
|
||||
/// and Back, 8 apart, centred on the screen) with `lines` lines of message
|
||||
/// and issues: where the restore row starts.
|
||||
fn recover_buttons_top(h: f32, lines: usize) -> f32 {
|
||||
let text = lines as f32 * 9.0;
|
||||
let top = ((h.ceil() - (9.0 + 8.0 + text + 8.0 + 8.0 + 20.0 + 8.0 + 20.0)) / 2.0).trunc();
|
||||
top + 9.0 + 8.0 + text + 8.0 + 8.0
|
||||
}
|
||||
/// `RecoverWorldDataScreen.buildInfo`: "State from <date>: " and the file's
|
||||
/// issue; nothing for a level.dat_old that is just missing.
|
||||
fn recover_info(dir: &Path, old: bool, issue: Option<&crate::save::DatIssue>) -> Option<Text> {
|
||||
use crate::save::DatIssue;
|
||||
if old && issue == Some(&DatIssue::Missing) {
|
||||
return None;
|
||||
}
|
||||
let file = dir.join(if old { "level.dat_old" } else { "level.dat" });
|
||||
let date = match std::fs::metadata(file).and_then(|m| m.modified()) {
|
||||
Ok(time) => {
|
||||
let date: chrono::DateTime<chrono::Local> = time.into();
|
||||
date.format("%d/%m/%Y, %H:%M").to_string()
|
||||
}
|
||||
Err(_) => lang::get_or("recover_world.state_entry.unknown", "unknown"),
|
||||
};
|
||||
let state = format_translation(
|
||||
&lang::get_or("recover_world.state_entry", "State from %s: "),
|
||||
&[Text::colored(date, GRAY)],
|
||||
);
|
||||
let issue = match issue {
|
||||
None => Text::colored(lang::get_or("recover_world.issue.none", "No issues"), GREEN),
|
||||
Some(DatIssue::Missing) => Text::colored(
|
||||
lang::get_or("recover_world.issue.missing_file", "Missing file"),
|
||||
RED,
|
||||
),
|
||||
Some(DatIssue::Unreadable(why)) => Text::colored(why.clone(), RED),
|
||||
};
|
||||
Some(Text::empty().append(state).append(issue))
|
||||
}
|
||||
/// ChatFormatting GRAY, GREEN and RED.
|
||||
const GRAY: u32 = 0xAAAAAA;
|
||||
const GREEN: u32 = 0x55FF55;
|
||||
const RED: u32 = 0xFF5555;
|
||||
/// `RecoverWorldDataScreen.collectIssue` for level.dat and level.dat_old.
|
||||
fn recover_issues(dir: &Path) -> [Option<crate::save::DatIssue>; 2] {
|
||||
[
|
||||
crate::save::level_dat_issue(dir, false),
|
||||
crate::save::level_dat_issue(dir, true),
|
||||
]
|
||||
}
|
||||
/// The SafetyScreen's column (WarningScreen: title, 12 px around the message
|
||||
/// box of its lines and 4 px padding, the checkbox, then the buttons, 8 px
|
||||
/// apart): the title's top.
|
||||
@@ -91,6 +139,24 @@ pub struct WorldEntry {
|
||||
pub modified: std::time::SystemTime,
|
||||
pub commands: bool,
|
||||
pub version: String,
|
||||
/// `LevelSummary.CorruptedLevelSummary`: level.dat did not read, so the row
|
||||
/// only knows its folder and offers recovery instead of play.
|
||||
pub corrupted: bool,
|
||||
}
|
||||
impl WorldEntry {
|
||||
/// `CorruptedLevelSummary`: named after its folder, nothing else known.
|
||||
fn corrupted(folder: String, modified: std::time::SystemTime) -> Self {
|
||||
Self {
|
||||
name: folder.clone(),
|
||||
folder,
|
||||
game_mode: 0,
|
||||
hardcore: false,
|
||||
modified,
|
||||
commands: false,
|
||||
version: String::new(),
|
||||
corrupted: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Mode {
|
||||
@@ -123,6 +189,20 @@ pub enum Mode {
|
||||
Delete {
|
||||
world: WorldEntry,
|
||||
},
|
||||
/// RecoverWorldDataScreen: opening a world whose level.dat does not read
|
||||
/// (WorldOpenFlows.openWorldLoadLevelData) lists what is wrong with
|
||||
/// level.dat and level.dat_old and offers to go back to the old one.
|
||||
Recover {
|
||||
world: WorldEntry,
|
||||
/// `collectIssue` for level.dat, then level.dat_old.
|
||||
issues: [Option<crate::save::DatIssue>; 2],
|
||||
},
|
||||
/// Where `attemptRestore` ends: the "Recovery done" ConfirmScreen
|
||||
/// (Continue opens the world), or its AlertScreen when it failed.
|
||||
Recovered {
|
||||
world: WorldEntry,
|
||||
success: bool,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
return_to: Box<Mode>,
|
||||
@@ -235,18 +315,26 @@ pub fn list_worlds(saves: &Path) -> Vec<WorldEntry> {
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let path = e.path();
|
||||
let level = Level::read(&path)?;
|
||||
let modified = std::fs::metadata(path.join("level.dat"))
|
||||
.and_then(|m| m.modified())
|
||||
.ok()?;
|
||||
let data = crate::save::read_dat(&path.join("level.dat"))?;
|
||||
let data = data.get("Data")?;
|
||||
// findLevelCandidates: a folder with a level.dat or a level.dat_old.
|
||||
let file = |name: &str| std::fs::metadata(path.join(name)).ok().filter(|m| m.is_file());
|
||||
// getFileModificationTime: level.dat's, else level.dat_old's.
|
||||
let file = file("level.dat").or_else(|| file("level.dat_old"))?;
|
||||
let modified = file.modified().unwrap_or(std::time::UNIX_EPOCH);
|
||||
let folder = e.file_name().to_string_lossy().into_owned();
|
||||
// readLevelSummary reads level.dat alone; when it fails the world is
|
||||
// still listed, as a CorruptedLevelSummary named after its folder.
|
||||
let level = Level::read_summary(&path);
|
||||
let root = crate::save::read_dat(&path.join("level.dat"));
|
||||
let (Some(level), Some(root)) = (level, root) else {
|
||||
return Some(WorldEntry::corrupted(folder, modified));
|
||||
};
|
||||
let data = root.get("Data")?;
|
||||
let modified = match data.get("LastPlayed") {
|
||||
Some(horizon_world::nbt::Tag::Long(millis)) if *millis >= 0 => std::time::UNIX_EPOCH + std::time::Duration::from_millis(*millis as u64),
|
||||
_ => modified,
|
||||
};
|
||||
Some(WorldEntry {
|
||||
folder: e.file_name().to_string_lossy().into_owned(),
|
||||
folder,
|
||||
name: level.name,
|
||||
game_mode: level.game_mode,
|
||||
hardcore: level.hardcore,
|
||||
@@ -255,6 +343,7 @@ pub fn list_worlds(saves: &Path) -> Vec<WorldEntry> {
|
||||
version: data.get("Version").and_then(|v| v.get("Name"))
|
||||
.and_then(|v| match v { horizon_world::nbt::Tag::String(s) => Some(s.to_string_lossy()), _ => None })
|
||||
.unwrap_or_else(|| lang::get_or("selectWorld.versionUnknown", "Unknown")),
|
||||
corrupted: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -264,6 +353,13 @@ pub fn list_worlds(saves: &Path) -> Vec<WorldEntry> {
|
||||
/// LevelSummary.createInfo: a hardcore world's mode in red (CommonColors.RED),
|
||||
/// else `gameMode.` + its GameType name, then commands and the version.
|
||||
fn world_info(world: &WorldEntry) -> crate::text::Text {
|
||||
// CorruptedLevelSummary.INFO: recover_world.warning in red (-65536).
|
||||
if world.corrupted {
|
||||
return crate::text::Text::colored(
|
||||
lang::get_or("recover_world.warning", "Failed to load world summary"),
|
||||
0xFF0000,
|
||||
);
|
||||
}
|
||||
let mode = if world.hardcore {
|
||||
crate::text::Text::colored(lang::get_or("gameMode.hardcore", "Hardcore Mode"), 0xFF0000)
|
||||
} else {
|
||||
@@ -658,6 +754,13 @@ impl Title {
|
||||
));
|
||||
return Action::None;
|
||||
}
|
||||
// openWorldLoadLevelData: a level.dat that does not read opens
|
||||
// RecoverWorldDataScreen instead of the world.
|
||||
let issues = recover_issues(&self.saves.join(&world.folder));
|
||||
if issues[0].is_some() {
|
||||
self.mode = Mode::Recover { world, issues };
|
||||
return Action::None;
|
||||
}
|
||||
// openWorldCheckVersionCompatibility: a world from another series is an AlertScreen.
|
||||
if let Some(version) = crate::save::incompatible_version(&self.saves.join(&world.folder)) {
|
||||
let version = if version.is_empty() {
|
||||
@@ -700,6 +803,25 @@ impl Title {
|
||||
folder: world.folder,
|
||||
}
|
||||
}
|
||||
/// `RecoverWorldDataScreen.attemptRestore`: with level.dat still broken and
|
||||
/// level.dat_old readable, level.dat_old takes level.dat's place
|
||||
/// (`restoreLevelDataFromOld`); anything else is a failure.
|
||||
fn attempt_restore(&mut self, world: WorldEntry) {
|
||||
let dir = self.saves.join(&world.folder);
|
||||
let issues = recover_issues(&dir);
|
||||
let success = issues[0].is_some()
|
||||
&& issues[1].is_none()
|
||||
&& crate::save::restore_level_data_from_old(&dir);
|
||||
if !success {
|
||||
log::error!(
|
||||
"Failed to recover world, files not as expected. level.dat: {:?}, level.dat_old: {:?}",
|
||||
issues[0],
|
||||
issues[1]
|
||||
);
|
||||
}
|
||||
*self.recent.borrow_mut() = None;
|
||||
self.mode = Mode::Recovered { world, success };
|
||||
}
|
||||
fn start_backup(&mut self, world: WorldEntry) {
|
||||
let (send, receive) = mpsc::channel();
|
||||
let saves = self.saves.clone();
|
||||
@@ -910,56 +1032,67 @@ impl Title {
|
||||
}
|
||||
buttons
|
||||
}
|
||||
Mode::Select { selected, .. } => vec![
|
||||
b(
|
||||
(w / 2.0).floor() - 154.0,
|
||||
h - 52.0,
|
||||
150.0,
|
||||
&lang::get_or("selectWorld.select", "Play Selected World"),
|
||||
selected.is_some(),
|
||||
10,
|
||||
),
|
||||
b(
|
||||
(w / 2.0).floor() + 4.0,
|
||||
h - 52.0,
|
||||
150.0,
|
||||
&lang::get_or("selectWorld.create", "Create New World"),
|
||||
true,
|
||||
11,
|
||||
),
|
||||
b(
|
||||
(w / 2.0).floor() - 154.0,
|
||||
h - 28.0,
|
||||
71.0,
|
||||
&lang::get_or("selectWorld.edit", "Edit"),
|
||||
selected.is_some(),
|
||||
13,
|
||||
),
|
||||
b(
|
||||
(w / 2.0).floor() - 75.0,
|
||||
h - 28.0,
|
||||
71.0,
|
||||
&lang::get_or("selectWorld.delete", "Delete"),
|
||||
selected.is_some(),
|
||||
14,
|
||||
),
|
||||
b(
|
||||
(w / 2.0).floor() + 4.0,
|
||||
h - 28.0,
|
||||
71.0,
|
||||
&lang::get_or("selectWorld.recreate", "Re-Create"),
|
||||
selected.is_some(),
|
||||
15,
|
||||
),
|
||||
b(
|
||||
(w / 2.0).floor() + 83.0,
|
||||
h - 28.0,
|
||||
71.0,
|
||||
&lang::get_or("gui.back", "Back"),
|
||||
true,
|
||||
12,
|
||||
),
|
||||
],
|
||||
Mode::Select { worlds, selected } => {
|
||||
let world = selected.and_then(|i| worlds.get(i));
|
||||
// SelectWorldScreen.updateButtonStatus: primaryActionMessage, canEdit, canRecreate.
|
||||
let corrupted = world.is_some_and(|w| w.corrupted);
|
||||
let editable = world.is_some() && !corrupted;
|
||||
let play = if corrupted {
|
||||
lang::get_or("recover_world.button", "Attempt to Recover")
|
||||
} else {
|
||||
lang::get_or("selectWorld.select", "Play Selected World")
|
||||
};
|
||||
vec![
|
||||
b(
|
||||
(w / 2.0).floor() - 154.0,
|
||||
h - 52.0,
|
||||
150.0,
|
||||
&play,
|
||||
selected.is_some(),
|
||||
10,
|
||||
),
|
||||
b(
|
||||
(w / 2.0).floor() + 4.0,
|
||||
h - 52.0,
|
||||
150.0,
|
||||
&lang::get_or("selectWorld.create", "Create New World"),
|
||||
true,
|
||||
11,
|
||||
),
|
||||
b(
|
||||
(w / 2.0).floor() - 154.0,
|
||||
h - 28.0,
|
||||
71.0,
|
||||
&lang::get_or("selectWorld.edit", "Edit"),
|
||||
editable,
|
||||
13,
|
||||
),
|
||||
b(
|
||||
(w / 2.0).floor() - 75.0,
|
||||
h - 28.0,
|
||||
71.0,
|
||||
&lang::get_or("selectWorld.delete", "Delete"),
|
||||
selected.is_some(),
|
||||
14,
|
||||
),
|
||||
b(
|
||||
(w / 2.0).floor() + 4.0,
|
||||
h - 28.0,
|
||||
71.0,
|
||||
&lang::get_or("selectWorld.recreate", "Re-Create"),
|
||||
editable,
|
||||
15,
|
||||
),
|
||||
b(
|
||||
(w / 2.0).floor() + 83.0,
|
||||
h - 28.0,
|
||||
71.0,
|
||||
&lang::get_or("gui.back", "Back"),
|
||||
true,
|
||||
12,
|
||||
),
|
||||
]
|
||||
}
|
||||
Mode::Create {
|
||||
creative, options, ..
|
||||
} => create_world::buttons(w, h, *creative, options),
|
||||
@@ -1049,6 +1182,66 @@ impl Title {
|
||||
),
|
||||
]
|
||||
}
|
||||
Mode::Recover { issues, .. } => {
|
||||
let y = recover_buttons_top(h, self.message_lines.get());
|
||||
// The restore button is active only when level.dat is the broken one
|
||||
// and level.dat_old reads.
|
||||
vec![
|
||||
b(
|
||||
(w / 2.0).floor() - 60.0,
|
||||
y,
|
||||
120.0,
|
||||
&lang::get_or("recover_world.restore", "Attempt to Restore"),
|
||||
issues[0].is_some() && issues[1].is_none(),
|
||||
90,
|
||||
),
|
||||
b(
|
||||
(w / 2.0).floor() - 60.0,
|
||||
y + 28.0,
|
||||
120.0,
|
||||
&lang::get_or("gui.back", "Back"),
|
||||
true,
|
||||
91,
|
||||
),
|
||||
]
|
||||
}
|
||||
Mode::Recovered { success: true, .. } => {
|
||||
let y = confirm_top(h, self.message_lines.get())
|
||||
+ 41.0
|
||||
+ self.message_lines.get() as f32 * 9.0;
|
||||
vec![
|
||||
b(
|
||||
(w / 2.0).floor() - 152.0,
|
||||
y,
|
||||
150.0,
|
||||
&lang::get_or("gui.continue", "Continue"),
|
||||
true,
|
||||
92,
|
||||
),
|
||||
b(
|
||||
(w / 2.0).floor() + 2.0,
|
||||
y,
|
||||
150.0,
|
||||
&lang::get_or("gui.back", "Back"),
|
||||
true,
|
||||
93,
|
||||
),
|
||||
]
|
||||
}
|
||||
Mode::Recovered { success: false, .. } => {
|
||||
// AlertScreen.init: the button follows the text, kept inside the screen.
|
||||
let y = (90.0 + self.message_lines.get() as f32 * 9.0 + 12.0)
|
||||
.max(h / 6.0 + 96.0)
|
||||
.min(h - 24.0);
|
||||
vec![b(
|
||||
(w / 2.0).floor() - 75.0,
|
||||
y,
|
||||
150.0,
|
||||
&lang::get_or("gui.back", "Back"),
|
||||
true,
|
||||
93,
|
||||
)]
|
||||
}
|
||||
Mode::Error { incompatible, .. } => {
|
||||
let lines = self.message_lines.get();
|
||||
let y = if *incompatible {
|
||||
@@ -1393,6 +1586,79 @@ impl Title {
|
||||
centered(atlas, batch, line, top + 17.0 + i as f32 * 9.0, u32::MAX);
|
||||
}
|
||||
}
|
||||
Mode::Recover { world, issues } => {
|
||||
crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
|
||||
let dir = self.saves.join(&world.folder);
|
||||
let title =
|
||||
Text::literal(lang::get_or("recover_world.title", "Failed to load world"))
|
||||
.bold();
|
||||
let message = format_translation(
|
||||
&lang::get_or(
|
||||
"recover_world.message",
|
||||
"The following issues occurred while trying to read world folder \"%s\".\nIt might be possible to restore the world from an older state or you can report this issue on the bug tracker.",
|
||||
),
|
||||
&[Text::colored(world.folder.clone(), GRAY)],
|
||||
);
|
||||
let message: Vec<Text> = atlas
|
||||
.font
|
||||
.split_lines(&message, w - 50.0)
|
||||
.into_iter()
|
||||
.map(|(line, _)| line)
|
||||
.collect();
|
||||
let issues: Vec<Text> = [false, true]
|
||||
.into_iter()
|
||||
.zip(issues)
|
||||
.filter_map(|(old, issue)| recover_info(&dir, old, issue.as_ref()))
|
||||
.flat_map(|info| atlas.font.split_lines(&info, w - 50.0))
|
||||
.map(|(line, _)| line)
|
||||
.collect();
|
||||
self.message_lines.set(message.len() + issues.len());
|
||||
let row = recover_buttons_top(h, message.len() + issues.len());
|
||||
let top = row - 8.0 - 8.0 - (message.len() + issues.len()) as f32 * 9.0 - 8.0 - 9.0;
|
||||
let center =
|
||||
|text: &Text| (w / 2.0).floor() - (atlas.font.width_text(text) / 2.0).floor();
|
||||
batch.text_styled(atlas, &title, center(&title), top, u32::MAX, true);
|
||||
let mut y = top + 17.0;
|
||||
for line in &message {
|
||||
batch.text_styled(atlas, line, center(line), y, u32::MAX, true);
|
||||
y += 9.0;
|
||||
}
|
||||
// The issues widget is left aligned, the widget centred.
|
||||
let width = issues
|
||||
.iter()
|
||||
.map(|l| atlas.font.width_text(l))
|
||||
.fold(0.0, f32::max);
|
||||
let x = (w / 2.0).floor() - (width / 2.0).floor();
|
||||
for line in &issues {
|
||||
batch.text_styled(atlas, line, x, y + 8.0, u32::MAX, true);
|
||||
y += 9.0;
|
||||
}
|
||||
}
|
||||
Mode::Recovered { success, .. } => {
|
||||
crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
|
||||
let title = lang::get_or("recover_world.done.title", "Recovery done");
|
||||
let message = if *success {
|
||||
lang::get_or("recover_world.done.success", "Recovery was successful!")
|
||||
} else {
|
||||
lang::get_or(
|
||||
"recover_world.done.failed",
|
||||
"Failed to recover from previous state.",
|
||||
)
|
||||
};
|
||||
let lines = wrapped(atlas, &message, w - 50.0);
|
||||
self.message_lines.set(lines.len());
|
||||
// ConfirmScreen centres its column; AlertScreen puts the title at 70, the text at 90.
|
||||
let (top, text_top) = if *success {
|
||||
let top = confirm_top(h, lines.len());
|
||||
(top, top + 17.0)
|
||||
} else {
|
||||
(70.0, 90.0)
|
||||
};
|
||||
centered(atlas, batch, &title, top, u32::MAX);
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
centered(atlas, batch, line, text_top + i as f32 * 9.0, u32::MAX);
|
||||
}
|
||||
}
|
||||
Mode::BackupBeforeUpgrade { downgrade, .. } => {
|
||||
crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
|
||||
// BackupStatus.DOWNGRADE is severe: its question is red (-2142128).
|
||||
@@ -1870,6 +2136,19 @@ impl Title {
|
||||
self.escape();
|
||||
Action::None
|
||||
}
|
||||
(Mode::Recover { world, .. }, Some(90)) => {
|
||||
let world = world.clone();
|
||||
self.attempt_restore(world);
|
||||
Action::None
|
||||
}
|
||||
(Mode::Recovered { world, .. }, Some(92)) => {
|
||||
let world = world.clone();
|
||||
self.play(world)
|
||||
}
|
||||
(Mode::Recover { .. } | Mode::Recovered { .. }, Some(91 | 93)) => {
|
||||
self.escape();
|
||||
Action::None
|
||||
}
|
||||
(Mode::Error { return_to, .. }, Some(39)) => {
|
||||
self.mode = *return_to.clone();
|
||||
Action::None
|
||||
@@ -2204,6 +2483,8 @@ impl Title {
|
||||
Mode::Backup { .. } => {}
|
||||
Mode::Edit { world, .. }
|
||||
| Mode::Delete { world }
|
||||
| Mode::Recover { world, .. }
|
||||
| Mode::Recovered { world, .. }
|
||||
| Mode::BackupBeforeUpgrade { world, .. } => {
|
||||
let folder = world.folder.clone();
|
||||
self.return_to_list(Some(&folder));
|
||||
|
||||
@@ -1129,3 +1129,125 @@ fn world_list_names_every_game_mode_and_hardcore_in_red() {
|
||||
assert_eq!(creative.plain(), "Creative Mode, Commands, Version: 26.3");
|
||||
assert!(creative.spans.iter().all(|s| s.style.color.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_with_unreadable_level_dat_stays_in_the_world_list() {
|
||||
let f = Fixture::new();
|
||||
f.world("Broken", "My World");
|
||||
f.world("Broken", "My World");
|
||||
fs::write(f.title.saves.join("Broken/level.dat"), b"truncated").unwrap();
|
||||
// A folder with only level.dat_old is a world too (findLevelCandidates).
|
||||
f.world("Old only", "Old only");
|
||||
f.world("Old only", "Old only");
|
||||
fs::remove_file(f.title.saves.join("Old only/level.dat")).unwrap();
|
||||
let mut listed: Vec<String> = list_worlds(&f.title.saves)
|
||||
.into_iter()
|
||||
.map(|w| w.folder)
|
||||
.collect();
|
||||
listed.sort();
|
||||
assert_eq!(listed, ["Broken", "Old only"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_with_unreadable_level_dat_is_listed_and_recovers_from_level_dat_old() {
|
||||
let mut f = Fixture::new();
|
||||
// A second save keeps the first level.dat as level.dat_old; then level.dat
|
||||
// is cut short, as a crash in the middle of a save leaves it.
|
||||
f.world("Broken", "My World");
|
||||
f.world("Broken", "My World");
|
||||
let dir = f.title.saves.join("Broken");
|
||||
fs::write(dir.join("level.dat"), b"truncated").unwrap();
|
||||
|
||||
// LevelStorageSource.readLevelSummary: a CorruptedLevelSummary, still listed.
|
||||
let worlds = list_worlds(&f.title.saves);
|
||||
let broken = worlds
|
||||
.iter()
|
||||
.find(|w| w.folder == "Broken")
|
||||
.expect("a world whose level.dat does not read stays in the list");
|
||||
assert!(broken.corrupted);
|
||||
assert_eq!(broken.name, "Broken");
|
||||
let info = world_info(broken);
|
||||
assert_eq!(info.plain(), "Failed to load world summary");
|
||||
assert_eq!(info.spans[0].style.color, Some(0xFF0000));
|
||||
|
||||
f.select();
|
||||
let buttons = f.title.buttons(480.0, 262.0);
|
||||
let enabled = |label: &str| buttons.iter().find(|b| b.label == label).unwrap().enabled;
|
||||
assert!(enabled("Attempt to Recover"));
|
||||
assert!(!enabled("Edit") && !enabled("Re-Create") && enabled("Delete"));
|
||||
|
||||
// Its primary action opens RecoverWorldDataScreen, not the world.
|
||||
assert_eq!(f.click("Attempt to Recover"), Action::None);
|
||||
let Mode::Recover { issues, .. } = &f.title.mode else {
|
||||
panic!("expected the recovery screen, got {:?}", f.title.mode);
|
||||
};
|
||||
assert!(matches!(
|
||||
issues[0],
|
||||
Some(crate::save::DatIssue::Unreadable(_))
|
||||
));
|
||||
assert_eq!(issues[1], None);
|
||||
assert_eq!(fs::read(dir.join("level.dat")).unwrap(), b"truncated");
|
||||
|
||||
// restoreLevelDataFromOld: level.dat_old back in place, the broken file kept aside.
|
||||
f.click("Attempt to Restore");
|
||||
assert!(matches!(
|
||||
f.title.mode,
|
||||
Mode::Recovered { success: true, .. }
|
||||
));
|
||||
assert_eq!(Level::read_summary(&dir).unwrap().name, "My World");
|
||||
assert!(!dir.join("level.dat_old").exists());
|
||||
let kept: Vec<_> = fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with("level.dat_corrupted_")
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(fs::read(kept[0].path()).unwrap(), b"truncated");
|
||||
assert_eq!(
|
||||
f.click("Continue"),
|
||||
Action::Play {
|
||||
folder: "Broken".into()
|
||||
}
|
||||
);
|
||||
assert!(!list_worlds(&f.title.saves)[0].corrupted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_with_nothing_to_recover_from_is_listed_but_never_opened() {
|
||||
let mut f = Fixture::new();
|
||||
let dir = f.title.saves.join("Lost");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("level.dat"), b"garbage").unwrap();
|
||||
// A folder without level.dat or level.dat_old is no world (findLevelCandidates).
|
||||
fs::create_dir_all(f.title.saves.join("Not a world")).unwrap();
|
||||
let worlds = list_worlds(&f.title.saves);
|
||||
assert_eq!(worlds.len(), 1);
|
||||
assert!(worlds[0].corrupted);
|
||||
|
||||
f.select();
|
||||
f.click("Attempt to Recover");
|
||||
let Mode::Recover { issues, .. } = &f.title.mode else {
|
||||
panic!("expected the recovery screen, got {:?}", f.title.mode);
|
||||
};
|
||||
assert_eq!(issues[1], Some(crate::save::DatIssue::Missing));
|
||||
let restore = f
|
||||
.title
|
||||
.buttons(480.0, 262.0)
|
||||
.into_iter()
|
||||
.find(|b| b.label == "Attempt to Restore")
|
||||
.unwrap();
|
||||
assert!(!restore.enabled);
|
||||
// Pressed anyway, attemptRestore fails without touching the files.
|
||||
f.title.attempt_restore(worlds[0].clone());
|
||||
assert!(matches!(
|
||||
f.title.mode,
|
||||
Mode::Recovered { success: false, .. }
|
||||
));
|
||||
assert_eq!(fs::read(dir.join("level.dat")).unwrap(), b"garbage");
|
||||
f.click("Back");
|
||||
assert!(matches!(f.title.mode, Mode::Select { .. }));
|
||||
}
|
||||
Reference in new issue
Block a user