From d52be472f6960c158b4989e4b71c14038c8d6529 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:39:42 +0000 Subject: [PATCH 1/5] feat: locate owned animals in unloaded chunks for /animals On startup, read the saved entity chunks for every owned animal that is not loaded, fill in or correct its last-seen coordinates, and flag any animal that is not in any saved chunk as Missing in the roster. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../java/net/tfminecraft/cooking/Cooking.java | 2 + .../husbandry/HusbandryAnimalsCommand.java | 3 +- .../husbandry/HusbandryEntityScan.java | 239 ++++++++++++++++++ .../husbandry/HusbandryLifecycleListener.java | 1 + .../cooking/husbandry/HusbandryLocator.java | 128 ++++++++++ .../husbandry/HusbandryRepository.java | 10 + .../cooking/husbandry/HusbandryRoster.java | 28 +- .../husbandry/HusbandryEntityScanTest.java | 210 +++++++++++++++ .../husbandry/HusbandryRepositoryTest.java | 8 + .../husbandry/HusbandryRosterTest.java | 21 ++ 10 files changed, 646 insertions(+), 4 deletions(-) create mode 100644 src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java create mode 100644 src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java create mode 100644 src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java diff --git a/src/main/java/net/tfminecraft/cooking/Cooking.java b/src/main/java/net/tfminecraft/cooking/Cooking.java index e8ab6cc..bd4e3cf 100644 --- a/src/main/java/net/tfminecraft/cooking/Cooking.java +++ b/src/main/java/net/tfminecraft/cooking/Cooking.java @@ -36,6 +36,7 @@ import net.tfminecraft.cooking.crops.CropGrowthListener; import net.tfminecraft.cooking.crops.CropsLoader; import net.tfminecraft.cooking.husbandry.HusbandryLoader; +import net.tfminecraft.cooking.husbandry.HusbandryLocator; import net.tfminecraft.cooking.husbandry.HusbandryRepository; import net.tfminecraft.cooking.husbandry.HusbandryTickTask; import net.tfminecraft.cooking.loader.PermissionEffectsLoader; @@ -151,6 +152,7 @@ public void onEnable() { NutritionDrainTask.start(); HusbandryLifecycleListener.resumeLoadedWorlds(); HusbandryTickTask.start(); + HusbandryLocator.scanUnloaded(); }); getCommand("cooking").setExecutor(commands); diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimalsCommand.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimalsCommand.java index 1a663d3..b906690 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimalsCommand.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimalsCommand.java @@ -64,7 +64,8 @@ public boolean onCommand(CommandSender sender, Command command, String label, St rows.add(new HusbandryOwned(animal, owned.role())); } for (Component line : HusbandryRoster.render( - ownerName, self, rows, HusbandryConfig.maxAnimals(), System.currentTimeMillis())) { + ownerName, self, rows, HusbandryConfig.maxAnimals(), System.currentTimeMillis(), + HusbandryLocator::isMissing)) { sender.sendMessage(line); } return true; diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java new file mode 100644 index 0000000..a9de28b --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java @@ -0,0 +1,239 @@ +package net.tfminecraft.cooking.husbandry; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.GZIPInputStream; +import java.util.zip.InflaterInputStream; + +/** + * Reads saved entity chunks ({@code entities/r.X.Z.mca}) to find where unloaded animals are. + * Runs off the main thread; it only reads files. + */ +final class HusbandryEntityScan { + + record WorldDir(String world, File entities) {} + + record Found(String world, int x, int y, int z) {} + + /** {@code complete} is false when some chunk could not be read, so a missing animal may still exist. */ + record Result(Map found, boolean complete) {} + + private static final Pattern REGION = Pattern.compile("r\\.(-?\\d+)\\.(-?\\d+)\\.mca"); + private static final int SECTOR = 4096; + + private HusbandryEntityScan() {} + + static Result scan(List worlds, Set targets) { + Map found = new HashMap<>(); + boolean complete = true; + if (targets.isEmpty()) { + return new Result(found, true); + } + for (WorldDir world : worlds) { + File[] files = world.entities().listFiles(); + if (files == null) { + continue; + } + for (File file : files) { + Matcher name = REGION.matcher(file.getName()); + if (!name.matches()) { + continue; + } + int regionX = Integer.parseInt(name.group(1)); + int regionZ = Integer.parseInt(name.group(2)); + complete &= scanRegion(world, file, regionX, regionZ, targets, found); + } + } + return new Result(found, complete); + } + + private static boolean scanRegion( + WorldDir world, File file, int regionX, int regionZ, Set targets, Map found) { + byte[] data; + try { + data = Files.readAllBytes(file.toPath()); + } catch (IOException ex) { + return false; + } + if (data.length < SECTOR * 2) { + return true; + } + boolean complete = true; + for (int index = 0; index < 1024; index++) { + int entry = readInt(data, index * 4); + int offset = (entry >>> 8) * SECTOR; + if (offset == 0) { + continue; + } + try { + byte[] chunk = chunkBytes(world, data, offset, regionX * 32 + index % 32, regionZ * 32 + index / 32); + if (chunk == null) { + complete = false; + continue; + } + readEntityChunk(new DataInputStream(new ByteArrayInputStream(chunk)), world.world(), targets, found); + } catch (IOException | RuntimeException ex) { + complete = false; + } + } + return complete; + } + + private static byte[] chunkBytes(WorldDir world, byte[] data, int offset, int chunkX, int chunkZ) + throws IOException { + if (offset + 5 > data.length) { + return null; + } + int length = readInt(data, offset); + int compression = data[offset + 4] & 0xFF; + byte[] raw; + if ((compression & 0x80) != 0) { + compression &= 0x7F; + raw = Files.readAllBytes(new File(world.entities(), "c." + chunkX + "." + chunkZ + ".mcc").toPath()); + } else { + if (length < 1 || offset + 4 + length > data.length) { + return null; + } + raw = new byte[length - 1]; + System.arraycopy(data, offset + 5, raw, 0, raw.length); + } + InputStream in = switch (compression) { + case 1 -> new GZIPInputStream(new ByteArrayInputStream(raw)); + case 2 -> new InflaterInputStream(new ByteArrayInputStream(raw)); + case 3 -> new ByteArrayInputStream(raw); + default -> null; + }; + if (in == null) { + return null; + } + try (in) { + return in.readAllBytes(); + } + } + + private static int readInt(byte[] data, int at) { + return ((data[at] & 0xFF) << 24) | ((data[at + 1] & 0xFF) << 16) + | ((data[at + 2] & 0xFF) << 8) | (data[at + 3] & 0xFF); + } + + // ---------------------------------------------------------------------- + // NBT: only Entities[].UUID, Pos and Passengers are read, the rest is skipped + // ---------------------------------------------------------------------- + + private static final byte END = 0; + private static final byte DOUBLE = 6; + private static final byte LIST = 9; + private static final byte COMPOUND = 10; + private static final byte INT_ARRAY = 11; + + private static void readEntityChunk(DataInputStream in, String world, Set targets, Map found) + throws IOException { + if (in.readByte() != COMPOUND) { + return; + } + in.readUTF(); + byte type; + while ((type = in.readByte()) != END) { + String key = in.readUTF(); + if (type == LIST && key.equals("Entities")) { + readEntityList(in, world, targets, found); + } else { + skip(in, type); + } + } + } + + private static void readEntityList(DataInputStream in, String world, Set targets, Map found) + throws IOException { + byte element = in.readByte(); + int size = in.readInt(); + if (element != COMPOUND) { + for (int i = 0; i < size; i++) { + skip(in, element); + } + return; + } + for (int i = 0; i < size; i++) { + readEntity(in, world, targets, found); + } + } + + private static void readEntity(DataInputStream in, String world, Set targets, Map found) + throws IOException { + UUID uuid = null; + double[] pos = null; + byte type; + while ((type = in.readByte()) != END) { + String key = in.readUTF(); + if (type == INT_ARRAY && key.equals("UUID")) { + int size = in.readInt(); + int[] parts = new int[size]; + for (int i = 0; i < size; i++) { + parts[i] = in.readInt(); + } + if (size == 4) { + uuid = new UUID( + ((long) parts[0] << 32) | (parts[1] & 0xFFFFFFFFL), + ((long) parts[2] << 32) | (parts[3] & 0xFFFFFFFFL)); + } + } else if (type == LIST && key.equals("Pos")) { + byte element = in.readByte(); + int size = in.readInt(); + if (element == DOUBLE && size == 3) { + pos = new double[] {in.readDouble(), in.readDouble(), in.readDouble()}; + } else { + for (int i = 0; i < size; i++) { + skip(in, element); + } + } + } else if (type == LIST && key.equals("Passengers")) { + readEntityList(in, world, targets, found); + } else { + skip(in, type); + } + } + if (uuid != null && pos != null && targets.contains(uuid)) { + found.putIfAbsent(uuid, new Found( + world, (int) Math.floor(pos[0]), (int) Math.floor(pos[1]), (int) Math.floor(pos[2]))); + } + } + + private static void skip(DataInputStream in, byte type) throws IOException { + switch (type) { + case 1 -> in.skipNBytes(1); + case 2 -> in.skipNBytes(2); + case 3, 5 -> in.skipNBytes(4); + case 4, 6 -> in.skipNBytes(8); + case 7 -> in.skipNBytes(in.readInt()); + case 8 -> in.skipNBytes(in.readUnsignedShort()); + case 9 -> { + byte element = in.readByte(); + int size = in.readInt(); + for (int i = 0; i < size; i++) { + skip(in, element); + } + } + case 10 -> { + byte inner; + while ((inner = in.readByte()) != END) { + in.skipNBytes(in.readUnsignedShort()); + skip(in, inner); + } + } + case 11 -> in.skipNBytes(4L * in.readInt()); + case 12 -> in.skipNBytes(8L * in.readInt()); + default -> throw new IOException("Unknown NBT tag " + type); + } + } +} diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java index 254e755..10520c0 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java @@ -152,6 +152,7 @@ static void handleLoad(Entity entity) { HusbandryAnimal animal = stored.get(); long now = System.currentTimeMillis(); HusbandryLocation.remember(animal, living); + HusbandryLocator.markFound(uuid); HusbandrySimulator.catchUp(animal, now, java.util.concurrent.ThreadLocalRandom.current()); HusbandryGrowth.applyMaturity(living, animal, now); HusbandryMounts.applyStats(living, animal); diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java new file mode 100644 index 0000000..38dfd38 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java @@ -0,0 +1,128 @@ +package net.tfminecraft.cooking.husbandry; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.Bukkit; +import org.bukkit.World; + +import net.tfminecraft.cooking.Cooking; +import net.tfminecraft.tlibs.database.SqliteDatabaseException; + +/** + * Finds owned animals sitting in unloaded chunks by reading the saved entity chunks, + * so {@code /animals} can point at them. Animals that are not in any saved chunk are marked missing. + */ +public final class HusbandryLocator { + + private static final Set MISSING = ConcurrentHashMap.newKeySet(); + + private HusbandryLocator() {} + + public static boolean isMissing(UUID uuid) { + return uuid != null && MISSING.contains(uuid); + } + + static void markFound(UUID uuid) { + if (uuid != null) { + MISSING.remove(uuid); + } + } + + /** Scans once in the background. Call on the main thread after loaded worlds have resumed. */ + public static void scanUnloaded() { + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null || Cooking.plugin == null) { + return; + } + Map targets = new HashMap<>(); + for (HusbandryAnimal animal : repository.listOwnedAnimals()) { + if (HusbandryEntities.getLoaded(animal.uuid()).isEmpty()) { + targets.put(animal.uuid(), animal.unloadedAt()); + } + } + if (targets.isEmpty()) { + return; + } + List worlds = new ArrayList<>(); + for (World world : Bukkit.getWorlds()) { + worlds.add(new HusbandryEntityScan.WorldDir(world.getName(), entitiesFolder(world))); + } + Bukkit.getScheduler().runTaskAsynchronously(Cooking.plugin, () -> { + HusbandryEntityScan.Result result = HusbandryEntityScan.scan(worlds, targets.keySet()); + if (Cooking.plugin != null && Cooking.plugin.isEnabled()) { + Bukkit.getScheduler().runTask(Cooking.plugin, () -> apply(targets, result)); + } + }); + } + + private static File entitiesFolder(World world) { + File folder = world.getWorldFolder(); + return switch (world.getEnvironment()) { + case NETHER -> new File(folder, "DIM-1/entities"); + case THE_END -> new File(folder, "DIM1/entities"); + default -> new File(folder, "entities"); + }; + } + + private static void apply(Map targets, HusbandryEntityScan.Result result) { + HusbandryRepository repository = HusbandryEntities.repository(); + if (repository == null) { + return; + } + int located = 0; + int missing = 0; + for (Map.Entry target : targets.entrySet()) { + UUID uuid = target.getKey(); + if (HusbandryEntities.getLoaded(uuid).isPresent()) { + continue; + } + Optional stored = repository.getAnimal(uuid); + if (stored.isEmpty() || !Objects.equals(stored.get().unloadedAt(), target.getValue())) { + // Loaded or unloaded again during the scan; the stored location is newer. + continue; + } + HusbandryEntityScan.Found found = result.found().get(uuid); + if (found == null) { + if (result.complete()) { + MISSING.add(uuid); + missing++; + } + continue; + } + MISSING.remove(uuid); + if (sameLocation(stored.get(), found)) { + continue; + } + HusbandryAnimal animal = stored.get(); + animal.setLastLocation(found.world(), found.x(), found.y(), found.z()); + try { + repository.upsertAnimal(animal); + located++; + } catch (SqliteDatabaseException ex) { + Bukkit.getLogger().severe("[Cooking] Failed to save husbandry animal location: " + ex.getMessage()); + } + } + if (located > 0 || missing > 0 || !result.complete()) { + Bukkit.getLogger().info("[Cooking] Animal scan: updated " + located + " locations, " + missing + + " owned animals not found in saved chunks" + + (result.complete() ? "." : " (some chunks could not be read, none marked missing).")); + } + } + + private static boolean sameLocation(HusbandryAnimal animal, HusbandryEntityScan.Found found) { + return animal.hasLocation() + && found.world().equals(animal.world()) + && found.x() == animal.x() + && found.y() == animal.y() + && found.z() == animal.z(); + } +} diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRepository.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRepository.java index ad26f97..3037fd2 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRepository.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRepository.java @@ -345,6 +345,16 @@ public List listForPlayer(UUID playerUuid) { playerUuid.toString()); } + public List listOwnedAnimals() { + return queryList( + """ + SELECT a.* + FROM animals a + WHERE EXISTS (SELECT 1 FROM owners o WHERE o.animal_uuid = a.uuid) + """, + HusbandryRepository::mapAnimal); + } + public void checkpointWal(boolean truncate) { String mode = truncate ? "TRUNCATE" : "PASSIVE"; database.execute("PRAGMA wal_checkpoint(" + mode + ")"); diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRoster.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRoster.java index 38afe29..92360fe 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRoster.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryRoster.java @@ -4,6 +4,8 @@ import java.util.Comparator; import java.util.List; import java.util.Locale; +import java.util.UUID; +import java.util.function.Predicate; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; @@ -21,6 +23,16 @@ public static List render( List owned, int maxAnimals, long nowMillis) { + return render(ownerName, self, owned, maxAnimals, nowMillis, uuid -> false); + } + + public static List render( + String ownerName, + boolean self, + List owned, + int maxAnimals, + long nowMillis, + Predicate missing) { List rows = owned == null ? List.of() : owned; int shown = rows.size(); int cap = Math.max(1, maxAnimals); @@ -45,7 +57,7 @@ public static List render( .thenComparing(row -> speciesLabel(row.animal().type()).toLowerCase(Locale.ROOT)) .thenComparing(row -> row.animal().uuid() == null ? "" : row.animal().uuid().toString())); for (HusbandryOwned row : sorted) { - lines.add(line(row, nowMillis)); + lines.add(line(row, nowMillis, missing.test(row.animal().uuid()))); } return lines; } @@ -79,7 +91,7 @@ private static String sortLabel(HusbandryOwned owned) { return speciesLabel(owned.animal().type()); } - private static Component line(HusbandryOwned owned, long nowMillis) { + private static Component line(HusbandryOwned owned, long nowMillis, boolean missing) { HusbandryAnimal animal = owned.animal(); String species = speciesLabel(animal.type()); String name = plain(animal.name()); @@ -95,7 +107,17 @@ private static Component line(HusbandryOwned owned, long nowMillis) { if (!HusbandryGrowth.isMature(animal, nowMillis)) { row = row.append(sep()).append(Component.text("Growing", NamedTextColor.GRAY)); } - return row.append(sep()).append(status(animal)).append(sep()).append(place(animal)); + row = row.append(sep()).append(status(animal)); + if (missing) { + row = row.append(sep()).append(Component.text("Missing", NamedTextColor.RED) + .hoverEvent(HoverEvent.showText(Component.text( + "Not found in any saved chunk when the server started.")))); + if (!animal.hasLocation()) { + return row; + } + return row.append(sep()).append(Component.text("last seen ", NamedTextColor.GRAY)).append(place(animal)); + } + return row.append(sep()).append(place(animal)); } private static Component status(HusbandryAnimal animal) { diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java new file mode 100644 index 0000000..e2f0459 --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java @@ -0,0 +1,210 @@ +package net.tfminecraft.cooking.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.GZIPOutputStream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class HusbandryEntityScanTest { + + private static final UUID COW = UUID.fromString("b3a3b7bb-af8d-48ff-baa4-c75e7b5cd3cf"); + private static final UUID HORSE = UUID.fromString("4a20145e-0b17-4531-a630-e3fb2faa2a50"); + private static final UUID RIDER = UUID.fromString("da6a6b72-b5d9-403c-9af0-0d89eca049fe"); + private static final UUID GONE = UUID.fromString("ad0c0422-8f97-4a4d-96c0-74a20b74383f"); + + @Test + void findsAnimalsAndPassengersInSavedChunks(@TempDir Path dir) throws IOException { + File entities = dir.resolve("entities").toFile(); + entities.mkdirs(); + Map chunks = Map.of( + 0, zlib(chunk(entity(COW, 12.7, 64.0, -3.2))), + 33, gzip(chunk(entity(HORSE, -40.5, 70.0, 900.9, entity(RIDER, -40.5, 71.5, 900.9))))); + writeRegion(new File(entities, "r.0.-1.mca"), chunks, Map.of()); + + HusbandryEntityScan.Result result = HusbandryEntityScan.scan( + List.of(new HusbandryEntityScan.WorldDir("TFMC_Map", entities)), Set.of(COW, RIDER, GONE)); + + assertTrue(result.complete()); + assertEquals(new HusbandryEntityScan.Found("TFMC_Map", 12, 64, -4), result.found().get(COW)); + assertEquals(new HusbandryEntityScan.Found("TFMC_Map", -41, 71, 900), result.found().get(RIDER)); + assertFalse(result.found().containsKey(HORSE)); + assertFalse(result.found().containsKey(GONE)); + } + + @Test + void readsOversizedChunksFromExternalFiles(@TempDir Path dir) throws IOException { + File entities = dir.resolve("entities").toFile(); + entities.mkdirs(); + // Chunk index 34 in region (-1, 2) is chunk (-30, 65). + Files.write(new File(entities, "c.-30.65.mcc").toPath(), zlib(chunk(entity(COW, 1.0, 2.0, 3.0)))); + writeRegion(new File(entities, "r.-1.2.mca"), Map.of(), Map.of(34, 2 | 0x80)); + + HusbandryEntityScan.Result result = HusbandryEntityScan.scan( + List.of(new HusbandryEntityScan.WorldDir("world", entities)), Set.of(COW)); + + assertTrue(result.complete()); + assertEquals(new HusbandryEntityScan.Found("world", 1, 2, 3), result.found().get(COW)); + } + + @Test + void unreadableChunksMakeTheScanIncomplete(@TempDir Path dir) throws IOException { + File entities = dir.resolve("entities").toFile(); + entities.mkdirs(); + writeRegion(new File(entities, "r.0.0.mca"), + Map.of(0, zlib(chunk(entity(COW, 0, 0, 0))), 1, new byte[] {1, 2, 3}), Map.of(1, 4)); + + HusbandryEntityScan.Result result = HusbandryEntityScan.scan( + List.of(new HusbandryEntityScan.WorldDir("world", entities)), Set.of(COW, GONE)); + + assertFalse(result.complete()); + assertTrue(result.found().containsKey(COW)); + } + + @Test + void missingFolderIsSkipped(@TempDir Path dir) { + HusbandryEntityScan.Result result = HusbandryEntityScan.scan( + List.of(new HusbandryEntityScan.WorldDir("world", dir.resolve("nope").toFile())), Set.of(COW)); + assertTrue(result.complete()); + assertTrue(result.found().isEmpty()); + } + + // ---------------------------------------------------------------------- + // Fixture writers + // ---------------------------------------------------------------------- + + private interface Body { + void write(DataOutputStream out) throws IOException; + } + + private static Body entity(UUID uuid, double x, double y, double z, Body... passengers) { + return out -> { + out.writeByte(8); + out.writeUTF("id"); + out.writeUTF("minecraft:cow"); + out.writeByte(10); + out.writeUTF("Brain"); + out.writeByte(9); + out.writeUTF("memories"); + out.writeByte(0); + out.writeInt(0); + out.writeByte(0); + out.writeByte(9); + out.writeUTF("Pos"); + out.writeByte(6); + out.writeInt(3); + out.writeDouble(x); + out.writeDouble(y); + out.writeDouble(z); + out.writeByte(12); + out.writeUTF("Longs"); + out.writeInt(1); + out.writeLong(7L); + out.writeByte(11); + out.writeUTF("UUID"); + out.writeInt(4); + out.writeInt((int) (uuid.getMostSignificantBits() >> 32)); + out.writeInt((int) uuid.getMostSignificantBits()); + out.writeInt((int) (uuid.getLeastSignificantBits() >> 32)); + out.writeInt((int) uuid.getLeastSignificantBits()); + if (passengers.length > 0) { + out.writeByte(9); + out.writeUTF("Passengers"); + out.writeByte(10); + out.writeInt(passengers.length); + for (Body passenger : passengers) { + passenger.write(out); + } + } + out.writeByte(0); + }; + } + + private static byte[] chunk(Body... entities) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bytes); + out.writeByte(10); + out.writeUTF(""); + out.writeByte(3); + out.writeUTF("DataVersion"); + out.writeInt(4556); + out.writeByte(11); + out.writeUTF("Position"); + out.writeInt(2); + out.writeInt(0); + out.writeInt(0); + out.writeByte(9); + out.writeUTF("Entities"); + out.writeByte(10); + out.writeInt(entities.length); + for (Body entity : entities) { + entity.write(out); + } + out.writeByte(0); + return bytes.toByteArray(); + } + + private static byte[] zlib(byte[] raw) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DeflaterOutputStream out = new DeflaterOutputStream(bytes)) { + out.write(raw); + } + return bytes.toByteArray(); + } + + private static byte[] gzip(byte[] raw) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (GZIPOutputStream out = new GZIPOutputStream(bytes)) { + out.write(raw); + } + return bytes.toByteArray(); + } + + /** Payloads default to zlib (2), or gzip (1) when the bytes start with the gzip magic. */ + private static void writeRegion(File file, Map payloads, Map compression) + throws IOException { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + int[] header = new int[1024]; + int sector = 2; + for (int index = 0; index < 1024; index++) { + byte[] payload = payloads.getOrDefault(index, compression.containsKey(index) ? new byte[0] : null); + if (payload == null) { + continue; + } + int type = compression.getOrDefault(index, + payload.length > 1 && (payload[0] & 0xFF) == 0x1F && (payload[1] & 0xFF) == 0x8B ? 1 : 2); + ByteArrayOutputStream chunk = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(chunk); + out.writeInt(payload.length + 1); + out.writeByte(type); + out.write(payload); + int sectors = (chunk.size() + 4095) / 4096; + chunk.write(new byte[sectors * 4096 - chunk.size()]); + header[index] = (sector << 8) | sectors; + sector += sectors; + body.write(chunk.toByteArray()); + } + ByteArrayOutputStream region = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(region); + for (int entry : header) { + out.writeInt(entry); + } + out.write(new byte[4096]); + out.write(body.toByteArray()); + Files.write(file.toPath(), region.toByteArray()); + } +} diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRepositoryTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRepositoryTest.java index d05aedb..1fe20cf 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRepositoryTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRepositoryTest.java @@ -165,6 +165,14 @@ void locationRoundTripsAndListsOnlyThatPlayersAnimals(@TempDir Path tempDir) { .filter(row -> row.animal().uuid().equals(pig.uuid())) .count()); assertTrue(repository.listForPlayer(UUID.randomUUID()).isEmpty()); + + HusbandryAnimal wild = animal(UUID.randomUUID(), "COW", "", 0); + repository.upsertAnimal(wild); + assertEquals( + java.util.Set.of(cow.uuid(), sheep.uuid(), pig.uuid()), + repository.listOwnedAnimals().stream() + .map(HusbandryAnimal::uuid) + .collect(java.util.stream.Collectors.toSet())); } finally { repository.close(); } diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRosterTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRosterTest.java index 70e01e6..c1ac178 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRosterTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRosterTest.java @@ -54,6 +54,27 @@ void listsNameSpeciesStatusAndCopyableCoordinates() { assertNull(findClick(lines.get(2))); } + @Test + void missingAnimalsAreFlaggedWithTheirLastKnownSpot() { + HusbandryAnimal bess = animal("COW", "Bess"); + bess.setLastLocation("TFMC_Map", 4369, 167, 1950); + HusbandryAnimal ham = animal("PIG", "Ham"); + HusbandryAnimal woolly = animal("SHEEP", "Woolly"); + woolly.setLastLocation("TFMC_Map", 10, 64, 10); + + List lines = text(HusbandryRoster.render("Ada", true, List.of( + new HusbandryOwned(bess, "owner"), + new HusbandryOwned(ham, "owner"), + new HusbandryOwned(woolly, "owner")), 15, 1_000L, + uuid -> !uuid.equals(woolly.uuid()))); + + assertEquals(List.of( + "Your animals (3/15)", + "• Bess · Cow · Happy · Missing · last seen TFMC_Map 4369, 167, 1950", + "• Ham · Pig · Happy · Missing", + "• Woolly · Sheep · Happy · TFMC_Map 10, 64, 10"), lines); + } + @Test void otherPlayersEmptyListUsesTheirName() { List lines = text(HusbandryRoster.render("Ada", false, List.of(), 15, 1_000L)); From 6ce4ab7549885aa0791a26e3a852b9ca3763800c Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:47:18 +0000 Subject: [PATCH 2/5] fix: don't mark animals missing from truncated regions or unscanned worlds Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cooking/husbandry/HusbandryEntityScan.java | 5 ++++- .../cooking/husbandry/HusbandryLocator.java | 12 +++++++++--- .../cooking/husbandry/HusbandryEntityScanTest.java | 12 ++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java index a9de28b..01be863 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java @@ -66,9 +66,12 @@ private static boolean scanRegion( } catch (IOException ex) { return false; } - if (data.length < SECTOR * 2) { + if (data.length == 0) { return true; } + if (data.length < SECTOR * 2) { + return false; + } boolean complete = true; for (int index = 0; index < 1024; index++) { int entry = readInt(data, index * 4); diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java index 38dfd38..6552460 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLocator.java @@ -59,7 +59,7 @@ public static void scanUnloaded() { Bukkit.getScheduler().runTaskAsynchronously(Cooking.plugin, () -> { HusbandryEntityScan.Result result = HusbandryEntityScan.scan(worlds, targets.keySet()); if (Cooking.plugin != null && Cooking.plugin.isEnabled()) { - Bukkit.getScheduler().runTask(Cooking.plugin, () -> apply(targets, result)); + Bukkit.getScheduler().runTask(Cooking.plugin, () -> apply(targets, result, worlds)); } }); } @@ -73,7 +73,10 @@ private static File entitiesFolder(World world) { }; } - private static void apply(Map targets, HusbandryEntityScan.Result result) { + private static void apply( + Map targets, + HusbandryEntityScan.Result result, + List worlds) { HusbandryRepository repository = HusbandryEntities.repository(); if (repository == null) { return; @@ -92,7 +95,10 @@ private static void apply(Map targets, HusbandryEntityScan.Result re } HusbandryEntityScan.Found found = result.found().get(uuid); if (found == null) { - if (result.complete()) { + String storedWorld = stored.get().world(); + // An animal last seen in a world that was not scanned may still be there. + if (result.complete() && (storedWorld == null + || worlds.stream().anyMatch(world -> world.world().equals(storedWorld)))) { MISSING.add(uuid); missing++; } diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java index e2f0459..5a19b4e 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java @@ -75,6 +75,18 @@ void unreadableChunksMakeTheScanIncomplete(@TempDir Path dir) throws IOException assertTrue(result.found().containsKey(COW)); } + @Test + void truncatedRegionIsIncompleteButEmptyRegionIsNot(@TempDir Path dir) throws IOException { + File entities = dir.resolve("entities").toFile(); + entities.mkdirs(); + Files.write(new File(entities, "r.0.0.mca").toPath(), new byte[0]); + List worlds = List.of(new HusbandryEntityScan.WorldDir("world", entities)); + assertTrue(HusbandryEntityScan.scan(worlds, Set.of(COW)).complete()); + + Files.write(new File(entities, "r.0.1.mca").toPath(), new byte[100]); + assertFalse(HusbandryEntityScan.scan(worlds, Set.of(COW)).complete()); + } + @Test void missingFolderIsSkipped(@TempDir Path dir) { HusbandryEntityScan.Result result = HusbandryEntityScan.scan( From 27ea5563b374df636c3b3eab8330c3c9eed5c674 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:58:25 +0000 Subject: [PATCH 3/5] fix: bound entity scan reads and treat unreadable folders as incomplete Co-Authored-By: Claude Opus 5.5 (1M context) --- .../husbandry/HusbandryEntityScan.java | 21 ++++++++++++------- .../husbandry/HusbandryEntityScanTest.java | 18 ++++++++++++++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java index 01be863..9627302 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java @@ -31,6 +31,7 @@ record Result(Map found, boolean complete) {} private static final Pattern REGION = Pattern.compile("r\\.(-?\\d+)\\.(-?\\d+)\\.mca"); private static final int SECTOR = 4096; + private static final int MAX_CHUNK_BYTES = 32 * 1024 * 1024; private HusbandryEntityScan() {} @@ -41,8 +42,12 @@ static Result scan(List worlds, Set targets) { return new Result(found, true); } for (WorldDir world : worlds) { + if (!world.entities().exists()) { + continue; + } File[] files = world.entities().listFiles(); if (files == null) { + complete = false; continue; } for (File file : files) { @@ -121,7 +126,11 @@ private static byte[] chunkBytes(WorldDir world, byte[] data, int offset, int ch return null; } try (in) { - return in.readAllBytes(); + byte[] chunk = in.readNBytes(MAX_CHUNK_BYTES + 1); + if (chunk.length > MAX_CHUNK_BYTES) { + throw new IOException("Entity chunk larger than " + MAX_CHUNK_BYTES + " bytes"); + } + return chunk; } } @@ -181,14 +190,12 @@ private static void readEntity(DataInputStream in, String world, Set targe String key = in.readUTF(); if (type == INT_ARRAY && key.equals("UUID")) { int size = in.readInt(); - int[] parts = new int[size]; - for (int i = 0; i < size; i++) { - parts[i] = in.readInt(); - } if (size == 4) { uuid = new UUID( - ((long) parts[0] << 32) | (parts[1] & 0xFFFFFFFFL), - ((long) parts[2] << 32) | (parts[3] & 0xFFFFFFFFL)); + ((long) in.readInt() << 32) | (in.readInt() & 0xFFFFFFFFL), + ((long) in.readInt() << 32) | (in.readInt() & 0xFFFFFFFFL)); + } else { + in.skipNBytes(4L * size); } } else if (type == LIST && key.equals("Pos")) { byte element = in.readByte(); diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java index 5a19b4e..df859a8 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java @@ -87,6 +87,24 @@ void truncatedRegionIsIncompleteButEmptyRegionIsNot(@TempDir Path dir) throws IO assertFalse(HusbandryEntityScan.scan(worlds, Set.of(COW)).complete()); } + @Test + void corruptUuidLengthIsUnreadableNotAnAllocation(@TempDir Path dir) throws IOException { + File entities = dir.resolve("entities").toFile(); + entities.mkdirs(); + Body corrupt = out -> { + out.writeByte(11); + out.writeUTF("UUID"); + out.writeInt(Integer.MAX_VALUE); + }; + writeRegion(new File(entities, "r.0.0.mca"), Map.of(0, zlib(chunk(corrupt))), Map.of()); + + HusbandryEntityScan.Result result = HusbandryEntityScan.scan( + List.of(new HusbandryEntityScan.WorldDir("world", entities)), Set.of(COW)); + + assertFalse(result.complete()); + assertTrue(result.found().isEmpty()); + } + @Test void missingFolderIsSkipped(@TempDir Path dir) { HusbandryEntityScan.Result result = HusbandryEntityScan.scan( From 0178757526ec47b06dfad186164bd0305d00103f Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 27 Sep 2026 08:02:17 +0000 Subject: [PATCH 4/5] fix: cap region and external chunk file reads in the entity scan Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cooking/husbandry/HusbandryEntityScan.java | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java index 9627302..8345280 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java @@ -29,9 +29,10 @@ record Found(String world, int x, int y, int z) {} /** {@code complete} is false when some chunk could not be read, so a missing animal may still exist. */ record Result(Map found, boolean complete) {} - private static final Pattern REGION = Pattern.compile("r\\.(-?\\d+)\\.(-?\\d+)\\.mca"); + private static final Pattern REGION = Pattern.compile("r\\.(-?\\d{1,7})\\.(-?\\d{1,7})\\.mca"); private static final int SECTOR = 4096; private static final int MAX_CHUNK_BYTES = 32 * 1024 * 1024; + private static final int MAX_REGION_BYTES = 256 * 1024 * 1024; private HusbandryEntityScan() {} @@ -67,7 +68,7 @@ private static boolean scanRegion( WorldDir world, File file, int regionX, int regionZ, Set targets, Map found) { byte[] data; try { - data = Files.readAllBytes(file.toPath()); + data = readBounded(file, MAX_REGION_BYTES); } catch (IOException ex) { return false; } @@ -108,7 +109,7 @@ private static byte[] chunkBytes(WorldDir world, byte[] data, int offset, int ch byte[] raw; if ((compression & 0x80) != 0) { compression &= 0x7F; - raw = Files.readAllBytes(new File(world.entities(), "c." + chunkX + "." + chunkZ + ".mcc").toPath()); + raw = readBounded(new File(world.entities(), "c." + chunkX + "." + chunkZ + ".mcc"), MAX_CHUNK_BYTES); } else { if (length < 1 || offset + 4 + length > data.length) { return null; @@ -134,6 +135,16 @@ private static byte[] chunkBytes(WorldDir world, byte[] data, int offset, int ch } } + private static byte[] readBounded(File file, int max) throws IOException { + try (InputStream in = Files.newInputStream(file.toPath())) { + byte[] data = in.readNBytes(max + 1); + if (data.length > max) { + throw new IOException(file.getName() + " is larger than " + max + " bytes"); + } + return data; + } + } + private static int readInt(byte[] data, int at) { return ((data[at] & 0xFF) << 24) | ((data[at + 1] & 0xFF) << 16) | ((data[at + 2] & 0xFF) << 8) | (data[at + 3] & 0xFF); From f4eeb4a0379b4e676e4f7e09ab796685f9a748e5 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 27 Sep 2026 08:13:02 +0000 Subject: [PATCH 5/5] fix: use overflow-safe region offsets in the entity scan Co-Authored-By: Claude Opus 5.5 (1M context) --- .../husbandry/HusbandryEntityScan.java | 14 +++++----- .../husbandry/HusbandryEntityScanTest.java | 26 +++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java index 8345280..7fb29e6 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScan.java @@ -81,10 +81,15 @@ private static boolean scanRegion( boolean complete = true; for (int index = 0; index < 1024; index++) { int entry = readInt(data, index * 4); - int offset = (entry >>> 8) * SECTOR; - if (offset == 0) { + long sectorOffset = (long) (entry >>> 8) * SECTOR; + if (sectorOffset == 0) { continue; } + if (sectorOffset + 5 > data.length) { + complete = false; + continue; + } + int offset = (int) sectorOffset; try { byte[] chunk = chunkBytes(world, data, offset, regionX * 32 + index % 32, regionZ * 32 + index / 32); if (chunk == null) { @@ -101,9 +106,6 @@ private static boolean scanRegion( private static byte[] chunkBytes(WorldDir world, byte[] data, int offset, int chunkX, int chunkZ) throws IOException { - if (offset + 5 > data.length) { - return null; - } int length = readInt(data, offset); int compression = data[offset + 4] & 0xFF; byte[] raw; @@ -111,7 +113,7 @@ private static byte[] chunkBytes(WorldDir world, byte[] data, int offset, int ch compression &= 0x7F; raw = readBounded(new File(world.entities(), "c." + chunkX + "." + chunkZ + ".mcc"), MAX_CHUNK_BYTES); } else { - if (length < 1 || offset + 4 + length > data.length) { + if (length < 1 || (long) offset + 4 + length > data.length) { return null; } raw = new byte[length - 1]; diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java index df859a8..9044044 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryEntityScanTest.java @@ -105,6 +105,32 @@ void corruptUuidLengthIsUnreadableNotAnAllocation(@TempDir Path dir) throws IOEx assertTrue(result.found().isEmpty()); } + @Test + void outOfRangeChunkHeadersAreUnreadable(@TempDir Path dir) throws IOException { + File entities = dir.resolve("entities").toFile(); + entities.mkdirs(); + byte[] region = new byte[4096 * 3]; + // Chunk 0 points past the end of the file; chunk 1 claims a 2 GiB payload. + region[0] = (byte) 0xFF; + region[1] = (byte) 0xFF; + region[2] = (byte) 0xFF; + region[3] = 1; + region[6] = 2; + region[7] = 1; + region[8192] = 0x7F; + region[8193] = (byte) 0xFF; + region[8194] = (byte) 0xFF; + region[8195] = (byte) 0xFF; + region[8196] = 2; + Files.write(new File(entities, "r.0.0.mca").toPath(), region); + + HusbandryEntityScan.Result result = HusbandryEntityScan.scan( + List.of(new HusbandryEntityScan.WorldDir("world", entities)), Set.of(COW)); + + assertFalse(result.complete()); + assertTrue(result.found().isEmpty()); + } + @Test void missingFolderIsSkipped(@TempDir Path dir) { HusbandryEntityScan.Result result = HusbandryEntityScan.scan(