Skip to content

Commit 4806ff6

Browse files
Drefvelinryanbarlow97claude
authored
feat: locate owned animals in unloaded chunks for /animals (#50)
* 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) <noreply@anthropic.com> * fix: don't mark animals missing from truncated regions or unscanned worlds Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: bound entity scan reads and treat unreadable folders as incomplete Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: cap region and external chunk file reads in the entity scan Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: use overflow-safe region offsets in the entity scan Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 9ee914a commit 4806ff6

10 files changed

Lines changed: 731 additions & 4 deletions

File tree

‎src/main/java/net/tfminecraft/cooking/Cooking.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import net.tfminecraft.cooking.crops.CropGrowthListener;
3737
import net.tfminecraft.cooking.crops.CropsLoader;
3838
import net.tfminecraft.cooking.husbandry.HusbandryLoader;
39+
import net.tfminecraft.cooking.husbandry.HusbandryLocator;
3940
import net.tfminecraft.cooking.husbandry.HusbandryRepository;
4041
import net.tfminecraft.cooking.husbandry.HusbandryTickTask;
4142
import net.tfminecraft.cooking.loader.PermissionEffectsLoader;
@@ -151,6 +152,7 @@ public void onEnable() {
151152
NutritionDrainTask.start();
152153
HusbandryLifecycleListener.resumeLoadedWorlds();
153154
HusbandryTickTask.start();
155+
HusbandryLocator.scanUnloaded();
154156
});
155157

156158
getCommand("cooking").setExecutor(commands);

