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:
d3grandoandClaude Opus 5.5 committed 2026-10-30 13:33:33 +07:00
1 parent 68f036059e
commit 9bdb5ec7fd
5 files changed
+529 -61

No files matched your search

+12 -1
View File
@@ -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();
+56 -3
View File
@@ -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(&current, &backup).is_err() {
return false;
}
if fs::rename(&latest, &current).is_err() {
if backup.exists() {
let _ = fs::rename(&backup, &current);
}
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 {
+338 -57
View File
@@ -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));
+122
View File
@@ -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 { .. }));
}