‎src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimalsCommand.java‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ public boolean onCommand(CommandSender sender, Command command, String label, St
6464
rows.add(new HusbandryOwned(animal, owned.role()));
6565
}
6666
for (Component line : HusbandryRoster.render(
67-
ownerName, self, rows, HusbandryConfig.maxAnimals(), System.currentTimeMillis())) {
67+
ownerName, self, rows, HusbandryConfig.maxAnimals(), System.currentTimeMillis(),
68+
HusbandryLocator::isMissing)) {
6869
sender.sendMessage(line);
6970
}
7071
return true;
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
package net.tfminecraft.cooking.husbandry;
2+
3+
import java.io.ByteArrayInputStream;
4+
import java.io.DataInputStream;
5+
import java.io.File;
6+
import java.io.IOException;
7+
import java.io.InputStream;
8+
import java.nio.file.Files;
9+
import java.util.HashMap;
10+
import java.util.List;
11+
import java.util.Map;
12+
import java.util.Set;
13+
import java.util.UUID;
14+
import java.util.regex.Matcher;
15+
import java.util.regex.Pattern;
16+
import java.util.zip.GZIPInputStream;
17+
import java.util.zip.InflaterInputStream;
18+
19+
/**
20+
* Reads saved entity chunks ({@code entities/r.X.Z.mca}) to find where unloaded animals are.
21+
* Runs off the main thread; it only reads files.
22+
*/
23+
final class HusbandryEntityScan {
24+
25+
record WorldDir(String world, File entities) {}
26+
27+
record Found(String world, int x, int y, int z) {}
28+
29+
/** {@code complete} is false when some chunk could not be read, so a missing animal may still exist. */
30+
record Result(Map<UUID, Found> found, boolean complete) {}
31+
32+
private static final Pattern REGION = Pattern.compile("r\\.(-?\\d{1,7})\\.(-?\\d{1,7})\\.mca");
33+
private static final int SECTOR = 4096;
34+
private static final int MAX_CHUNK_BYTES = 32 * 1024 * 1024;
35+
private static final int MAX_REGION_BYTES = 256 * 1024 * 1024;
36+
37+
private HusbandryEntityScan() {}
38+
39+
static Result scan(List<WorldDir> worlds, Set<UUID> targets) {
40+
Map<UUID, Found> found = new HashMap<>();
41+
boolean complete = true;
42+
if (targets.isEmpty()) {
43+
return new Result(found, true);
44+
}
45+
for (WorldDir world : worlds) {
46+
if (!world.entities().exists()) {
47+
continue;
48+
}
49+
File[] files = world.entities().listFiles();
50+
if (files == null) {
51+
complete = false;
52+
continue;
53+
}
54+
for (File file : files) {
55+
Matcher name = REGION.matcher(file.getName());
56+
if (!name.matches()) {
57+
continue;
58+
}
59+
int regionX = Integer.parseInt(name.group(1));
60+
int regionZ = Integer.parseInt(name.group(2));
61+
complete &= scanRegion(world, file, regionX, regionZ, targets, found);
62+
}
63+
}
64+
return new Result(found, complete);
65+
}
66+
67+
private static boolean scanRegion(
68+
WorldDir world, File file, int regionX, int regionZ, Set<UUID> targets, Map<UUID, Found> found) {
69+
byte[] data;
70+
try {
71+
data = readBounded(file, MAX_REGION_BYTES);
72+
} catch (IOException ex) {
73+
return false;
74+
}
75+
if (data.length == 0) {
76+
return true;
77+
}
78+
if (data.length < SECTOR * 2) {
79+
return false;
80+
}
81+
boolean complete = true;
82+
for (int index = 0; index < 1024; index++) {
83+
int entry = readInt(data, index * 4);
84+
long sectorOffset = (long) (entry >>> 8) * SECTOR;
85+
if (sectorOffset == 0) {
86+
continue;
87+
}
88+
if (sectorOffset + 5 > data.length) {
89+
complete = false;
90+
continue;
91+
}
92+
int offset = (int) sectorOffset;
93+
try {
94+
byte[] chunk = chunkBytes(world, data, offset, regionX * 32 + index % 32, regionZ * 32 + index / 32);
95+
if (chunk == null) {
96+
complete = false;
97+
continue;
98+
}
99+
readEntityChunk(new DataInputStream(new ByteArrayInputStream(chunk)), world.world(), targets, found);
100+
} catch (IOException | RuntimeException ex) {
101+
complete = false;
102+
}
103+
}
104+
return complete;
105+
}
106+
107+
private static byte[] chunkBytes(WorldDir world, byte[] data, int offset, int chunkX, int chunkZ)
108+
throws IOException {
109+
int length = readInt(data, offset);
110+
int compression = data[offset + 4] & 0xFF;
111+
byte[] raw;
112+
if ((compression & 0x80) != 0) {
113+
compression &= 0x7F;
114+
raw = readBounded(new File(world.entities(), "c." + chunkX + "." + chunkZ + ".mcc"), MAX_CHUNK_BYTES);
115+
} else {
116+
if (length < 1 || (long) offset + 4 + length > data.length) {
117+
return null;
118+
}
119+
raw = new byte[length - 1];
120+
System.arraycopy(data, offset + 5, raw, 0, raw.length);
121+
}
122+
InputStream in = switch (compression) {
123+
case 1 -> new GZIPInputStream(new ByteArrayInputStream(raw));
124+
case 2 -> new InflaterInputStream(new ByteArrayInputStream(raw));
125+
case 3 -> new ByteArrayInputStream(raw);
126+
default -> null;
127+
};
128+
if (in == null) {
129+
return null;
130+
}
131+
try (in) {
132+
byte[] chunk = in.readNBytes(MAX_CHUNK_BYTES + 1);
133+
if (chunk.length > MAX_CHUNK_BYTES) {
134+
throw new IOException("Entity chunk larger than " + MAX_CHUNK_BYTES + " bytes");
135+
}
136+
return chunk;
137+
}
138+
}
139+
140+
private static byte[] readBounded(File file, int max) throws IOException {
141+
try (InputStream in = Files.newInputStream(file.toPath())) {
142+
byte[] data = in.readNBytes(max + 1);
143+
if (data.length > max) {
144+
throw new IOException(file.getName() + " is larger than " + max + " bytes");
145+
}
146+
return data;
147+
}
148+
}
149+
150+
private static int readInt(byte[] data, int at) {
151+
return ((data[at] & 0xFF) << 24) | ((data[at + 1] & 0xFF) << 16)
152+
| ((data[at + 2] & 0xFF) << 8) | (data[at + 3] & 0xFF);
153+
}
154+
155+
// ----------------------------------------------------------------------
156+
// NBT: only Entities[].UUID, Pos and Passengers are read, the rest is skipped
157+
// ----------------------------------------------------------------------
158+
159+
private static final byte END = 0;
160+
private static final byte DOUBLE = 6;
161+
private static final byte LIST = 9;
162+
private static final byte COMPOUND = 10;
163+
private static final byte INT_ARRAY = 11;
164+
165+
private static void readEntityChunk(DataInputStream in, String world, Set<UUID> targets, Map<UUID, Found> found)
166+
throws IOException {
167+
if (in.readByte() != COMPOUND) {
168+
return;
169+
}
170+
in.readUTF();
171+
byte type;
172+
while ((type = in.readByte()) != END) {
173+
String key = in.readUTF();
174+
if (type == LIST && key.equals("Entities")) {
175+
readEntityList(in, world, targets, found);
176+
} else {
177+
skip(in, type);
178+
}
179+
}
180+
}
181+
182+
private static void readEntityList(DataInputStream in, String world, Set<UUID> targets, Map<UUID, Found> found)
183+
throws IOException {
184+
byte element = in.readByte();
185+
int size = in.readInt();
186+
if (element != COMPOUND) {
187+
for (int i = 0; i < size; i++) {
188+
skip(in, element);
189+
}
190+
return;
191+
}
192+
for (int i = 0; i < size; i++) {
193+
readEntity(in, world, targets, found);
194+
}
195+
}
196+
197+
private static void readEntity(DataInputStream in, String world, Set<UUID> targets, Map<UUID, Found> found)
198+
throws IOException {
199+
UUID uuid = null;
200+
double[] pos = null;
201+
byte type;
202+
while ((type = in.readByte()) != END) {
203+
String key = in.readUTF();
204+
if (type == INT_ARRAY && key.equals("UUID")) {
205+
int size = in.readInt();
206+
if (size == 4) {
207+
uuid = new UUID(
208+
((long) in.readInt() << 32) | (in.readInt() & 0xFFFFFFFFL),
209+
((long) in.readInt() << 32) | (in.readInt() & 0xFFFFFFFFL));
210+
} else {
211+
in.skipNBytes(4L * size);
212+
}
213+
} else if (type == LIST && key.equals("Pos")) {
214+
byte element = in.readByte();
215+
int size = in.readInt();
216+
if (element == DOUBLE && size == 3) {
217+
pos = new double[] {in.readDouble(), in.readDouble(), in.readDouble()};
218+
} else {
219+
for (int i = 0; i < size; i++) {
220+
skip(in, element);
221+
}
222+
}
223+
} else if (type == LIST && key.equals("Passengers")) {
224+
readEntityList(in, world, targets, found);
225+
} else {
226+
skip(in, type);
227+
}
228+
}
229+
if (uuid != null && pos != null && targets.contains(uuid)) {
230+
found.putIfAbsent(uuid, new Found(
231+
world, (int) Math.floor(pos[0]), (int) Math.floor(pos[1]), (int) Math.floor(pos[2])));
232+
}
233+
}
234+
235+
private static void skip(DataInputStream in, byte type) throws IOException {
236+
switch (type) {
237+
case 1 -> in.skipNBytes(1);
238+
case 2 -> in.skipNBytes(2);
239+
case 3, 5 -> in.skipNBytes(4);
240+
case 4, 6 -> in.skipNBytes(8);
241+
case 7 -> in.skipNBytes(in.readInt());
242+
case 8 -> in.skipNBytes(in.readUnsignedShort());
243+
case 9 -> {
244+
byte element = in.readByte();
245+
int size = in.readInt();
246+
for (int i = 0; i < size; i++) {
247+
skip(in, element);
248+
}
249+
}
250+
case 10 -> {
251+
byte inner;
252+
while ((inner = in.readByte()) != END) {
253+
in.skipNBytes(in.readUnsignedShort());
254+
skip(in, inner);
255+
}
256+
}
257+
case 11 -> in.skipNBytes(4L * in.readInt());
258+
case 12 -> in.skipNBytes(8L * in.readInt());
259+
default -> throw new IOException("Unknown NBT tag " + type);
260+
}
261+
}
262+
}

‎src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ static void handleLoad(Entity entity) {
152152
HusbandryAnimal animal = stored.get();
153153
long now = System.currentTimeMillis();
154154
HusbandryLocation.remember(animal, living);
155+
HusbandryLocator.markFound(uuid);
155156
HusbandrySimulator.catchUp(animal, now, java.util.concurrent.ThreadLocalRandom.current());
156157
HusbandryGrowth.applyMaturity(living, animal, now);
157158
HusbandryMounts.applyStats(living, animal);

0 commit comments

Comments
 (0)