From ca01ef0d613cde14c66cccf68c94c9964436e956 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:09:13 +0800 Subject: [PATCH 01/61] Add ContentSource, one pack id and assets folder to load definitions from --- .../mcheli/content/pack/ContentSource.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 common/src/main/java/com/jnhprojects/mcheli/content/pack/ContentSource.java diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/pack/ContentSource.java b/common/src/main/java/com/jnhprojects/mcheli/content/pack/ContentSource.java new file mode 100644 index 0000000..cbfe6c7 --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/content/pack/ContentSource.java @@ -0,0 +1,20 @@ +package com.jnhprojects.mcheli.content.pack; + +import java.nio.file.Path; + +/** + * One place definitions are read from: the {@code assets/mcheli} folder of a content pack, or of + * the imported 1.0.3 content. + * + * @param pack id of the pack, {@link #ORIGINAL} for the import + * @param assets the {@code assets/mcheli} folder, possibly inside a zip file system; it need not + * exist + */ +public record ContentSource(String pack, Path assets) { + /** Pack id of the imported 1.0.3 content. No content pack may use it. */ + public static final String ORIGINAL = "original"; + + public boolean isOriginal() { + return ORIGINAL.equals(pack); + } +} From 7264218288817bc52436a4e0c11684fa225b0726 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:11 +0800 Subject: [PATCH 02/61] Add PackProblem for content pack errors shown in the log and chat --- .../mcheli/content/pack/PackProblem.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 common/src/main/java/com/jnhprojects/mcheli/content/pack/PackProblem.java diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/pack/PackProblem.java b/common/src/main/java/com/jnhprojects/mcheli/content/pack/PackProblem.java new file mode 100644 index 0000000..c6d69fa --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/content/pack/PackProblem.java @@ -0,0 +1,15 @@ +package com.jnhprojects.mcheli.content.pack; + +/** + * Something wrong with a content pack, for the log and for operators in the chat. A problem never + * stops the game: the pack, or the one file, is left out and everything else loads. + * + * @param pack the pack id, or its file name when the id could not be read + * @param message what is wrong, in English + */ +public record PackProblem(String pack, String message) { + @Override + public String toString() { + return pack + ": " + message; + } +} From 474de96764197331ef4580540e3493a1c3768d75 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:12 +0800 Subject: [PATCH 03/61] Add PackException for packs that cannot be used --- .../jnhprojects/mcheli/content/pack/PackException.java | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 common/src/main/java/com/jnhprojects/mcheli/content/pack/PackException.java diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/pack/PackException.java b/common/src/main/java/com/jnhprojects/mcheli/content/pack/PackException.java new file mode 100644 index 0000000..6165cbe --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/content/pack/PackException.java @@ -0,0 +1,8 @@ +package com.jnhprojects.mcheli.content.pack; + +/** A content pack that cannot be used as it is. The message says why, for the pack's author. */ +public final class PackException extends Exception { + public PackException(String message) { + super(message); + } +} From 7255f0cf970346fac000fa8c05049483cfd580c2 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:12 +0800 Subject: [PATCH 04/61] Read the pack.properties manifest of a content pack --- .../mcheli/content/pack/PackManifest.java | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 common/src/main/java/com/jnhprojects/mcheli/content/pack/PackManifest.java diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/pack/PackManifest.java b/common/src/main/java/com/jnhprojects/mcheli/content/pack/PackManifest.java new file mode 100644 index 0000000..f0824fd --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/content/pack/PackManifest.java @@ -0,0 +1,50 @@ +package com.jnhprojects.mcheli.content.pack; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Properties; +import java.util.regex.Pattern; + +/** + * The {@code pack.properties} file at the top of a content pack: + *
+ * id = rework-example
+ * name = MCHeli Rework example pack
+ * author = JayMar921
+ * version = 1.0.0
+ * description = One small helicopter, to show how a pack is put together.
+ * 
+ * Only {@code id} is required. It names the pack in logs, the allow list and the client cache, so + * it is limited to lower case letters, digits, {@code -}, {@code _} and {@code .}. The other values + * are shown to players and are cut to a sensible length, since servers send them to every client. + */ +public record PackManifest(String id, String name, String author, String version, String description) { + public static final String FILE = "pack.properties"; + + private static final Pattern ID = Pattern.compile("[a-z0-9][a-z0-9_.-]{0,63}"); + + public static PackManifest read(InputStream in) throws IOException, PackException { + Properties p = new Properties(); + p.load(new InputStreamReader(in, StandardCharsets.UTF_8)); + String id = p.getProperty("id", "").trim(); + if (!ID.matcher(id).matches()) { + throw new PackException("%s: id \"%s\" must be 1 to 64 characters of a-z, 0-9, '-', '_' and '.'" + .formatted(FILE, id)); + } + if (id.equals(ContentSource.ORIGINAL)) { + throw new PackException(FILE + ": the id \"original\" is reserved for the imported 1.0.3 content"); + } + return new PackManifest(id, value(p, "name", id, 64), value(p, "author", "", 64), + value(p, "version", "1.0.0", 32), value(p, "description", "", 256)); + } + + private static String value(Properties p, String key, String fallback, int maxLength) { + String value = p.getProperty(key, "").trim(); + if (value.isEmpty()) { + return fallback; + } + return value.length() > maxLength ? value.substring(0, maxLength) : value; + } +} From 3380d69dd2b0ae8d4d18819ba1a9dc08c6b09faa Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:12 +0800 Subject: [PATCH 05/61] Open and check content packs and compute their content hash --- .../mcheli/content/pack/ContentPack.java | 296 ++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 common/src/main/java/com/jnhprojects/mcheli/content/pack/ContentPack.java diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/pack/ContentPack.java b/common/src/main/java/com/jnhprojects/mcheli/content/pack/ContentPack.java new file mode 100644 index 0000000..eed9783 --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/content/pack/ContentPack.java @@ -0,0 +1,296 @@ +package com.jnhprojects.mcheli.content.pack; + +import org.jspecify.annotations.Nullable; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.ProviderNotFoundException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipException; +import java.util.zip.ZipOutputStream; + +/** + * A content pack: vehicle and weapon definitions with their models, textures and sounds, in a zip + * file or a folder, described by a {@link PackManifest}. + *
+ * rework-example.zip
+ *   pack.properties
+ *   assets/mcheli/helicopters/rework-scout.txt
+ *   assets/mcheli/models/helicopters/rework-scout.obj
+ *   assets/mcheli/textures/helicopters/rework-scout.png
+ *   assets/mcheli/sounds/rework-scout.ogg
+ * 
+ * A zip whose files all sit in one folder (what zipping a folder usually gives) works too. + * + *

A pack is data only. Opening one checks that it holds nothing but definitions, models, + * textures and sounds under {@code assets/mcheli}, plus a readme or licence at the top, so a pack + * can never bring code. It also stays within {@link #MAX_FILES} files and + * {@link #MAX_UNPACKED_BYTES} unpacked, counted while reading rather than trusted from the zip, + * so a crafted zip cannot exhaust memory or disk. + * + *

The {@linkplain #hash() content hash} covers the path and bytes of every file rather than the + * zip, so the same files hash the same whether they are zipped, how they are compressed, or kept + * in a folder. Servers and clients compare it to decide whether a pack has to be sent. + * + *

A zip is read in place through a zip file system, which stays open until {@link #close}. + */ +public final class ContentPack implements Closeable { + public static final int MAX_FILES = 10_000; + public static final long MAX_UNPACKED_BYTES = 512L << 20; + + private static final String ASSETS = "assets/mcheli/"; + private static final Set ASSET_TYPES = Set.of("txt", "mqo", "obj", "mtl", "png", "ogg"); + private static final Set TOP_LEVEL_TYPES = Set.of("", "txt", "md"); + + private final Path source; + private final @Nullable FileSystem zip; + private final Path root; + private final PackManifest manifest; + private final List files; + private final String hash; + private byte @Nullable [] folderArchive; + + private ContentPack(Path source, @Nullable FileSystem zip, Path root, PackManifest manifest, List files, String hash) { + this.source = source; + this.zip = zip; + this.root = root; + this.manifest = manifest; + this.files = files; + this.hash = hash; + } + + /** + * Opens and checks the pack in a zip file or folder. + * + * @throws PackException when it is not a usable pack; the message is meant for its author + */ + public static ContentPack open(Path source) throws IOException, PackException { + if (Files.isDirectory(source)) { + return read(source, null, packRoot(source)); + } + FileSystem zip; + try { + zip = FileSystems.newFileSystem(source); + } catch (ZipException | ProviderNotFoundException e) { + throw new PackException("not a zip file"); + } + try { + return read(source, zip, packRoot(zip.getRootDirectories().iterator().next())); + } catch (IOException | PackException | RuntimeException e) { + zip.close(); + throw e; + } + } + + /** The folder holding {@code pack.properties}: the top, or the one folder at the top. */ + private static Path packRoot(Path top) throws IOException { + if (Files.exists(top.resolve(PackManifest.FILE))) { + return top; + } + try (Stream children = Files.list(top)) { + List list = children.filter(p -> !ignored(p.getFileName().toString())).toList(); + if (list.size() == 1 && Files.isRegularFile(list.getFirst().resolve(PackManifest.FILE))) { + return list.getFirst(); + } + } + return top; + } + + private static ContentPack read(Path source, @Nullable FileSystem zip, Path root) throws IOException, PackException { + List files = new ArrayList<>(); + try (Stream walk = Files.walk(root)) { + for (Path file : (Iterable) walk.filter(Files::isRegularFile)::iterator) { + String path = relative(root, file); + if (isIgnored(path)) { + continue; + } + check(path); + files.add(path); + if (files.size() > MAX_FILES) { + throw new PackException("more than %d files".formatted(MAX_FILES)); + } + } + } + Collections.sort(files); + if (!files.contains(PackManifest.FILE)) { + throw new PackException("no " + PackManifest.FILE + " at the top of the pack"); + } + PackManifest manifest; + try (InputStream in = Files.newInputStream(root.resolve(PackManifest.FILE))) { + manifest = PackManifest.read(in); + } + return new ContentPack(source, zip, root, manifest, List.copyOf(files), hash(root, files)); + } + + private static void check(String path) throws PackException { + String name = path.substring(path.lastIndexOf('/') + 1); + int dot = name.lastIndexOf('.'); + String type = dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT); + if (path.equals(name)) { + if (!path.equals(PackManifest.FILE) && !TOP_LEVEL_TYPES.contains(type)) { + throw new PackException(path + ": only " + PackManifest.FILE + ", a readme and a licence may sit at the top of a pack"); + } + return; + } + if (!path.startsWith(ASSETS) || !ASSET_TYPES.contains(type)) { + throw new PackException(path + ": a pack may only hold definitions (.txt), models (.obj, .mqo), textures (.png)" + + " and sounds (.ogg) under " + ASSETS); + } + if (!path.equals(path.toLowerCase(Locale.ROOT))) { + throw new PackException(path + ": file names under " + ASSETS + " must be lower case"); + } + } + + /** Files that operating systems and archivers leave behind, which belong to no pack. */ + private static boolean isIgnored(String path) { + for (String part : path.split("/")) { + if (ignored(part)) { + return true; + } + } + return false; + } + + private static boolean ignored(String name) { + return name.startsWith(".") || name.equals("__MACOSX") || name.equalsIgnoreCase("thumbs.db") + || name.equalsIgnoreCase("desktop.ini"); + } + + private static String relative(Path root, Path file) { + return root.relativize(file).toString().replace(file.getFileSystem().getSeparator(), "/"); + } + + /** + * SHA-256 over each file's path and its own SHA-256, in path order. Hashing each file on its + * own keeps the encoding unambiguous without trusting the sizes a zip claims. + */ + private static String hash(Path root, List files) throws IOException, PackException { + MessageDigest pack = sha256(); + MessageDigest file = sha256(); + byte[] buffer = new byte[8192]; + long total = 0; + for (String path : files) { + try (InputStream in = Files.newInputStream(root.resolve(path))) { + int n; + while ((n = in.read(buffer)) > 0) { + total += n; + if (total > MAX_UNPACKED_BYTES) { + throw new PackException("more than %d MB unpacked".formatted(MAX_UNPACKED_BYTES >> 20)); + } + file.update(buffer, 0, n); + } + } + pack.update(path.getBytes(StandardCharsets.UTF_8)); + pack.update((byte) 0); + pack.update(file.digest()); + } + return HexFormat.of().formatHex(pack.digest()); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Every Java runtime has SHA-256", e); + } + } + + public String id() { + return manifest.id(); + } + + public PackManifest manifest() { + return manifest; + } + + /** Hex SHA-256 of the pack's files; see the class comment. */ + public String hash() { + return hash; + } + + /** The zip file or folder the pack was opened from. */ + public Path source() { + return source; + } + + /** The pack's {@code assets/mcheli} folder, for the definition loader and the runtime assets. */ + public Path assets() { + return root.resolve(ASSETS); + } + + public ContentSource contentSource() { + return new ContentSource(id(), assets()); + } + + /** Paths of the pack's files relative to its top, in order. */ + public List files() { + return files; + } + + /** + * The pack as zip bytes, ready to send: the zip file as it is on disk, or for a folder a zip + * built from it once and kept. A zip is read again on every call and may have changed on disk + * since the pack was opened; check the length against {@link #archiveSize()}. + */ + public synchronized byte[] archive() throws IOException { + if (zip != null) { + return Files.readAllBytes(source); + } + if (folderArchive == null) { + folderArchive = zipFolder(); + } + return folderArchive; + } + + /** The length {@link #archive()} returns now, without reading a zip. */ + public long archiveSize() throws IOException { + return zip != null ? Files.size(source) : archive().length; + } + + private byte[] zipFolder() throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream out = new ZipOutputStream(bytes)) { + for (String path : files) { + ZipEntry entry = new ZipEntry(path); + entry.setTime(0L); + out.putNextEntry(entry); + Files.copy(root.resolve(path), out); + out.closeEntry(); + } + } + return bytes.toByteArray(); + } + + @Override + public void close() throws IOException { + if (zip != null) { + zip.close(); + } + } + + @Override + public String toString() { + return id() + " " + manifest.version() + " (" + source.getFileName() + ")"; + } + + /** The hash as a short string for logs and chat. */ + public static String shortHash(String hash) { + return hash.length() > 12 ? hash.substring(0, 12) : hash; + } +} From c6f9c1062cfc2df836b6b886153c80a9a7015175 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:13 +0800 Subject: [PATCH 06/61] Scan the packs folder and report packs that are left out --- .../mcheli/content/pack/PackFolder.java | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 common/src/main/java/com/jnhprojects/mcheli/content/pack/PackFolder.java diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/pack/PackFolder.java b/common/src/main/java/com/jnhprojects/mcheli/content/pack/PackFolder.java new file mode 100644 index 0000000..77cd6de --- /dev/null +++ b/common/src/main/java/com/jnhprojects/mcheli/content/pack/PackFolder.java @@ -0,0 +1,72 @@ +package com.jnhprojects.mcheli.content.pack; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Stream; + +/** + * Finds the content packs in a folder, {@code /mcheli/packs} in the game: every zip file + * and every folder in it. Other files (a readme, a stray image) are ignored. + * + *

A pack that cannot be used is reported and left out; the scan itself never fails. Two packs + * with the same id cannot both load, so the second in file name order is reported and left out. + */ +public final class PackFolder { + private PackFolder() { + } + + /** + * @param packs the open packs, sorted by id; the caller closes them + * @param problems one entry per pack that was left out + */ + public record Scan(List packs, List problems) { + } + + public static Scan scan(Path folder) { + List entries; + try (Stream list = Files.list(folder)) { + entries = list.filter(PackFolder::isCandidate).sorted().toList(); + } catch (NoSuchFileException e) { + return new Scan(List.of(), List.of()); + } catch (IOException e) { + return new Scan(List.of(), List.of(new PackProblem(folder.getFileName().toString(), "could not be listed: " + e))); + } + + Map byId = new TreeMap<>(); + List problems = new ArrayList<>(); + for (Path entry : entries) { + String fileName = entry.getFileName().toString(); + try { + ContentPack pack = ContentPack.open(entry); + ContentPack clash = byId.get(pack.id()); + if (clash != null) { + problems.add(new PackProblem(fileName, "left out, %s already uses the id \"%s\"" + .formatted(clash.source().getFileName(), pack.id()))); + pack.close(); + } else { + byId.put(pack.id(), pack); + } + } catch (PackException e) { + problems.add(new PackProblem(fileName, e.getMessage())); + } catch (IOException | RuntimeException e) { + problems.add(new PackProblem(fileName, "could not be read: " + e)); + } + } + return new Scan(List.copyOf(byId.values()), List.copyOf(problems)); + } + + private static boolean isCandidate(Path entry) { + String name = entry.getFileName().toString(); + if (name.startsWith(".")) { + return false; + } + return Files.isDirectory(entry) || (name.toLowerCase(Locale.ROOT).endsWith(".zip") && Files.isRegularFile(entry)); + } +} From dc77d393bfa1329c73a185c75aaa39a426c4f89e Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:13 +0800 Subject: [PATCH 07/61] Record which content pack a definition came from --- .../com/jnhprojects/mcheli/content/Definition.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/Definition.java b/common/src/main/java/com/jnhprojects/mcheli/content/Definition.java index a2241dc..7305420 100644 --- a/common/src/main/java/com/jnhprojects/mcheli/content/Definition.java +++ b/common/src/main/java/com/jnhprojects/mcheli/content/Definition.java @@ -14,6 +14,7 @@ public abstract class Definition { private final String name; private final Map> handlers = new HashMap<>(); + private String pack = ""; protected Definition(String name) { this.name = name; @@ -25,6 +26,18 @@ public String name() { return name; } + /** + * Id of the content pack the file came from, {@code original} for the imported 1.0.3 content, + * or empty for a definition built in code. + */ + public String pack() { + return pack; + } + + void setPack(String pack) { + this.pack = pack; + } + protected abstract void registerKeys(KeyTable keys); /** From 02f8bc4966ea26bfac42e63ee9decdc4a72d3b77 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:14 +0800 Subject: [PATCH 08/61] Load definitions from several sources and collect pack problems --- .../mcheli/content/ContentRegistry.java | 198 +++++++++++++----- 1 file changed, 144 insertions(+), 54 deletions(-) diff --git a/common/src/main/java/com/jnhprojects/mcheli/content/ContentRegistry.java b/common/src/main/java/com/jnhprojects/mcheli/content/ContentRegistry.java index deae35d..d42a4d1 100644 --- a/common/src/main/java/com/jnhprojects/mcheli/content/ContentRegistry.java +++ b/common/src/main/java/com/jnhprojects/mcheli/content/ContentRegistry.java @@ -5,6 +5,8 @@ import com.jnhprojects.mcheli.content.aircraft.PlaneDefinition; import com.jnhprojects.mcheli.content.aircraft.TankDefinition; import com.jnhprojects.mcheli.content.aircraft.VehicleDefinition; +import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.content.pack.PackProblem; import com.jnhprojects.mcheli.content.weapon.WeaponDefinition; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; @@ -23,16 +25,22 @@ import java.util.Set; import java.util.TreeMap; import java.util.TreeSet; +import java.util.function.Consumer; import java.util.function.Function; import java.util.stream.Stream; /** * Loaded vehicle and weapon definitions, keyed by file name. * + *

Definitions come from one or more {@linkplain ContentSource sources}: the imported 1.0.3 + * content and the content packs. A definition in a later source replaces the one with the same + * name from an earlier source, whatever its kind. + * *

The registry can change while the game runs: a definition can be added or replaced * ({@link #add}), and the whole set can be swapped for a freshly loaded one ({@link #replaceWith}) - * when an operator reloads the content. Every change bumps {@link #generation()}, which is how - * vehicles already in the world notice that their definition object is stale. + * when an operator reloads the content or a server sends its packs. Every change bumps + * {@link #generation()}, which is how vehicles already in the world notice that their definition + * object is stale. * *

Readers never lock. The maps live in an immutable snapshot that writers copy, change and * publish in one volatile write, so the render thread and the server thread of a single player @@ -50,70 +58,142 @@ private ContentRegistry(Snapshot snapshot) { /** A registry with nothing in it, for when no content could be found. */ public static ContentRegistry empty() { - return new ContentRegistry(new Snapshot(Map.of(), Map.of(), Map.of(), Map.of(), Map.of(), Map.of())); + return new ContentRegistry(new Snapshot(Map.of(), Map.of(), Map.of(), Map.of(), Map.of(), Map.of(), List.of())); } /** + * Loads the imported 1.0.3 content alone. + * * @param assetRoot the {@code assets/mcheli} folder; when it does not exist, nothing is loaded */ - public static ContentRegistry load(Path assetRoot) throws IOException { - Map> unhandled = new TreeMap<>(); - Map weapons = loadFolder(assetRoot.resolve(WeaponDefinition.FOLDER), WeaponDefinition::new, unhandled); - Map helicopters = - loadFolder(assetRoot.resolve(HelicopterDefinition.FOLDER), HelicopterDefinition::new, unhandled); - Map planes = loadFolder(assetRoot.resolve(PlaneDefinition.FOLDER), PlaneDefinition::new, unhandled); - Map tanks = loadFolder(assetRoot.resolve(TankDefinition.FOLDER), TankDefinition::new, unhandled); - Map vehicles = loadFolder(assetRoot.resolve(VehicleDefinition.FOLDER), VehicleDefinition::new, unhandled); - - for (Map kind : List.of(helicopters, planes, tanks, vehicles)) { - for (AircraftDefinition def : kind.values()) { - dropUnknownWeapons(def, weapons); - } - } + public static ContentRegistry load(Path assetRoot) { + return load(List.of(new ContentSource(ContentSource.ORIGINAL, assetRoot))); + } - LOGGER.info("Loaded {} helicopters, {} planes, {} tanks, {} vehicles and {} weapons", - helicopters.size(), planes.size(), tanks.size(), vehicles.size(), weapons.size()); - if (!unhandled.isEmpty()) { - LOGGER.debug("Definition keys not implemented yet: {}", unhandled.keySet()); + /** + * Loads every source, in order. Weapons come first, from all sources, so a vehicle can use a + * weapon from another pack. Never throws: a file or folder that cannot be read is logged and + * listed in {@link #problems()}, and everything else still loads. + */ + public static ContentRegistry load(List sources) { + Loader loader = new Loader(); + for (ContentSource source : sources) { + loader.folder(source, WeaponDefinition.FOLDER, WeaponDefinition::new, loader::weapon); + } + for (ContentSource source : sources) { + loader.folder(source, HelicopterDefinition.FOLDER, HelicopterDefinition::new, loader::vehicle); + loader.folder(source, PlaneDefinition.FOLDER, PlaneDefinition::new, loader::vehicle); + loader.folder(source, TankDefinition.FOLDER, TankDefinition::new, loader::vehicle); + loader.folder(source, VehicleDefinition.FOLDER, VehicleDefinition::new, loader::vehicle); } - return new ContentRegistry(new Snapshot(helicopters, planes, tanks, vehicles, weapons, unhandled)); + return new ContentRegistry(loader.finish(sources.size())); } - private static Map loadFolder( - Path folder, Function factory, Map> unhandled) throws IOException { - Map out = new LinkedHashMap<>(); - if (!Files.isDirectory(folder)) { - return out; + /** Collects definitions across sources; one per {@link #load(List)} call. */ + private static final class Loader { + private final Map weapons = new LinkedHashMap<>(); + private final Map vehicles = new LinkedHashMap<>(); + private final Map> unhandled = new TreeMap<>(); + private final List problems = new ArrayList<>(); + + void folder(ContentSource source, String folder, Function factory, Consumer sink) { + Path dir = source.assets().resolve(folder); + if (!Files.isDirectory(dir)) { + return; + } + + List files; + try (Stream stream = Files.list(dir)) { + files = stream.filter(p -> p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".txt")) + .sorted() + .toList(); + } catch (IOException e) { + problem(source, folder + ": could not be read: " + e); + return; + } + + for (Path file : files) { + String fileName = file.getFileName().toString().toLowerCase(Locale.ROOT); + String name = fileName.substring(0, fileName.length() - 4); + DefinitionReader.Result result; + try { + result = DefinitionReader.read(file, factory.apply(name)); + } catch (IOException e) { + problem(source, folder + "/" + fileName + ": could not be read: " + e); + continue; + } + for (String problem : result.problems()) { + LOGGER.warn("{}: {}/{}: {}", source.pack(), folder, fileName, problem); + // The import's own quirks are EMB4's and known; only pack authors can act on theirs. + if (!source.isOriginal()) { + problems.add(new PackProblem(source.pack(), folder + "/" + fileName + ": " + problem)); + } + } + for (String key : result.unhandledKeys()) { + unhandled.computeIfAbsent(key, k -> new TreeSet<>()).add(name); + } + if (result.isValid()) { + T def = result.definition(); + def.setPack(source.pack()); + sink.accept(def); + } + } } - List files; - try (Stream stream = Files.list(folder)) { - files = stream.filter(p -> p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".txt")) - .sorted() - .toList(); + void weapon(WeaponDefinition weapon) { + replaced(weapons.put(weapon.name(), weapon), weapon, WeaponDefinition.FOLDER); } - for (Path file : files) { - String fileName = file.getFileName().toString().toLowerCase(Locale.ROOT); - String name = fileName.substring(0, fileName.length() - 4); - DefinitionReader.Result result = DefinitionReader.read(file, factory.apply(name)); - for (String problem : result.problems()) { - LOGGER.warn("{}/{}: {}", folder.getFileName(), fileName, problem); + void vehicle(AircraftDefinition def) { + replaced(vehicles.put(def.name(), def), def, def.folder()); + } + + private static void replaced(@Nullable Definition previous, Definition def, String folder) { + if (previous != null && !previous.pack().equals(def.pack())) { + LOGGER.info("{} replaces {}/{} from {}", def.pack(), folder, def.name(), previous.pack()); } - for (String key : result.unhandledKeys()) { - unhandled.computeIfAbsent(key, k -> new TreeSet<>()).add(name); + } + + private void problem(ContentSource source, String message) { + LOGGER.warn("{}: {}", source.pack(), message); + problems.add(new PackProblem(source.pack(), message)); + } + + Snapshot finish(int sources) { + Map helicopters = new LinkedHashMap<>(); + Map planes = new LinkedHashMap<>(); + Map tanks = new LinkedHashMap<>(); + Map other = new LinkedHashMap<>(); + for (AircraftDefinition def : vehicles.values()) { + List dropped = def.retainWeapons(weapons.keySet()); + if (!dropped.isEmpty()) { + // The original silently skipped AddWeapon lines naming a weapon it had not + // loaded, and the shipped AH-64, KV-2 and M26 do that. In a pack it is a typo. + if (def.pack().equals(ContentSource.ORIGINAL)) { + LOGGER.debug("{}/{}: unknown weapons {}", def.folder(), def.name(), dropped); + } else { + LOGGER.warn("{}: {}/{}: unknown weapons {}", def.pack(), def.folder(), def.name(), dropped); + problems.add(new PackProblem(def.pack(), def.folder() + "/" + def.name() + ": unknown weapons " + dropped)); + } + } + switch (def) { + case HelicopterDefinition h -> helicopters.put(h.name(), h); + case PlaneDefinition p -> planes.put(p.name(), p); + case TankDefinition t -> tanks.put(t.name(), t); + case VehicleDefinition v -> other.put(v.name(), v); + default -> throw new IllegalArgumentException("Unknown vehicle kind " + def.kind()); + } } - if (result.isValid()) { - out.put(name, result.definition()); + + LOGGER.info("Loaded {} helicopters, {} planes, {} tanks, {} vehicles and {} weapons from {} sources", + helicopters.size(), planes.size(), tanks.size(), other.size(), weapons.size(), sources); + if (!unhandled.isEmpty()) { + LOGGER.debug("Definition keys not implemented yet: {}", unhandled.keySet()); } + return new Snapshot(helicopters, planes, tanks, other, weapons, unhandled, problems); } - return out; } - /** - * The original silently skipped AddWeapon lines naming a weapon it had not loaded. The shipped - * AH-64, KV-2 and M26 do that, so it is only worth a debug line. - */ private static void dropUnknownWeapons(AircraftDefinition def, Map weapons) { List dropped = def.retainWeapons(weapons.keySet()); if (!dropped.isEmpty()) { @@ -240,10 +320,19 @@ public Map> unhandledKeys() { return snapshot.unhandledKeys; } + /** + * What went wrong loading the content packs: files that could not be read, rejected + * definitions, bad lines and unknown weapons. The import's own known quirks are only logged. + */ + public List problems() { + return snapshot.problems; + } + /** One consistent state of the registry. Never changed once published. */ private record Snapshot(Map helicopters, Map planes, Map tanks, Map vehicles, - Map weapons, Map> unhandledKeys) { + Map weapons, Map> unhandledKeys, + List problems) { Snapshot { helicopters = Collections.unmodifiableMap(helicopters); planes = Collections.unmodifiableMap(planes); @@ -251,6 +340,7 @@ private record Snapshot(Map helicopters, Map helicopters, Map map) { - return new Snapshot(map, planes, tanks, vehicles, weapons, unhandledKeys); + return new Snapshot(map, planes, tanks, vehicles, weapons, unhandledKeys, problems); } Snapshot withPlanes(Map map) { - return new Snapshot(helicopters, map, tanks, vehicles, weapons, unhandledKeys); + return new Snapshot(helicopters, map, tanks, vehicles, weapons, unhandledKeys, problems); } Snapshot withTanks(Map map) { - return new Snapshot(helicopters, planes, map, vehicles, weapons, unhandledKeys); + return new Snapshot(helicopters, planes, map, vehicles, weapons, unhandledKeys, problems); } Snapshot withVehicles(Map map) { - return new Snapshot(helicopters, planes, tanks, map, weapons, unhandledKeys); + return new Snapshot(helicopters, planes, tanks, map, weapons, unhandledKeys, problems); } Snapshot withWeapons(Map map) { - return new Snapshot(helicopters, planes, tanks, vehicles, map, unhandledKeys); + return new Snapshot(helicopters, planes, tanks, vehicles, map, unhandledKeys, problems); } private static Map remove(Map map, String name) { From 4bdc0a95995cf202b41d6d2d008e0af72c0d2eec Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:14 +0800 Subject: [PATCH 09/61] Test loading definitions from several packs and reporting problems --- .../mcheli/content/ContentRegistryTest.java | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/common/src/test/java/com/jnhprojects/mcheli/content/ContentRegistryTest.java b/common/src/test/java/com/jnhprojects/mcheli/content/ContentRegistryTest.java index 4d774a5..dd1b751 100644 --- a/common/src/test/java/com/jnhprojects/mcheli/content/ContentRegistryTest.java +++ b/common/src/test/java/com/jnhprojects/mcheli/content/ContentRegistryTest.java @@ -2,6 +2,8 @@ import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; import com.jnhprojects.mcheli.content.aircraft.TankDefinition; +import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.content.pack.PackProblem; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -87,4 +89,48 @@ void readersKeepTheirViewDuringAChange() { assertEquals(1, view.size()); assertEquals(2, registry.helicopters().size()); } + + @Test + void aLaterSourceReplacesADefinitionOfAnyKind(@TempDir Path dir) throws IOException { + Path first = dir.resolve("first"); + Path second = dir.resolve("second"); + write(first.resolve(HelicopterDefinition.FOLDER).resolve("mule.txt"), "DisplayName = Mule\nAddSeat = 0, 1, 0\n"); + write(first.resolve(HelicopterDefinition.FOLDER).resolve("scout.txt"), "DisplayName = Scout\nAddSeat = 0, 1, 0\n"); + write(second.resolve(TankDefinition.FOLDER).resolve("mule.txt"), "DisplayName = Mule tank\nAddSeat = 0, 1, 0\n"); + + ContentRegistry registry = ContentRegistry.load(List.of( + new ContentSource(ContentSource.ORIGINAL, first), new ContentSource("tanks", second))); + + assertNull(registry.helicopter("mule")); + assertEquals("tanks", registry.tank("mule").pack()); + assertEquals(ContentSource.ORIGINAL, registry.helicopter("scout").pack()); + assertTrue(registry.problems().isEmpty()); + } + + @Test + void problemsInAPackAreReportedButTheRestLoads(@TempDir Path dir) throws IOException { + Path pack = dir.resolve("pack"); + write(pack.resolve(HelicopterDefinition.FOLDER).resolve("good.txt"), + "DisplayName = Good\nAddSeat = 0, 1, 0\nAddWeapon = no_such_gun, 0, 0, 0\n"); + write(pack.resolve(HelicopterDefinition.FOLDER).resolve("bad.txt"), "DisplayName = Bad\nMaxHp = lots\n"); + + ContentRegistry registry = ContentRegistry.load(List.of(new ContentSource("mine", pack))); + + assertEquals("mine", registry.helicopter("good").pack()); + List messages = registry.problems().stream().map(PackProblem::toString).toList(); + assertTrue(messages.stream().anyMatch(m -> m.startsWith("mine: helicopters/bad.txt: line 2 (maxhp)")), messages::toString); + assertTrue(messages.stream().anyMatch(m -> m.contains("helicopters/good: unknown weapons [no_such_gun]")), messages::toString); + } + + @Test + void aMissingFolderLoadsNothing(@TempDir Path dir) { + ContentRegistry registry = ContentRegistry.load(List.of(new ContentSource("gone", dir.resolve("gone")))); + assertTrue(registry.all().isEmpty()); + assertTrue(registry.problems().isEmpty()); + } + + private static void write(Path file, String text) throws IOException { + Files.createDirectories(file.getParent()); + Files.writeString(file, text); + } } From 1329bb96e4028a77ff4c39949da51e76e19abc6e Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:15 +0800 Subject: [PATCH 10/61] Test opening, checking and hashing content packs --- .../mcheli/content/pack/ContentPackTest.java | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 common/src/test/java/com/jnhprojects/mcheli/content/pack/ContentPackTest.java diff --git a/common/src/test/java/com/jnhprojects/mcheli/content/pack/ContentPackTest.java b/common/src/test/java/com/jnhprojects/mcheli/content/pack/ContentPackTest.java new file mode 100644 index 0000000..c35e5e1 --- /dev/null +++ b/common/src/test/java/com/jnhprojects/mcheli/content/pack/ContentPackTest.java @@ -0,0 +1,151 @@ +package com.jnhprojects.mcheli.content.pack; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Opening, checking and hashing packs, in folders and zips built by the tests themselves. */ +class ContentPackTest { + private static final String MANIFEST = "id = scouts\nname = Scouts\nauthor = Test\nversion = 2.1\n"; + + @Test + void aFolderAndItsZipHaveTheSameHash(@TempDir Path dir) throws Exception { + Map files = pack(); + Path folder = folder(dir.resolve("scouts"), files); + Path zip = zip(dir.resolve("scouts.zip"), "", files); + + try (ContentPack fromFolder = ContentPack.open(folder); ContentPack fromZip = ContentPack.open(zip)) { + assertEquals("scouts", fromFolder.id()); + assertEquals("2.1", fromZip.manifest().version()); + assertEquals(fromFolder.hash(), fromZip.hash()); + assertEquals(List.copyOf(files.keySet()), fromZip.files()); + assertTrue(Files.isRegularFile(fromZip.assets().resolve("helicopters/scout.txt"))); + } + } + + @Test + void aZipWrappingOneFolderIsReadFromThatFolder(@TempDir Path dir) throws Exception { + Map files = pack(); + try (ContentPack flat = ContentPack.open(zip(dir.resolve("flat.zip"), "", files)); + ContentPack wrapped = ContentPack.open(zip(dir.resolve("wrapped.zip"), "scouts-1.0/", files))) { + assertEquals(flat.hash(), wrapped.hash()); + } + } + + @Test + void theHashFollowsTheContent(@TempDir Path dir) throws Exception { + Map files = pack(); + Map edited = new TreeMap<>(files); + edited.put("assets/mcheli/helicopters/scout.txt", "DisplayName = Scout Mk2\n"); + try (ContentPack a = ContentPack.open(folder(dir.resolve("a"), files)); + ContentPack b = ContentPack.open(folder(dir.resolve("b"), edited))) { + assertNotEquals(a.hash(), b.hash()); + } + } + + @Test + void theArchiveOfAFolderOpensToTheSamePack(@TempDir Path dir) throws Exception { + try (ContentPack folder = ContentPack.open(folder(dir.resolve("scouts"), pack()))) { + byte[] archive = folder.archive(); + assertEquals(archive.length, folder.archiveSize()); + Path copy = Files.write(dir.resolve("copy.zip"), archive); + try (ContentPack zipped = ContentPack.open(copy)) { + assertEquals(folder.hash(), zipped.hash()); + } + } + } + + @Test + void systemFilesAreIgnored(@TempDir Path dir) throws Exception { + Map files = pack(); + Map messy = new TreeMap<>(files); + messy.put(".DS_Store", "x"); + messy.put("assets/mcheli/textures/Thumbs.db", "x"); + try (ContentPack clean = ContentPack.open(folder(dir.resolve("clean"), files)); + ContentPack withJunk = ContentPack.open(folder(dir.resolve("messy"), messy))) { + assertEquals(clean.hash(), withJunk.hash()); + } + } + + @Test + void anythingButDataIsRefused(@TempDir Path dir) throws Exception { + assertRefused(dir, "assets/mcheli/Evil.class", "only hold definitions"); + assertRefused(dir, "mod.jar", "may sit at the top"); + assertRefused(dir, "data/mcheli/recipe/scout.json", "only hold definitions"); + assertRefused(dir, "assets/mcheli/textures/helicopters/Scout.png", "lower case"); + } + + @Test + void aPackNeedsAManifestWithAnId(@TempDir Path dir) throws Exception { + Map files = pack(); + files.remove(PackManifest.FILE); + PackException missing = assertThrows(PackException.class, () -> ContentPack.open(folder(dir.resolve("a"), files))); + assertTrue(missing.getMessage().contains("no pack.properties")); + + files.put(PackManifest.FILE, "id = Not Valid\n"); + PackException invalid = assertThrows(PackException.class, () -> ContentPack.open(folder(dir.resolve("b"), files))); + assertTrue(invalid.getMessage().contains("must be 1 to 64 characters")); + + files.put(PackManifest.FILE, "id = original\n"); + PackException reserved = assertThrows(PackException.class, () -> ContentPack.open(folder(dir.resolve("c"), files))); + assertTrue(reserved.getMessage().contains("reserved")); + } + + @Test + void aFileThatIsNoZipIsRefused(@TempDir Path dir) throws IOException { + Path file = Files.writeString(dir.resolve("broken.zip"), "not a zip"); + PackException e = assertThrows(PackException.class, () -> ContentPack.open(file)); + assertEquals("not a zip file", e.getMessage()); + } + + private static void assertRefused(Path dir, String extraFile, String expected) throws IOException { + Map files = pack(); + files.put(extraFile, "x"); + Path folder = folder(dir.resolve("refused-" + Math.abs(extraFile.hashCode())), files); + PackException e = assertThrows(PackException.class, () -> ContentPack.open(folder)); + assertTrue(e.getMessage().startsWith(extraFile + ": ") && e.getMessage().contains(expected), e.getMessage()); + } + + static Map pack() { + Map files = new TreeMap<>(); + files.put(PackManifest.FILE, MANIFEST); + files.put("README.md", "Scouts\n"); + files.put("assets/mcheli/helicopters/scout.txt", "DisplayName = Scout\nAddSeat = 0, 1, 0\n"); + files.put("assets/mcheli/models/helicopters/scout.obj", "o $body\nv 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n"); + return files; + } + + static Path folder(Path folder, Map files) throws IOException { + for (Map.Entry file : files.entrySet()) { + Path path = folder.resolve(file.getKey()); + Files.createDirectories(path.getParent()); + Files.writeString(path, file.getValue()); + } + return folder; + } + + static Path zip(Path zip, String prefix, Map files) throws IOException { + try (OutputStream out = Files.newOutputStream(zip); ZipOutputStream zipOut = new ZipOutputStream(out)) { + for (Map.Entry file : files.entrySet()) { + zipOut.putNextEntry(new ZipEntry(prefix + file.getKey())); + zipOut.write(file.getValue().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + zipOut.closeEntry(); + } + } + return zip; + } +} From 1f5d28a242c9569ed775445e3ea390317c4f3d70 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:15 +0800 Subject: [PATCH 11/61] Test scanning the packs folder --- .../mcheli/content/pack/PackFolderTest.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 common/src/test/java/com/jnhprojects/mcheli/content/pack/PackFolderTest.java diff --git a/common/src/test/java/com/jnhprojects/mcheli/content/pack/PackFolderTest.java b/common/src/test/java/com/jnhprojects/mcheli/content/pack/PackFolderTest.java new file mode 100644 index 0000000..1d7d4e1 --- /dev/null +++ b/common/src/test/java/com/jnhprojects/mcheli/content/pack/PackFolderTest.java @@ -0,0 +1,47 @@ +package com.jnhprojects.mcheli.content.pack; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +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.TreeMap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Scanning {@code mcheli/packs}: bad packs are reported and left out, never fatal. */ +class PackFolderTest { + + @Test + void loadsGoodPacksAndReportsTheRest(@TempDir Path dir) throws IOException { + Map other = new TreeMap<>(ContentPackTest.pack()); + other.put(PackManifest.FILE, "id = another\n"); + ContentPackTest.folder(dir.resolve("b-scouts"), ContentPackTest.pack()); + ContentPackTest.zip(dir.resolve("a-another.zip"), "", other); + ContentPackTest.zip(dir.resolve("c-scouts-copy.zip"), "", ContentPackTest.pack()); + Files.writeString(dir.resolve("d-broken.zip"), "not a zip"); + Files.writeString(dir.resolve("notes.txt"), "not a pack"); + + PackFolder.Scan scan = PackFolder.scan(dir); + try { + assertEquals(List.of("another", "scouts"), scan.packs().stream().map(ContentPack::id).toList()); + assertEquals(List.of("c-scouts-copy.zip", "d-broken.zip"), scan.problems().stream().map(PackProblem::pack).toList()); + assertTrue(scan.problems().getFirst().message().contains("b-scouts")); + } finally { + for (ContentPack pack : scan.packs()) { + pack.close(); + } + } + } + + @Test + void aMissingFolderHasNoPacks(@TempDir Path dir) { + PackFolder.Scan scan = PackFolder.scan(dir.resolve("packs")); + assertTrue(scan.packs().isEmpty()); + assertTrue(scan.problems().isEmpty()); + } +} From e7d39c4e92bc114ea0b39660181ef5dd04c67f5f Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:16 +0800 Subject: [PATCH 12/61] Add the manifest of the example content pack --- examples/rework-example-pack/pack.properties | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 examples/rework-example-pack/pack.properties diff --git a/examples/rework-example-pack/pack.properties b/examples/rework-example-pack/pack.properties new file mode 100644 index 0000000..c8a519e --- /dev/null +++ b/examples/rework-example-pack/pack.properties @@ -0,0 +1,6 @@ +# The manifest of a content pack. Only id is required; see docs/content/packs.md. +id = rework-example +name = MCHeli Rework example pack +author = JayMar921 +version = 1.0.0 +description = One small helicopter, to show how a content pack is put together. From 2d3027a7984017f4863e99b4e2b7c8a5c31ff1f5 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:16 +0800 Subject: [PATCH 13/61] Add a readme to the example content pack --- examples/rework-example-pack/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 examples/rework-example-pack/README.md diff --git a/examples/rework-example-pack/README.md b/examples/rework-example-pack/README.md new file mode 100644 index 0000000..bb40279 --- /dev/null +++ b/examples/rework-example-pack/README.md @@ -0,0 +1,10 @@ +# MCHeli Rework example pack + +A content pack with one vehicle, the **Rework Scout**, a two seat light helicopter with two paint +schemes. Everything in it was made for MCHeli Rework (none of it comes from the original 1.0.3 +content), and it is meant as the starting point for your own pack. + +To try it, copy this folder (or a zip of it) into `/mcheli/packs` and start the game, or +run `/mcheli reload` in a world. The Scout appears in the MCHeli Helicopters creative tab. + +How packs work, and what each file does: [docs/content/packs.md](../../docs/content/packs.md). From 401155bb1fd03630dfce9167063529426bdc8b5b Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:17 +0800 Subject: [PATCH 14/61] Add the Rework Scout definition to the example pack --- .../mcheli/helicopters/rework-scout.txt | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 examples/rework-example-pack/assets/mcheli/helicopters/rework-scout.txt diff --git a/examples/rework-example-pack/assets/mcheli/helicopters/rework-scout.txt b/examples/rework-example-pack/assets/mcheli/helicopters/rework-scout.txt new file mode 100644 index 0000000..f463293 --- /dev/null +++ b/examples/rework-example-pack/assets/mcheli/helicopters/rework-scout.txt @@ -0,0 +1,27 @@ +// Rework Scout: a two seat light helicopter, the vehicle of the MCHeli Rework example pack. +// The keys are the same as in the original definitions; see docs/content/aircraft.md. + +DisplayName = Rework Scout +AddDisplayName = ja_JP, リワーク スカウト +Category = C + +MaxHp = 60 +Speed = 0.65 +MaxFuel = 500 +FuelConsumption = 0.5 + +// Civilian HUD from the imported content. Without it the game shows a plain text readout. +HUD = civilian_aircraft, none + +// The default paint is textures/helicopters/rework-scout.png; this adds a second scheme. +AddTexture = rework-scout-rescue + +// Main rotor: two copies of $blade0, 180 degrees apart, turning about Y at the mast. +AddRotor = 2, 180, 0.00, 2.05, 0.20, 0.0, 1.0, 0.0 +// Tail rotor: two copies of $blade1 turning about X beside the fin. +AddRotor = 2, 180, 0.10, 1.60, -3.55, 1.0, 0.0, 0.0 + +AddSeat = 0.28, 0.62, 0.20 +AddSeat = -0.28, 0.62, 0.20 + +BoundingBox = 0.0, 1.0, 0.2, 1.4, 1.6 From 074d26ab6741a1b331ad6435de3529ec25ed69ce Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:17 +0800 Subject: [PATCH 15/61] Add the Rework Scout model to the example pack --- .../models/helicopters/rework-scout.obj | 1265 +++++++++++++++++ 1 file changed, 1265 insertions(+) create mode 100644 examples/rework-example-pack/assets/mcheli/models/helicopters/rework-scout.obj diff --git a/examples/rework-example-pack/assets/mcheli/models/helicopters/rework-scout.obj b/examples/rework-example-pack/assets/mcheli/models/helicopters/rework-scout.obj new file mode 100644 index 0000000..3222b60 --- /dev/null +++ b/examples/rework-example-pack/assets/mcheli/models/helicopters/rework-scout.obj @@ -0,0 +1,1265 @@ +# Rework Scout, a two seat light helicopter. Made for the MCHeli Rework example pack. +# Nose along +Z, Y up, 1 unit = 1 block, origin on the ground under the middle. +o $body +v 0.6000 0.4500 -0.5000 +v -0.6000 0.4500 -0.5000 +v -0.6000 1.7000 -0.5000 +v 0.6000 1.7000 -0.5000 +vt 0.00781 0.50781 +vt 0.49219 0.50781 +vt 0.49219 0.99219 +vt 0.00781 0.99219 +vn 0.0000 0.0000 -1.0000 +f 1/1/1 2/2/1 3/3/1 4/4/1 +v -0.5500 0.4500 1.0000 +v 0.5500 0.4500 1.0000 +v 0.4500 1.6000 0.5500 +v -0.4500 1.6000 0.5500 +vt 0.75781 0.75781 +vt 0.99219 0.75781 +vt 0.99219 0.99219 +vt 0.75781 0.99219 +vn -0.0000 0.3644 0.9312 +f 5/5/2 6/6/2 7/7/2 8/8/2 +v -0.6000 1.7000 -0.5000 +v -0.4500 1.6000 0.5500 +v 0.4500 1.6000 0.5500 +v 0.6000 1.7000 -0.5000 +vt 0.00781 0.50781 +vt 0.49219 0.50781 +vt 0.49219 0.99219 +vt 0.00781 0.99219 +vn 0.0000 0.9955 0.0948 +f 9/9/3 10/10/3 11/11/3 12/12/3 +v -0.6000 0.4500 -0.5000 +v 0.6000 0.4500 -0.5000 +v 0.5500 0.4500 1.0000 +v -0.5500 0.4500 1.0000 +vt 0.00781 0.50781 +vt 0.49219 0.50781 +vt 0.49219 0.99219 +vt 0.00781 0.99219 +vn 0.0000 -1.0000 0.0000 +f 13/13/4 14/14/4 15/15/4 16/16/4 +v -0.6000 0.4500 -0.5000 +v -0.5500 0.4500 1.0000 +v -0.4500 1.6000 0.5500 +v -0.6000 1.7000 -0.5000 +vt 0.75781 0.75781 +vt 0.99219 0.75781 +vt 0.99219 0.99219 +vt 0.75781 0.99219 +vn -0.9945 0.0994 0.0331 +f 17/17/5 18/18/5 19/19/5 20/20/5 +v 0.5500 0.4500 1.0000 +v 0.6000 0.4500 -0.5000 +v 0.6000 1.7000 -0.5000 +v 0.4500 1.6000 0.5500 +vt 0.75781 0.75781 +vt 0.99219 0.75781 +vt 0.99219 0.99219 +vt 0.75781 0.99219 +vn 0.9994 0.0000 0.0333 +f 21/21/6 22/22/6 23/23/6 24/24/6 +v 0.5600 1.6600 -0.4600 +v -0.5600 1.6600 -0.4600 +v -0.5600 0.4900 -0.4600 +v 0.5600 0.4900 -0.4600 +vt 0.75781 0.50781 +vt 0.99219 0.50781 +vt 0.99219 0.74219 +vt 0.75781 0.74219 +vn 0.0000 0.0000 1.0000 +f 25/25/7 26/26/7 27/27/7 28/28/7 +v -0.4100 1.5600 0.5100 +v 0.4100 1.5600 0.5100 +v 0.5100 0.4900 0.9600 +v -0.5100 0.4900 0.9600 +vt 0.75781 0.50781 +vt 0.99219 0.50781 +vt 0.99219 0.74219 +vt 0.75781 0.74219 +vn 0.0000 -0.3877 -0.9218 +f 29/29/8 30/30/8 31/31/8 32/32/8 +v 0.5600 1.6600 -0.4600 +v 0.4100 1.5600 0.5100 +v -0.4100 1.5600 0.5100 +v -0.5600 1.6600 -0.4600 +vt 0.75781 0.50781 +vt 0.99219 0.50781 +vt 0.99219 0.74219 +vt 0.75781 0.74219 +vn 0.0000 -0.9947 -0.1025 +f 33/33/9 34/34/9 35/35/9 36/36/9 +v -0.5100 0.4900 0.9600 +v 0.5100 0.4900 0.9600 +v 0.5600 0.4900 -0.4600 +v -0.5600 0.4900 -0.4600 +vt 0.75781 0.50781 +vt 0.99219 0.50781 +vt 0.99219 0.74219 +vt 0.75781 0.74219 +vn -0.0000 1.0000 0.0000 +f 37/37/10 38/38/10 39/39/10 40/40/10 +v -0.5600 1.6600 -0.4600 +v -0.4100 1.5600 0.5100 +v -0.5100 0.4900 0.9600 +v -0.5600 0.4900 -0.4600 +vt 0.75781 0.50781 +vt 0.99219 0.50781 +vt 0.99219 0.74219 +vt 0.75781 0.74219 +vn 0.9727 -0.1612 -0.1670 +f 41/41/11 42/42/11 43/43/11 44/44/11 +v 0.4100 1.5600 0.5100 +v 0.5600 1.6600 -0.4600 +v 0.5600 0.4900 -0.4600 +v 0.5100 0.4900 0.9600 +vt 0.75781 0.50781 +vt 0.99219 0.50781 +vt 0.99219 0.74219 +vt 0.75781 0.74219 +vn -0.9883 0.0000 -0.1528 +f 45/45/12 46/46/12 47/47/12 48/48/12 +v 0.4800 0.5000 0.0000 +v 0.0800 0.5000 0.0000 +v 0.0800 0.7500 0.0000 +v 0.4800 0.7500 0.0000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 -1.0000 +f 49/49/13 50/50/13 51/51/13 52/52/13 +v 0.0800 0.5000 0.4000 +v 0.4800 0.5000 0.4000 +v 0.4800 0.7500 0.4000 +v 0.0800 0.7500 0.4000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 1.0000 +f 53/53/14 54/54/14 55/55/14 56/56/14 +v 0.0800 0.7500 0.0000 +v 0.0800 0.7500 0.4000 +v 0.4800 0.7500 0.4000 +v 0.4800 0.7500 0.0000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 1.0000 0.0000 +f 57/57/15 58/58/15 59/59/15 60/60/15 +v 0.0800 0.5000 0.0000 +v 0.4800 0.5000 0.0000 +v 0.4800 0.5000 0.4000 +v 0.0800 0.5000 0.4000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 -1.0000 0.0000 +f 61/61/16 62/62/16 63/63/16 64/64/16 +v 0.0800 0.5000 0.0000 +v 0.0800 0.5000 0.4000 +v 0.0800 0.7500 0.4000 +v 0.0800 0.7500 0.0000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn -1.0000 0.0000 0.0000 +f 65/65/17 66/66/17 67/67/17 68/68/17 +v 0.4800 0.5000 0.4000 +v 0.4800 0.5000 0.0000 +v 0.4800 0.7500 0.0000 +v 0.4800 0.7500 0.4000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 1.0000 0.0000 0.0000 +f 69/69/18 70/70/18 71/71/18 72/72/18 +v -0.0800 0.5000 0.0000 +v -0.4800 0.5000 0.0000 +v -0.4800 0.7500 0.0000 +v -0.0800 0.7500 0.0000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 -1.0000 +f 73/73/19 74/74/19 75/75/19 76/76/19 +v -0.4800 0.5000 0.4000 +v -0.0800 0.5000 0.4000 +v -0.0800 0.7500 0.4000 +v -0.4800 0.7500 0.4000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 1.0000 +f 77/77/20 78/78/20 79/79/20 80/80/20 +v -0.4800 0.7500 0.0000 +v -0.4800 0.7500 0.4000 +v -0.0800 0.7500 0.4000 +v -0.0800 0.7500 0.0000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 1.0000 0.0000 +f 81/81/21 82/82/21 83/83/21 84/84/21 +v -0.4800 0.5000 0.0000 +v -0.0800 0.5000 0.0000 +v -0.0800 0.5000 0.4000 +v -0.4800 0.5000 0.4000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 -1.0000 0.0000 +f 85/85/22 86/86/22 87/87/22 88/88/22 +v -0.4800 0.5000 0.0000 +v -0.4800 0.5000 0.4000 +v -0.4800 0.7500 0.4000 +v -0.4800 0.7500 0.0000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn -1.0000 0.0000 0.0000 +f 89/89/23 90/90/23 91/91/23 92/92/23 +v -0.0800 0.5000 0.4000 +v -0.0800 0.5000 0.0000 +v -0.0800 0.7500 0.0000 +v -0.0800 0.7500 0.4000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 1.0000 0.0000 0.0000 +f 93/93/24 94/94/24 95/95/24 96/96/24 +v 0.4800 0.7500 -0.0500 +v 0.0800 0.7500 -0.0500 +v 0.0800 1.2500 -0.0500 +v 0.4800 1.2500 -0.0500 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 -1.0000 +f 97/97/25 98/98/25 99/99/25 100/100/25 +v 0.0800 0.7500 0.0500 +v 0.4800 0.7500 0.0500 +v 0.4800 1.2500 0.0500 +v 0.0800 1.2500 0.0500 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 1.0000 +f 101/101/26 102/102/26 103/103/26 104/104/26 +v 0.0800 1.2500 -0.0500 +v 0.0800 1.2500 0.0500 +v 0.4800 1.2500 0.0500 +v 0.4800 1.2500 -0.0500 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 1.0000 0.0000 +f 105/105/27 106/106/27 107/107/27 108/108/27 +v 0.0800 0.7500 -0.0500 +v 0.4800 0.7500 -0.0500 +v 0.4800 0.7500 0.0500 +v 0.0800 0.7500 0.0500 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 -1.0000 0.0000 +f 109/109/28 110/110/28 111/111/28 112/112/28 +v 0.0800 0.7500 -0.0500 +v 0.0800 0.7500 0.0500 +v 0.0800 1.2500 0.0500 +v 0.0800 1.2500 -0.0500 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn -1.0000 0.0000 0.0000 +f 113/113/29 114/114/29 115/115/29 116/116/29 +v 0.4800 0.7500 0.0500 +v 0.4800 0.7500 -0.0500 +v 0.4800 1.2500 -0.0500 +v 0.4800 1.2500 0.0500 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 1.0000 0.0000 0.0000 +f 117/117/30 118/118/30 119/119/30 120/120/30 +v -0.0800 0.7500 -0.0500 +v -0.4800 0.7500 -0.0500 +v -0.4800 1.2500 -0.0500 +v -0.0800 1.2500 -0.0500 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 -1.0000 +f 121/121/31 122/122/31 123/123/31 124/124/31 +v -0.4800 0.7500 0.0500 +v -0.0800 0.7500 0.0500 +v -0.0800 1.2500 0.0500 +v -0.4800 1.2500 0.0500 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 1.0000 +f 125/125/32 126/126/32 127/127/32 128/128/32 +v -0.4800 1.2500 -0.0500 +v -0.4800 1.2500 0.0500 +v -0.0800 1.2500 0.0500 +v -0.0800 1.2500 -0.0500 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 1.0000 0.0000 +f 129/129/33 130/130/33 131/131/33 132/132/33 +v -0.4800 0.7500 -0.0500 +v -0.0800 0.7500 -0.0500 +v -0.0800 0.7500 0.0500 +v -0.4800 0.7500 0.0500 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 -1.0000 0.0000 +f 133/133/34 134/134/34 135/135/34 136/136/34 +v -0.4800 0.7500 -0.0500 +v -0.4800 0.7500 0.0500 +v -0.4800 1.2500 0.0500 +v -0.4800 1.2500 -0.0500 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn -1.0000 0.0000 0.0000 +f 137/137/35 138/138/35 139/139/35 140/140/35 +v -0.0800 0.7500 0.0500 +v -0.0800 0.7500 -0.0500 +v -0.0800 1.2500 -0.0500 +v -0.0800 1.2500 0.0500 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 1.0000 0.0000 0.0000 +f 141/141/36 142/142/36 143/143/36 144/144/36 +v 0.3500 1.7000 -0.5000 +v -0.3500 1.7000 -0.5000 +v -0.3500 1.9000 -0.5000 +v 0.3500 1.9000 -0.5000 +vt 0.00781 0.50781 +vt 0.49219 0.50781 +vt 0.49219 0.99219 +vt 0.00781 0.99219 +vn 0.0000 0.0000 -1.0000 +f 145/145/37 146/146/37 147/147/37 148/148/37 +v -0.3500 1.7000 0.3000 +v 0.3500 1.7000 0.3000 +v 0.3500 1.9000 0.3000 +v -0.3500 1.9000 0.3000 +vt 0.00781 0.50781 +vt 0.49219 0.50781 +vt 0.49219 0.99219 +vt 0.00781 0.99219 +vn 0.0000 0.0000 1.0000 +f 149/149/38 150/150/38 151/151/38 152/152/38 +v -0.3500 1.9000 -0.5000 +v -0.3500 1.9000 0.3000 +v 0.3500 1.9000 0.3000 +v 0.3500 1.9000 -0.5000 +vt 0.00781 0.50781 +vt 0.49219 0.50781 +vt 0.49219 0.99219 +vt 0.00781 0.99219 +vn 0.0000 1.0000 0.0000 +f 153/153/39 154/154/39 155/155/39 156/156/39 +v -0.3500 1.7000 -0.5000 +v 0.3500 1.7000 -0.5000 +v 0.3500 1.7000 0.3000 +v -0.3500 1.7000 0.3000 +vt 0.00781 0.50781 +vt 0.49219 0.50781 +vt 0.49219 0.99219 +vt 0.00781 0.99219 +vn 0.0000 -1.0000 0.0000 +f 157/157/40 158/158/40 159/159/40 160/160/40 +v -0.3500 1.7000 -0.5000 +v -0.3500 1.7000 0.3000 +v -0.3500 1.9000 0.3000 +v -0.3500 1.9000 -0.5000 +vt 0.00781 0.50781 +vt 0.49219 0.50781 +vt 0.49219 0.99219 +vt 0.00781 0.99219 +vn -1.0000 0.0000 0.0000 +f 161/161/41 162/162/41 163/163/41 164/164/41 +v 0.3500 1.7000 0.3000 +v 0.3500 1.7000 -0.5000 +v 0.3500 1.9000 -0.5000 +v 0.3500 1.9000 0.3000 +vt 0.00781 0.50781 +vt 0.49219 0.50781 +vt 0.49219 0.99219 +vt 0.00781 0.99219 +vn 1.0000 0.0000 0.0000 +f 165/165/42 166/166/42 167/167/42 168/168/42 +v 0.0700 1.9000 0.1300 +v -0.0700 1.9000 0.1300 +v -0.0700 2.0500 0.1300 +v 0.0700 2.0500 0.1300 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 -1.0000 +f 169/169/43 170/170/43 171/171/43 172/172/43 +v -0.0700 1.9000 0.2700 +v 0.0700 1.9000 0.2700 +v 0.0700 2.0500 0.2700 +v -0.0700 2.0500 0.2700 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 1.0000 +f 173/173/44 174/174/44 175/175/44 176/176/44 +v -0.0700 2.0500 0.1300 +v -0.0700 2.0500 0.2700 +v 0.0700 2.0500 0.2700 +v 0.0700 2.0500 0.1300 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 1.0000 0.0000 +f 177/177/45 178/178/45 179/179/45 180/180/45 +v -0.0700 1.9000 0.1300 +v 0.0700 1.9000 0.1300 +v 0.0700 1.9000 0.2700 +v -0.0700 1.9000 0.2700 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 -1.0000 0.0000 +f 181/181/46 182/182/46 183/183/46 184/184/46 +v -0.0700 1.9000 0.1300 +v -0.0700 1.9000 0.2700 +v -0.0700 2.0500 0.2700 +v -0.0700 2.0500 0.1300 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn -1.0000 0.0000 0.0000 +f 185/185/47 186/186/47 187/187/47 188/188/47 +v 0.0700 1.9000 0.2700 +v 0.0700 1.9000 0.1300 +v 0.0700 2.0500 0.1300 +v 0.0700 2.0500 0.2700 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 1.0000 0.0000 0.0000 +f 189/189/48 190/190/48 191/191/48 192/192/48 +v 0.1600 1.0000 -3.6000 +v -0.1600 1.0000 -3.6000 +v -0.1600 1.3000 -3.6000 +v 0.1600 1.3000 -3.6000 +vt 0.00781 0.25781 +vt 0.49219 0.25781 +vt 0.49219 0.49219 +vt 0.00781 0.49219 +vn 0.0000 0.0000 -1.0000 +f 193/193/49 194/194/49 195/195/49 196/196/49 +v -0.2800 0.9000 -0.5000 +v 0.2800 0.9000 -0.5000 +v 0.2800 1.5000 -0.5000 +v -0.2800 1.5000 -0.5000 +vt 0.00781 0.50781 +vt 0.49219 0.50781 +vt 0.49219 0.99219 +vt 0.00781 0.99219 +vn 0.0000 0.0000 1.0000 +f 197/197/50 198/198/50 199/199/50 200/200/50 +v -0.1600 1.3000 -3.6000 +v -0.2800 1.5000 -0.5000 +v 0.2800 1.5000 -0.5000 +v 0.1600 1.3000 -3.6000 +vt 0.00781 0.50781 +vt 0.49219 0.50781 +vt 0.49219 0.99219 +vt 0.00781 0.99219 +vn 0.0000 0.9979 -0.0644 +f 201/201/51 202/202/51 203/203/51 204/204/51 +v -0.1600 1.0000 -3.6000 +v 0.1600 1.0000 -3.6000 +v 0.2800 0.9000 -0.5000 +v -0.2800 0.9000 -0.5000 +vt 0.00781 0.50781 +vt 0.49219 0.50781 +vt 0.49219 0.99219 +vt 0.00781 0.99219 +vn 0.0000 -0.9995 -0.0322 +f 205/205/52 206/206/52 207/207/52 208/208/52 +v -0.1600 1.0000 -3.6000 +v -0.2800 0.9000 -0.5000 +v -0.2800 1.5000 -0.5000 +v -0.1600 1.3000 -3.6000 +vt 0.00781 0.50781 +vt 0.49219 0.50781 +vt 0.49219 0.99219 +vt 0.00781 0.99219 +vn -0.9993 0.0000 -0.0387 +f 209/209/53 210/210/53 211/211/53 212/212/53 +v 0.2800 0.9000 -0.5000 +v 0.1600 1.0000 -3.6000 +v 0.1600 1.3000 -3.6000 +v 0.2800 1.5000 -0.5000 +vt 0.00781 0.50781 +vt 0.49219 0.50781 +vt 0.49219 0.99219 +vt 0.00781 0.99219 +vn 0.9993 0.0000 -0.0387 +f 213/213/54 214/214/54 215/215/54 216/216/54 +v 0.0300 1.2000 -3.7500 +v -0.0300 1.2000 -3.7500 +v -0.0300 2.0000 -3.7500 +v 0.0300 2.0000 -3.7500 +vt 0.00781 0.25781 +vt 0.49219 0.25781 +vt 0.49219 0.49219 +vt 0.00781 0.49219 +vn 0.0000 0.0000 -1.0000 +f 217/217/55 218/218/55 219/219/55 220/220/55 +v -0.0300 1.2000 -3.3500 +v 0.0300 1.2000 -3.3500 +v 0.0300 2.0000 -3.3500 +v -0.0300 2.0000 -3.3500 +vt 0.00781 0.25781 +vt 0.49219 0.25781 +vt 0.49219 0.49219 +vt 0.00781 0.49219 +vn 0.0000 0.0000 1.0000 +f 221/221/56 222/222/56 223/223/56 224/224/56 +v -0.0300 2.0000 -3.7500 +v -0.0300 2.0000 -3.3500 +v 0.0300 2.0000 -3.3500 +v 0.0300 2.0000 -3.7500 +vt 0.00781 0.25781 +vt 0.49219 0.25781 +vt 0.49219 0.49219 +vt 0.00781 0.49219 +vn 0.0000 1.0000 0.0000 +f 225/225/57 226/226/57 227/227/57 228/228/57 +v -0.0300 1.2000 -3.7500 +v 0.0300 1.2000 -3.7500 +v 0.0300 1.2000 -3.3500 +v -0.0300 1.2000 -3.3500 +vt 0.00781 0.25781 +vt 0.49219 0.25781 +vt 0.49219 0.49219 +vt 0.00781 0.49219 +vn 0.0000 -1.0000 0.0000 +f 229/229/58 230/230/58 231/231/58 232/232/58 +v -0.0300 1.2000 -3.7500 +v -0.0300 1.2000 -3.3500 +v -0.0300 2.0000 -3.3500 +v -0.0300 2.0000 -3.7500 +vt 0.00781 0.25781 +vt 0.49219 0.25781 +vt 0.49219 0.49219 +vt 0.00781 0.49219 +vn -1.0000 0.0000 0.0000 +f 233/233/59 234/234/59 235/235/59 236/236/59 +v 0.0300 1.2000 -3.3500 +v 0.0300 1.2000 -3.7500 +v 0.0300 2.0000 -3.7500 +v 0.0300 2.0000 -3.3500 +vt 0.00781 0.25781 +vt 0.49219 0.25781 +vt 0.49219 0.49219 +vt 0.00781 0.49219 +vn 1.0000 0.0000 0.0000 +f 237/237/60 238/238/60 239/239/60 240/240/60 +v 0.6000 1.1200 -3.2000 +v -0.6000 1.1200 -3.2000 +v -0.6000 1.1800 -3.2000 +v 0.6000 1.1800 -3.2000 +vt 0.00781 0.50781 +vt 0.49219 0.50781 +vt 0.49219 0.99219 +vt 0.00781 0.99219 +vn 0.0000 0.0000 -1.0000 +f 241/241/61 242/242/61 243/243/61 244/244/61 +v -0.6000 1.1200 -2.9000 +v 0.6000 1.1200 -2.9000 +v 0.6000 1.1800 -2.9000 +v -0.6000 1.1800 -2.9000 +vt 0.00781 0.50781 +vt 0.49219 0.50781 +vt 0.49219 0.99219 +vt 0.00781 0.99219 +vn 0.0000 0.0000 1.0000 +f 245/245/62 246/246/62 247/247/62 248/248/62 +v -0.6000 1.1800 -3.2000 +v -0.6000 1.1800 -2.9000 +v 0.6000 1.1800 -2.9000 +v 0.6000 1.1800 -3.2000 +vt 0.00781 0.50781 +vt 0.49219 0.50781 +vt 0.49219 0.99219 +vt 0.00781 0.99219 +vn 0.0000 1.0000 0.0000 +f 249/249/63 250/250/63 251/251/63 252/252/63 +v -0.6000 1.1200 -3.2000 +v 0.6000 1.1200 -3.2000 +v 0.6000 1.1200 -2.9000 +v -0.6000 1.1200 -2.9000 +vt 0.00781 0.50781 +vt 0.49219 0.50781 +vt 0.49219 0.99219 +vt 0.00781 0.99219 +vn 0.0000 -1.0000 0.0000 +f 253/253/64 254/254/64 255/255/64 256/256/64 +v -0.6000 1.1200 -3.2000 +v -0.6000 1.1200 -2.9000 +v -0.6000 1.1800 -2.9000 +v -0.6000 1.1800 -3.2000 +vt 0.00781 0.50781 +vt 0.49219 0.50781 +vt 0.49219 0.99219 +vt 0.00781 0.99219 +vn -1.0000 0.0000 0.0000 +f 257/257/65 258/258/65 259/259/65 260/260/65 +v 0.6000 1.1200 -2.9000 +v 0.6000 1.1200 -3.2000 +v 0.6000 1.1800 -3.2000 +v 0.6000 1.1800 -2.9000 +vt 0.00781 0.50781 +vt 0.49219 0.50781 +vt 0.49219 0.99219 +vt 0.00781 0.99219 +vn 1.0000 0.0000 0.0000 +f 261/261/66 262/262/66 263/263/66 264/264/66 +v 0.7000 0.0000 -0.9000 +v 0.6000 0.0000 -0.9000 +v 0.6000 0.0800 -0.9000 +v 0.7000 0.0800 -0.9000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 -1.0000 +f 265/265/67 266/266/67 267/267/67 268/268/67 +v 0.6000 0.0000 1.1000 +v 0.7000 0.0000 1.1000 +v 0.7000 0.0800 1.1000 +v 0.6000 0.0800 1.1000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 1.0000 +f 269/269/68 270/270/68 271/271/68 272/272/68 +v 0.6000 0.0800 -0.9000 +v 0.6000 0.0800 1.1000 +v 0.7000 0.0800 1.1000 +v 0.7000 0.0800 -0.9000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 1.0000 0.0000 +f 273/273/69 274/274/69 275/275/69 276/276/69 +v 0.6000 0.0000 -0.9000 +v 0.7000 0.0000 -0.9000 +v 0.7000 0.0000 1.1000 +v 0.6000 0.0000 1.1000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 -1.0000 0.0000 +f 277/277/70 278/278/70 279/279/70 280/280/70 +v 0.6000 0.0000 -0.9000 +v 0.6000 0.0000 1.1000 +v 0.6000 0.0800 1.1000 +v 0.6000 0.0800 -0.9000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn -1.0000 0.0000 0.0000 +f 281/281/71 282/282/71 283/283/71 284/284/71 +v 0.7000 0.0000 1.1000 +v 0.7000 0.0000 -0.9000 +v 0.7000 0.0800 -0.9000 +v 0.7000 0.0800 1.1000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 1.0000 0.0000 0.0000 +f 285/285/72 286/286/72 287/287/72 288/288/72 +v -0.6000 0.0000 -0.9000 +v -0.7000 0.0000 -0.9000 +v -0.7000 0.0800 -0.9000 +v -0.6000 0.0800 -0.9000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 -1.0000 +f 289/289/73 290/290/73 291/291/73 292/292/73 +v -0.7000 0.0000 1.1000 +v -0.6000 0.0000 1.1000 +v -0.6000 0.0800 1.1000 +v -0.7000 0.0800 1.1000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 1.0000 +f 293/293/74 294/294/74 295/295/74 296/296/74 +v -0.7000 0.0800 -0.9000 +v -0.7000 0.0800 1.1000 +v -0.6000 0.0800 1.1000 +v -0.6000 0.0800 -0.9000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 1.0000 0.0000 +f 297/297/75 298/298/75 299/299/75 300/300/75 +v -0.7000 0.0000 -0.9000 +v -0.6000 0.0000 -0.9000 +v -0.6000 0.0000 1.1000 +v -0.7000 0.0000 1.1000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 -1.0000 0.0000 +f 301/301/76 302/302/76 303/303/76 304/304/76 +v -0.7000 0.0000 -0.9000 +v -0.7000 0.0000 1.1000 +v -0.7000 0.0800 1.1000 +v -0.7000 0.0800 -0.9000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn -1.0000 0.0000 0.0000 +f 305/305/77 306/306/77 307/307/77 308/308/77 +v -0.6000 0.0000 1.1000 +v -0.6000 0.0000 -0.9000 +v -0.6000 0.0800 -0.9000 +v -0.6000 0.0800 1.1000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 1.0000 0.0000 0.0000 +f 309/309/78 310/310/78 311/311/78 312/312/78 +v 0.6600 0.0800 -0.3400 +v 0.6000 0.0800 -0.3400 +v 0.6000 0.5000 -0.3400 +v 0.6600 0.5000 -0.3400 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 -1.0000 +f 313/313/79 314/314/79 315/315/79 316/316/79 +v 0.6000 0.0800 -0.2600 +v 0.6600 0.0800 -0.2600 +v 0.6600 0.5000 -0.2600 +v 0.6000 0.5000 -0.2600 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 1.0000 +f 317/317/80 318/318/80 319/319/80 320/320/80 +v 0.6000 0.5000 -0.3400 +v 0.6000 0.5000 -0.2600 +v 0.6600 0.5000 -0.2600 +v 0.6600 0.5000 -0.3400 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 1.0000 0.0000 +f 321/321/81 322/322/81 323/323/81 324/324/81 +v 0.6000 0.0800 -0.3400 +v 0.6600 0.0800 -0.3400 +v 0.6600 0.0800 -0.2600 +v 0.6000 0.0800 -0.2600 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 -1.0000 0.0000 +f 325/325/82 326/326/82 327/327/82 328/328/82 +v 0.6000 0.0800 -0.3400 +v 0.6000 0.0800 -0.2600 +v 0.6000 0.5000 -0.2600 +v 0.6000 0.5000 -0.3400 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn -1.0000 0.0000 0.0000 +f 329/329/83 330/330/83 331/331/83 332/332/83 +v 0.6600 0.0800 -0.2600 +v 0.6600 0.0800 -0.3400 +v 0.6600 0.5000 -0.3400 +v 0.6600 0.5000 -0.2600 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 1.0000 0.0000 0.0000 +f 333/333/84 334/334/84 335/335/84 336/336/84 +v -0.6000 0.0800 -0.3400 +v -0.6600 0.0800 -0.3400 +v -0.6600 0.5000 -0.3400 +v -0.6000 0.5000 -0.3400 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 -1.0000 +f 337/337/85 338/338/85 339/339/85 340/340/85 +v -0.6600 0.0800 -0.2600 +v -0.6000 0.0800 -0.2600 +v -0.6000 0.5000 -0.2600 +v -0.6600 0.5000 -0.2600 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 1.0000 +f 341/341/86 342/342/86 343/343/86 344/344/86 +v -0.6600 0.5000 -0.3400 +v -0.6600 0.5000 -0.2600 +v -0.6000 0.5000 -0.2600 +v -0.6000 0.5000 -0.3400 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 1.0000 0.0000 +f 345/345/87 346/346/87 347/347/87 348/348/87 +v -0.6600 0.0800 -0.3400 +v -0.6000 0.0800 -0.3400 +v -0.6000 0.0800 -0.2600 +v -0.6600 0.0800 -0.2600 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 -1.0000 0.0000 +f 349/349/88 350/350/88 351/351/88 352/352/88 +v -0.6600 0.0800 -0.3400 +v -0.6600 0.0800 -0.2600 +v -0.6600 0.5000 -0.2600 +v -0.6600 0.5000 -0.3400 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn -1.0000 0.0000 0.0000 +f 353/353/89 354/354/89 355/355/89 356/356/89 +v -0.6000 0.0800 -0.2600 +v -0.6000 0.0800 -0.3400 +v -0.6000 0.5000 -0.3400 +v -0.6000 0.5000 -0.2600 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 1.0000 0.0000 0.0000 +f 357/357/90 358/358/90 359/359/90 360/360/90 +v 0.6600 0.0800 0.5600 +v 0.6000 0.0800 0.5600 +v 0.6000 0.5000 0.5600 +v 0.6600 0.5000 0.5600 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 -1.0000 +f 361/361/91 362/362/91 363/363/91 364/364/91 +v 0.6000 0.0800 0.6400 +v 0.6600 0.0800 0.6400 +v 0.6600 0.5000 0.6400 +v 0.6000 0.5000 0.6400 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 1.0000 +f 365/365/92 366/366/92 367/367/92 368/368/92 +v 0.6000 0.5000 0.5600 +v 0.6000 0.5000 0.6400 +v 0.6600 0.5000 0.6400 +v 0.6600 0.5000 0.5600 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 1.0000 0.0000 +f 369/369/93 370/370/93 371/371/93 372/372/93 +v 0.6000 0.0800 0.5600 +v 0.6600 0.0800 0.5600 +v 0.6600 0.0800 0.6400 +v 0.6000 0.0800 0.6400 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 -1.0000 0.0000 +f 373/373/94 374/374/94 375/375/94 376/376/94 +v 0.6000 0.0800 0.5600 +v 0.6000 0.0800 0.6400 +v 0.6000 0.5000 0.6400 +v 0.6000 0.5000 0.5600 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn -1.0000 0.0000 0.0000 +f 377/377/95 378/378/95 379/379/95 380/380/95 +v 0.6600 0.0800 0.6400 +v 0.6600 0.0800 0.5600 +v 0.6600 0.5000 0.5600 +v 0.6600 0.5000 0.6400 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 1.0000 0.0000 0.0000 +f 381/381/96 382/382/96 383/383/96 384/384/96 +v -0.6000 0.0800 0.5600 +v -0.6600 0.0800 0.5600 +v -0.6600 0.5000 0.5600 +v -0.6000 0.5000 0.5600 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 -1.0000 +f 385/385/97 386/386/97 387/387/97 388/388/97 +v -0.6600 0.0800 0.6400 +v -0.6000 0.0800 0.6400 +v -0.6000 0.5000 0.6400 +v -0.6600 0.5000 0.6400 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 1.0000 +f 389/389/98 390/390/98 391/391/98 392/392/98 +v -0.6600 0.5000 0.5600 +v -0.6600 0.5000 0.6400 +v -0.6000 0.5000 0.6400 +v -0.6000 0.5000 0.5600 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 1.0000 0.0000 +f 393/393/99 394/394/99 395/395/99 396/396/99 +v -0.6600 0.0800 0.5600 +v -0.6000 0.0800 0.5600 +v -0.6000 0.0800 0.6400 +v -0.6600 0.0800 0.6400 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 -1.0000 0.0000 +f 397/397/100 398/398/100 399/399/100 400/400/100 +v -0.6600 0.0800 0.5600 +v -0.6600 0.0800 0.6400 +v -0.6600 0.5000 0.6400 +v -0.6600 0.5000 0.5600 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn -1.0000 0.0000 0.0000 +f 401/401/101 402/402/101 403/403/101 404/404/101 +v -0.6000 0.0800 0.6400 +v -0.6000 0.0800 0.5600 +v -0.6000 0.5000 0.5600 +v -0.6000 0.5000 0.6400 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 1.0000 0.0000 0.0000 +f 405/405/102 406/406/102 407/407/102 408/408/102 +o $blade0 +v 0.1200 2.0500 0.0800 +v -0.1200 2.0500 0.0800 +v -0.1200 2.1200 0.0800 +v 0.1200 2.1200 0.0800 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 -1.0000 +f 409/409/103 410/410/103 411/411/103 412/412/103 +v -0.1200 2.0500 0.3200 +v 0.1200 2.0500 0.3200 +v 0.1200 2.1200 0.3200 +v -0.1200 2.1200 0.3200 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 1.0000 +f 413/413/104 414/414/104 415/415/104 416/416/104 +v -0.1200 2.1200 0.0800 +v -0.1200 2.1200 0.3200 +v 0.1200 2.1200 0.3200 +v 0.1200 2.1200 0.0800 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 1.0000 0.0000 +f 417/417/105 418/418/105 419/419/105 420/420/105 +v -0.1200 2.0500 0.0800 +v 0.1200 2.0500 0.0800 +v 0.1200 2.0500 0.3200 +v -0.1200 2.0500 0.3200 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 -1.0000 0.0000 +f 421/421/106 422/422/106 423/423/106 424/424/106 +v -0.1200 2.0500 0.0800 +v -0.1200 2.0500 0.3200 +v -0.1200 2.1200 0.3200 +v -0.1200 2.1200 0.0800 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn -1.0000 0.0000 0.0000 +f 425/425/107 426/426/107 427/427/107 428/428/107 +v 0.1200 2.0500 0.3200 +v 0.1200 2.0500 0.0800 +v 0.1200 2.1200 0.0800 +v 0.1200 2.1200 0.3200 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 1.0000 0.0000 0.0000 +f 429/429/108 430/430/108 431/431/108 432/432/108 +v 3.4000 2.0700 0.1200 +v 0.1200 2.0700 0.1200 +v 0.1200 2.1000 0.1200 +v 3.4000 2.1000 0.1200 +vt 0.50781 0.50781 +vt 0.74219 0.50781 +vt 0.74219 0.74219 +vt 0.50781 0.74219 +vn 0.0000 0.0000 -1.0000 +f 433/433/109 434/434/109 435/435/109 436/436/109 +v 0.1200 2.0700 0.2800 +v 3.4000 2.0700 0.2800 +v 3.4000 2.1000 0.2800 +v 0.1200 2.1000 0.2800 +vt 0.50781 0.50781 +vt 0.74219 0.50781 +vt 0.74219 0.74219 +vt 0.50781 0.74219 +vn 0.0000 0.0000 1.0000 +f 437/437/110 438/438/110 439/439/110 440/440/110 +v 0.1200 2.1000 0.1200 +v 0.1200 2.1000 0.2800 +v 3.4000 2.1000 0.2800 +v 3.4000 2.1000 0.1200 +vt 0.50781 0.50781 +vt 0.74219 0.50781 +vt 0.74219 0.74219 +vt 0.50781 0.74219 +vn 0.0000 1.0000 0.0000 +f 441/441/111 442/442/111 443/443/111 444/444/111 +v 0.1200 2.0700 0.1200 +v 3.4000 2.0700 0.1200 +v 3.4000 2.0700 0.2800 +v 0.1200 2.0700 0.2800 +vt 0.50781 0.50781 +vt 0.74219 0.50781 +vt 0.74219 0.74219 +vt 0.50781 0.74219 +vn 0.0000 -1.0000 0.0000 +f 445/445/112 446/446/112 447/447/112 448/448/112 +v 0.1200 2.0700 0.1200 +v 0.1200 2.0700 0.2800 +v 0.1200 2.1000 0.2800 +v 0.1200 2.1000 0.1200 +vt 0.50781 0.50781 +vt 0.74219 0.50781 +vt 0.74219 0.74219 +vt 0.50781 0.74219 +vn -1.0000 0.0000 0.0000 +f 449/449/113 450/450/113 451/451/113 452/452/113 +v 3.4000 2.0700 0.2800 +v 3.4000 2.0700 0.1200 +v 3.4000 2.1000 0.1200 +v 3.4000 2.1000 0.2800 +vt 0.50781 0.50781 +vt 0.74219 0.50781 +vt 0.74219 0.74219 +vt 0.50781 0.74219 +vn 1.0000 0.0000 0.0000 +f 453/453/114 454/454/114 455/455/114 456/456/114 +o $blade1 +v 0.1400 1.5500 -3.6000 +v 0.0600 1.5500 -3.6000 +v 0.0600 1.6500 -3.6000 +v 0.1400 1.6500 -3.6000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 -1.0000 +f 457/457/115 458/458/115 459/459/115 460/460/115 +v 0.0600 1.5500 -3.5000 +v 0.1400 1.5500 -3.5000 +v 0.1400 1.6500 -3.5000 +v 0.0600 1.6500 -3.5000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 0.0000 1.0000 +f 461/461/116 462/462/116 463/463/116 464/464/116 +v 0.0600 1.6500 -3.6000 +v 0.0600 1.6500 -3.5000 +v 0.1400 1.6500 -3.5000 +v 0.1400 1.6500 -3.6000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 1.0000 0.0000 +f 465/465/117 466/466/117 467/467/117 468/468/117 +v 0.0600 1.5500 -3.6000 +v 0.1400 1.5500 -3.6000 +v 0.1400 1.5500 -3.5000 +v 0.0600 1.5500 -3.5000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 0.0000 -1.0000 0.0000 +f 469/469/118 470/470/118 471/471/118 472/472/118 +v 0.0600 1.5500 -3.6000 +v 0.0600 1.5500 -3.5000 +v 0.0600 1.6500 -3.5000 +v 0.0600 1.6500 -3.6000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn -1.0000 0.0000 0.0000 +f 473/473/119 474/474/119 475/475/119 476/476/119 +v 0.1400 1.5500 -3.5000 +v 0.1400 1.5500 -3.6000 +v 0.1400 1.6500 -3.6000 +v 0.1400 1.6500 -3.5000 +vt 0.50781 0.75781 +vt 0.74219 0.75781 +vt 0.74219 0.99219 +vt 0.50781 0.99219 +vn 1.0000 0.0000 0.0000 +f 477/477/120 478/478/120 479/479/120 480/480/120 +v 0.1100 1.6500 -3.5900 +v 0.0800 1.6500 -3.5900 +v 0.0800 2.1500 -3.5900 +v 0.1100 2.1500 -3.5900 +vt 0.50781 0.50781 +vt 0.74219 0.50781 +vt 0.74219 0.74219 +vt 0.50781 0.74219 +vn 0.0000 0.0000 -1.0000 +f 481/481/121 482/482/121 483/483/121 484/484/121 +v 0.0800 1.6500 -3.5100 +v 0.1100 1.6500 -3.5100 +v 0.1100 2.1500 -3.5100 +v 0.0800 2.1500 -3.5100 +vt 0.50781 0.50781 +vt 0.74219 0.50781 +vt 0.74219 0.74219 +vt 0.50781 0.74219 +vn 0.0000 0.0000 1.0000 +f 485/485/122 486/486/122 487/487/122 488/488/122 +v 0.0800 2.1500 -3.5900 +v 0.0800 2.1500 -3.5100 +v 0.1100 2.1500 -3.5100 +v 0.1100 2.1500 -3.5900 +vt 0.50781 0.50781 +vt 0.74219 0.50781 +vt 0.74219 0.74219 +vt 0.50781 0.74219 +vn 0.0000 1.0000 0.0000 +f 489/489/123 490/490/123 491/491/123 492/492/123 +v 0.0800 1.6500 -3.5900 +v 0.1100 1.6500 -3.5900 +v 0.1100 1.6500 -3.5100 +v 0.0800 1.6500 -3.5100 +vt 0.50781 0.50781 +vt 0.74219 0.50781 +vt 0.74219 0.74219 +vt 0.50781 0.74219 +vn 0.0000 -1.0000 0.0000 +f 493/493/124 494/494/124 495/495/124 496/496/124 +v 0.0800 1.6500 -3.5900 +v 0.0800 1.6500 -3.5100 +v 0.0800 2.1500 -3.5100 +v 0.0800 2.1500 -3.5900 +vt 0.50781 0.50781 +vt 0.74219 0.50781 +vt 0.74219 0.74219 +vt 0.50781 0.74219 +vn -1.0000 0.0000 0.0000 +f 497/497/125 498/498/125 499/499/125 500/500/125 +v 0.1100 1.6500 -3.5100 +v 0.1100 1.6500 -3.5900 +v 0.1100 2.1500 -3.5900 +v 0.1100 2.1500 -3.5100 +vt 0.50781 0.50781 +vt 0.74219 0.50781 +vt 0.74219 0.74219 +vt 0.50781 0.74219 +vn 1.0000 0.0000 0.0000 +f 501/501/126 502/502/126 503/503/126 504/504/126 From a68d40fb6dfcdbdcefe8cf1a3e4bd19ec8b900a9 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:17 +0800 Subject: [PATCH 16/61] Add the Rework Scout default paint to the example pack --- .../mcheli/textures/helicopters/rework-scout.png | Bin 0 -> 401 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 examples/rework-example-pack/assets/mcheli/textures/helicopters/rework-scout.png diff --git a/examples/rework-example-pack/assets/mcheli/textures/helicopters/rework-scout.png b/examples/rework-example-pack/assets/mcheli/textures/helicopters/rework-scout.png new file mode 100644 index 0000000000000000000000000000000000000000..4c9c52a6680f51532c3ad05c63ef6293d5e8ae13 GIT binary patch literal 401 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7T#lEV2to|aSW-5dppU{kJ(Xx<-4Nz zM5V@LC8pC(Vup>%^IVQdSFrhi^zS*bAbeKf?_K;8>Sy~nSU%YUB!`TM8*_IJmxbv`lr*%eoNOZa5!C(*sX zwx~}!{Y1O!Yo@=-^poLVUR|E&p*MN`i>u3RC&sA7UtH~9t+dl;-=gsO-&~4j*0`>> odrPJP@=hFzEdn57LiBU?*Qu-*0+xTT2L?5Rr>mdKI;Vst03Z9ZBLDyZ literal 0 HcmV?d00001 From a29bd10d807bc537953e6af6234f1028f96ff203 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:18 +0800 Subject: [PATCH 17/61] Add the Rework Scout rescue paint to the example pack --- .../textures/helicopters/rework-scout-rescue.png | Bin 0 -> 396 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 examples/rework-example-pack/assets/mcheli/textures/helicopters/rework-scout-rescue.png diff --git a/examples/rework-example-pack/assets/mcheli/textures/helicopters/rework-scout-rescue.png b/examples/rework-example-pack/assets/mcheli/textures/helicopters/rework-scout-rescue.png new file mode 100644 index 0000000000000000000000000000000000000000..ddd477b5d929f48b26198249cfdcb8716070425a GIT binary patch literal 396 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7T#lEU<~$jaSW-5dpl`k?qLT3mi+>0 zY%&6TA+3rlSj3;*@ZG?E{V-dK#^&#h)@v%Q)HWYb*?;oRqvPM7zWo=!FD`zW`Sj9Q z{=%JgHFAk7CNMJ_FfjY%>s3wYmV0uDLF>r@2A%^9muCZci`aJ>G;mo0Gc4CZL z{KeJ&)k-^k_ALsZ|IMXnW{vB5ySJTBjDB{-)!q_5nfgg|@2@TDlTJU;uKJqkPo@C^ hP8^CYV8X`QzTLfP`_qjp-vdLL!PC{xWt~$(696V1tzQ5D literal 0 HcmV?d00001 From 3687d39045163e1980ff144240783ff0b11a234b Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:18 +0800 Subject: [PATCH 18/61] Test that the example pack loads without problems --- .../mcheli/content/pack/ExamplePackTest.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 common/src/test/java/com/jnhprojects/mcheli/content/pack/ExamplePackTest.java diff --git a/common/src/test/java/com/jnhprojects/mcheli/content/pack/ExamplePackTest.java b/common/src/test/java/com/jnhprojects/mcheli/content/pack/ExamplePackTest.java new file mode 100644 index 0000000..e6c632b --- /dev/null +++ b/common/src/test/java/com/jnhprojects/mcheli/content/pack/ExamplePackTest.java @@ -0,0 +1,51 @@ +package com.jnhprojects.mcheli.content.pack; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.aircraft.HelicopterDefinition; +import com.jnhprojects.mcheli.content.aircraft.RotorDefinition; +import com.jnhprojects.mcheli.model.Mesh; +import com.jnhprojects.mcheli.model.MeshLoader; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** The example pack in {@code examples/} is what pack authors copy, so it has to load cleanly. */ +class ExamplePackTest { + private static final Path PACK = Path.of(System.getProperty("mcheli.examplePack", "../examples/rework-example-pack")); + + @Test + void loadsWithoutProblems() throws Exception { + try (ContentPack pack = ContentPack.open(PACK)) { + assertEquals("rework-example", pack.id()); + + ContentRegistry registry = ContentRegistry.load(List.of(pack.contentSource())); + assertEquals(List.of(), registry.problems()); + HelicopterDefinition scout = registry.helicopter("rework-scout"); + assertNotNull(scout); + assertEquals("rework-example", scout.pack()); + + Path model = pack.assets().resolve("models/helicopters/rework-scout.obj"); + Mesh mesh; + try (InputStream in = Files.newInputStream(model)) { + mesh = MeshLoader.load(model.getFileName().toString(), in); + } + assertFalse(mesh.body().isEmpty()); + for (RotorDefinition rotor : scout.rotors) { + assertFalse(mesh.part("$" + rotor.part()).isEmpty(), "model has no $" + rotor.part()); + } + + assertTrue(Files.isRegularFile(pack.assets().resolve("textures/helicopters/rework-scout.png"))); + for (String texture : scout.textures) { + assertTrue(Files.isRegularFile(pack.assets().resolve("textures/helicopters/" + texture + ".png")), texture); + } + } + } +} From d23785918e6cca6ac6154425a0c9159f9df6d28b Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:19 +0800 Subject: [PATCH 19/61] Point the tests at the example pack --- common/build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/common/build.gradle b/common/build.gradle index 8dde66e..f0b103e 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -45,4 +45,7 @@ tasks.named('test', Test) { // and are skipped where the zip is absent, such as on CI. systemProperty 'mcheli.legacyZip', legacyZip systemProperty 'mcheli.testContent', layout.buildDirectory.dir('legacy-content').get().asFile.absolutePath + // Our own example content pack, which needs nothing from the original. + systemProperty 'mcheli.examplePack', rootProject.file('examples/rework-example-pack').absolutePath + inputs.dir(rootProject.file('examples/rework-example-pack')).withPropertyName('examplePack') } From 8b9768f8c7b30755c84c990f912ea87d168c9417 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:19 +0800 Subject: [PATCH 20/61] Add the pack allow list and size limits to the server config --- .../com/jnhprojects/mcheli/ServerConfig.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/ServerConfig.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/ServerConfig.java index 364bccd..ab5e7db 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/ServerConfig.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/ServerConfig.java @@ -21,6 +21,9 @@ public final class ServerConfig { private static final ForgeConfigSpec.DoubleValue VEHICLE_DAMAGE_FACTOR; private static final ForgeConfigSpec.BooleanValue INFINITE_AMMO; private static final ForgeConfigSpec.ConfigValue> DISABLED_VEHICLES; + private static final ForgeConfigSpec.ConfigValue> PACK_ALLOW_LIST; + private static final ForgeConfigSpec.IntValue MAX_PACK_SIZE; + private static final ForgeConfigSpec.IntValue MAX_TOTAL_SIZE; static { ForgeConfigSpec.Builder builder = new ForgeConfigSpec.Builder(); @@ -51,6 +54,21 @@ public final class ServerConfig { .define("infiniteAmmo", false); builder.pop(); + builder.comment("Content packs from /mcheli/packs. Players get the packs from the server when they join.", + "Changes apply when the world starts or after /mcheli reload.") + .push("packs"); + PACK_ALLOW_LIST = builder + .comment("Pack ids the server loads, such as \"rework-example\". Empty loads every pack in the folder.") + .defineListAllowEmpty("allowList", List.of(), o -> o instanceof String); + MAX_PACK_SIZE = builder + .comment("Largest pack the server loads and sends to players, in megabytes.") + .defineInRange("maxPackSize", 8, 1, 512); + MAX_TOTAL_SIZE = builder + .comment("Most the server loads and sends in all, in megabytes. Packs are taken in id order until", + "the next one would go over.") + .defineInRange("maxTotalSize", 64, 1, 4096); + builder.pop(); + SPEC = builder.build(); } @@ -74,6 +92,27 @@ public static boolean infiniteAmmo() { return SPEC.isLoaded() ? INFINITE_AMMO.get() : INFINITE_AMMO.getDefault(); } + /** Whether the allow list lets the server load this pack. An empty list allows every pack. */ + public static boolean isPackAllowed(String id) { + if (!SPEC.isLoaded() || PACK_ALLOW_LIST.get().isEmpty()) { + return true; + } + for (String allowed : PACK_ALLOW_LIST.get()) { + if (allowed.trim().toLowerCase(Locale.ROOT).equals(id)) { + return true; + } + } + return false; + } + + public static long maxPackBytes() { + return (long) (SPEC.isLoaded() ? MAX_PACK_SIZE.get() : MAX_PACK_SIZE.getDefault()) << 20; + } + + public static long maxTotalPackBytes() { + return (long) (SPEC.isLoaded() ? MAX_TOTAL_SIZE.get() : MAX_TOTAL_SIZE.getDefault()) << 20; + } + public static boolean isDisabled(String vehicle) { if (!SPEC.isLoaded()) { return false; From 56683d906face2fd05679e85e9d16100f2a532f3 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:20 +0800 Subject: [PATCH 21/61] Add the pack cache size to the client config --- .../java/com/jnhprojects/mcheli/ClientConfig.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/ClientConfig.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/ClientConfig.java index c829143..299d482 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/ClientConfig.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/ClientConfig.java @@ -10,6 +10,7 @@ public final class ClientConfig { public static final ForgeConfigSpec SPEC; private static final ForgeConfigSpec.BooleanValue NIGHT_GLOW; + private static final ForgeConfigSpec.IntValue PACK_CACHE_SIZE; static { ForgeConfigSpec.Builder builder = new ForgeConfigSpec.Builder(); @@ -21,6 +22,14 @@ public final class ClientConfig { .define("nightGlow", true); builder.pop(); + builder.push("packs"); + PACK_CACHE_SIZE = builder + .comment("How much the content packs downloaded from servers may take up in mcheli/cache, in megabytes.", + "The packs used longest ago are deleted first; the current server's packs are always kept.", + "A single pack larger than this is not downloaded.") + .defineInRange("cacheSize", 256, 16, 16384); + builder.pop(); + SPEC = builder.build(); } @@ -30,4 +39,8 @@ private ClientConfig() { public static boolean nightGlow() { return SPEC.isLoaded() ? NIGHT_GLOW.get() : NIGHT_GLOW.getDefault(); } + + public static long packCacheBytes() { + return (long) (SPEC.isLoaded() ? PACK_CACHE_SIZE.get() : PACK_CACHE_SIZE.getDefault()) << 20; + } } From 583c1963bc272c7413e2038d7ed17dd42c9a6890 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:20 +0800 Subject: [PATCH 22/61] Load the content packs folder with the server rules applied --- .../com/jnhprojects/mcheli/ContentPacks.java | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 forge-26.3/src/main/java/com/jnhprojects/mcheli/ContentPacks.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/ContentPacks.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/ContentPacks.java new file mode 100644 index 0000000..c378489 --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/ContentPacks.java @@ -0,0 +1,151 @@ +package com.jnhprojects.mcheli; + +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.content.pack.PackFolder; +import com.jnhprojects.mcheli.content.pack.PackProblem; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * The content packs in {@code /mcheli/packs}, loaded together with the imported 1.0.3 + * content, which always comes first. + * + *

At startup every pack in the folder is loaded, so the vehicles exist before any world does. + * When a server starts (single player included) and on {@code /mcheli reload}, the folder is + * scanned again and the server config applied: the allow list, the size limit per pack and the + * limit for all packs together. The packs that pass are what players get sent. + * + *

A zip pack is read in place, so packs stay open while their definitions and assets are in + * use. The set a reload replaces is closed on the reload after that, so a client in the same game + * that is still drawing from it never reads from a closed zip. + */ +public final class ContentPacks { + private static final int PROBLEMS_IN_CHAT = 8; + + private final Path folder; + private final LegacyContent legacy; + private volatile List active = List.of(); + private volatile List problems = List.of(); + private List retired = List.of(); + + ContentPacks(Path gameDir, LegacyContent legacy) { + this.folder = gameDir.resolve(MCHeli.MOD_ID).resolve("packs"); + this.legacy = legacy; + } + + /** {@code /mcheli/packs}. */ + public Path folder() { + return folder; + } + + /** The packs whose definitions are loaded, in load order. The import is not among them. */ + public List active() { + return active; + } + + /** Everything that went wrong in the last load, packs left out included. */ + public List problems() { + return problems; + } + + /** + * Scans the folder and loads the import and the packs. Never throws; whatever goes wrong is + * logged and kept in {@link #problems()}. + * + * @param serverRules whether to apply the server config; off at startup, when no world exists + * @return the new content, for the caller to swap in + */ + public synchronized ContentRegistry load(boolean serverRules) { + try { + Files.createDirectories(folder); + } catch (IOException e) { + MCHeli.LOGGER.warn("Could not create {}", folder, e); + } + + PackFolder.Scan scan = PackFolder.scan(folder); + List found = new ArrayList<>(scan.problems()); + List accepted = new ArrayList<>(); + long total = 0; + for (ContentPack pack : scan.packs()) { + long size; + String refusal; + try { + size = pack.archiveSize(); + refusal = serverRules ? refusal(pack, size, total) : null; + } catch (IOException e) { + size = 0; + refusal = "left out, could not be read: " + e; + } + if (refusal == null) { + accepted.add(pack); + total += size; + } else { + found.add(new PackProblem(pack.id(), refusal)); + close(List.of(pack)); + } + } + for (PackProblem problem : found) { + MCHeli.LOGGER.warn("Content pack {}", problem); + } + + List sources = new ArrayList<>(); + sources.add(new ContentSource(ContentSource.ORIGINAL, legacy.assets())); + for (ContentPack pack : accepted) { + sources.add(pack.contentSource()); + } + ContentRegistry registry = ContentRegistry.load(sources); + found.addAll(registry.problems()); + + close(retired); + retired = active; + active = List.copyOf(accepted); + problems = List.copyOf(found); + MCHeli.LOGGER.info("Content packs in use: {}", active.isEmpty() ? "none" : active); + return registry; + } + + private static @Nullable String refusal(ContentPack pack, long size, long total) { + if (!ServerConfig.isPackAllowed(pack.id())) { + return "left out, not on the server's allow list (packs.allowList in mcheli-server.toml)"; + } + if (size > ServerConfig.maxPackBytes()) { + return "left out, %.1f MB is more than the server allows for one pack (packs.maxPackSize, %d MB)" + .formatted(size / 1048576.0, ServerConfig.maxPackBytes() >> 20); + } + if (total + size > ServerConfig.maxTotalPackBytes()) { + return "left out, it would take the packs past the server's total limit (packs.maxTotalSize, %d MB)" + .formatted(ServerConfig.maxTotalPackBytes() >> 20); + } + return null; + } + + /** Sends the problems of the last load as chat lines, the first few of them. */ + public void reportProblems(Consumer out) { + List list = problems; + for (int i = 0; i < Math.min(list.size(), PROBLEMS_IN_CHAT); i++) { + out.accept(Component.translatable("message.mcheli.pack.problem", list.get(i).pack(), list.get(i).message())); + } + if (list.size() > PROBLEMS_IN_CHAT) { + out.accept(Component.translatable("message.mcheli.pack.more_problems", list.size() - PROBLEMS_IN_CHAT)); + } + } + + private static void close(List packs) { + for (ContentPack pack : packs) { + try { + pack.close(); + } catch (IOException e) { + MCHeli.LOGGER.warn("Could not close content pack {}", pack, e); + } + } + } +} From cd6038b4000ed7960aeda50da541d23d4db4aa77 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:21 +0800 Subject: [PATCH 23/61] Add the packet listing the server's content packs --- .../mcheli/network/PackListPacket.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java new file mode 100644 index 0000000..b475ead --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackListPacket.java @@ -0,0 +1,51 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.ClientAccess; +import io.netty.handler.codec.DecoderException; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.event.network.CustomPayloadEvent; + +import java.util.ArrayList; +import java.util.List; + +/** + * Server to player: the content packs the server uses, in load order. Sent on join and after + * {@code /mcheli reload}. The client requests the packs it has not cached ({@link PackRequestPacket}) + * and then rebuilds its content from exactly this list, so both sides fly the same definitions. + * The imported 1.0.3 content is never listed: every player imports their own copy. + */ +public record PackListPacket(List packs) { + public static final int MAX_PACKS = 256; + + /** + * @param size length of the pack's zip in bytes, which is what {@link PackDataPacket} sends + */ + public record Entry(String id, String version, String hash, int size) { + } + + public static void encode(PackListPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.packs.size()); + for (Entry entry : msg.packs) { + buf.writeUtf(entry.id, 64); + buf.writeUtf(entry.version, 32); + buf.writeUtf(entry.hash, 64); + buf.writeVarInt(entry.size); + } + } + + public static PackListPacket decode(FriendlyByteBuf buf) { + int count = buf.readVarInt(); + if (count < 0 || count > MAX_PACKS) { + throw new DecoderException("Too many content packs: " + count); + } + List packs = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + packs.add(new Entry(buf.readUtf(64), buf.readUtf(32), buf.readUtf(64), buf.readVarInt())); + } + return new PackListPacket(List.copyOf(packs)); + } + + public static void handle(PackListPacket msg, CustomPayloadEvent.Context ctx) { + ClientAccess.packs().onPackList(msg); + } +} From 74d1e8487eca9b5dc29ae69f9f20c17b9edf9b2e Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:21 +0800 Subject: [PATCH 24/61] Add the packet asking the server for missing content packs --- .../mcheli/network/PackRequestPacket.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java new file mode 100644 index 0000000..8ff7d32 --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackRequestPacket.java @@ -0,0 +1,42 @@ +package com.jnhprojects.mcheli.network; + +import io.netty.handler.codec.DecoderException; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraftforge.event.network.CustomPayloadEvent; + +import java.util.ArrayList; +import java.util.List; + +/** + * Player to server: send me these packs, by content hash. The server only answers for hashes in + * the list it sent, so a client cannot ask for anything else. + */ +public record PackRequestPacket(List hashes) { + + public static void encode(PackRequestPacket msg, FriendlyByteBuf buf) { + buf.writeVarInt(msg.hashes.size()); + for (String hash : msg.hashes) { + buf.writeUtf(hash, 64); + } + } + + public static PackRequestPacket decode(FriendlyByteBuf buf) { + int count = buf.readVarInt(); + if (count < 0 || count > PackListPacket.MAX_PACKS) { + throw new DecoderException("Too many content packs requested: " + count); + } + List hashes = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + hashes.add(buf.readUtf(64)); + } + return new PackRequestPacket(List.copyOf(hashes)); + } + + public static void handle(PackRequestPacket msg, CustomPayloadEvent.Context ctx) { + ServerPlayer sender = ctx.getSender(); + if (sender != null) { + PackSync.request(sender, msg.hashes); + } + } +} From 6cd6cfb5811fd1c9c08a05eadd809895ae8828cb Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:22 +0800 Subject: [PATCH 25/61] Add the packet carrying a chunk of a content pack --- .../mcheli/network/PackDataPacket.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java new file mode 100644 index 0000000..dc11b95 --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackDataPacket.java @@ -0,0 +1,31 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.entity.ClientAccess; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraftforge.event.network.CustomPayloadEvent; + +/** + * Server to player: one chunk of a pack's zip. Chunks of a pack arrive in order, so + * {@code offset} only lets the client check that nothing went missing. + * + * @param total length of the whole zip, as in the {@link PackListPacket} + */ +public record PackDataPacket(String hash, int offset, int total, byte[] data) { + /** Well under the 1 MiB limit of a clientbound custom payload. */ + public static final int CHUNK_SIZE = 128 * 1024; + + public static void encode(PackDataPacket msg, FriendlyByteBuf buf) { + buf.writeUtf(msg.hash, 64); + buf.writeVarInt(msg.offset); + buf.writeVarInt(msg.total); + buf.writeByteArray(msg.data); + } + + public static PackDataPacket decode(FriendlyByteBuf buf) { + return new PackDataPacket(buf.readUtf(64), buf.readVarInt(), buf.readVarInt(), buf.readByteArray(CHUNK_SIZE)); + } + + public static void handle(PackDataPacket msg, CustomPayloadEvent.Context ctx) { + ClientAccess.packs().onPackData(msg); + } +} From 4dd79e9ffce9fc064f1a558dcbc1a4e3d64334fb Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:22 +0800 Subject: [PATCH 26/61] Send content packs to players in chunks --- .../jnhprojects/mcheli/network/PackSync.java | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSync.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSync.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSync.java new file mode 100644 index 0000000..fc462d7 --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/PackSync.java @@ -0,0 +1,159 @@ +package com.jnhprojects.mcheli.network; + +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import net.minecraft.commands.Commands; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.event.entity.player.PlayerEvent; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Deque; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * The server half of the content pack sync. + *

+ *  player joins          ── PackListPacket(id, version, hash, size per pack) ──▶ client
+ *                        ◀── PackRequestPacket(hashes it has not cached) ────────
+ *  every tick            ── PackDataPacket(chunk) ──▶  until each pack is sent
+ * 
+ * Each player gets {@link #CHUNKS_PER_TICK} chunks a tick (about 5 MB a second), one pack after + * the other, so a download never crowds out the game's own packets. A pack's bytes are read when + * its turn comes and dropped once sent. Everything here runs on the server thread. + */ +public final class PackSync { + private static final int CHUNKS_PER_TICK = 2; + + private static final Map> TRANSFERS = new HashMap<>(); + + private PackSync() { + } + + public static void init() { + PlayerEvent.PlayerLoggedInEvent.BUS.addListener(event -> { + if (event.getEntity() instanceof ServerPlayer player) { + ModNetwork.sendToPlayer(player, list()); + if (seesProblems(player)) { + MCHeli.packs().reportProblems(player::sendSystemMessage); + } + } + }); + PlayerEvent.PlayerLoggedOutEvent.BUS.addListener(event -> TRANSFERS.remove(event.getEntity().getUUID())); + TickEvent.ServerTickEvent.Post.BUS.addListener(event -> pump(event.server())); + } + + /** The packs the server uses now, as the client should load them. */ + public static PackListPacket list() { + List entries = new ArrayList<>(); + for (ContentPack pack : MCHeli.packs().active()) { + try { + entries.add(new PackListPacket.Entry(pack.id(), pack.manifest().version(), pack.hash(), (int) pack.archiveSize())); + } catch (IOException e) { + MCHeli.LOGGER.error("Content pack {} cannot be sent to players", pack, e); + } + } + return new PackListPacket(List.copyOf(entries)); + } + + /** After a reload: drops transfers in progress and sends every player the new list. */ + public static void sendListToAll(MinecraftServer server) { + TRANSFERS.clear(); + ModNetwork.sendToAll(server, list()); + } + + /** Operators, and the owner of a single player or LAN world, are told what is wrong with the packs. */ + public static boolean seesProblems(ServerPlayer player) { + MinecraftServer server = player.level().getServer(); + return server.isSingleplayerOwner(player.nameAndId()) + || Commands.hasPermission(Commands.LEVEL_GAMEMASTERS).test(player.createCommandSourceStack()); + } + + static void request(ServerPlayer player, List hashes) { + Deque queue = TRANSFERS.computeIfAbsent(player.getUUID(), id -> new ArrayDeque<>()); + for (String hash : hashes) { + ContentPack pack = find(hash); + if (pack == null) { + MCHeli.LOGGER.debug("{} asked for content pack {}, which the server does not use", player.getScoreboardName(), hash); + continue; + } + if (queue.stream().noneMatch(t -> t.pack == pack)) { + queue.add(new Transfer(pack)); + } + } + } + + private static @Nullable ContentPack find(String hash) { + for (ContentPack pack : MCHeli.packs().active()) { + if (pack.hash().equals(hash)) { + return pack; + } + } + return null; + } + + private static void pump(MinecraftServer server) { + Iterator>> it = TRANSFERS.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry> entry = it.next(); + ServerPlayer player = server.getPlayerList().getPlayer(entry.getKey()); + Deque queue = entry.getValue(); + if (player == null || queue.isEmpty()) { + it.remove(); + continue; + } + for (int sent = 0; sent < CHUNKS_PER_TICK && !queue.isEmpty(); sent++) { + Transfer transfer = queue.peek(); + PackDataPacket chunk = transfer.next(); + if (chunk == null) { + queue.poll(); + continue; + } + ModNetwork.sendToPlayer(player, chunk); + if (transfer.done()) { + queue.poll(); + } + } + } + } + + /** One pack on its way to one player. */ + private static final class Transfer { + private final ContentPack pack; + private byte @Nullable [] data; + private int offset; + + Transfer(ContentPack pack) { + this.pack = pack; + } + + /** The next chunk, or {@code null} when the pack could not be read. */ + @Nullable PackDataPacket next() { + if (data == null) { + try { + data = pack.archive(); + } catch (IOException e) { + MCHeli.LOGGER.error("Could not read content pack {} to send it", pack, e); + return null; + } + } + int end = Math.min(offset + PackDataPacket.CHUNK_SIZE, data.length); + PackDataPacket chunk = new PackDataPacket(pack.hash(), offset, data.length, Arrays.copyOfRange(data, offset, end)); + offset = end; + return chunk; + } + + boolean done() { + return data != null && offset >= data.length; + } + } +} From 02e8568714984ea414a9b365ff4fdc1623f98012 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:35 +0800 Subject: [PATCH 27/61] Remove ContentReloadPacket, replaced by the pack list --- .../mcheli/network/ContentReloadPacket.java | 24 ------------------- 1 file changed, 24 deletions(-) delete mode 100644 forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ContentReloadPacket.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ContentReloadPacket.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ContentReloadPacket.java deleted file mode 100644 index 2759fe8..0000000 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ContentReloadPacket.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.jnhprojects.mcheli.network; - -import com.jnhprojects.mcheli.entity.ClientAccess; -import net.minecraft.network.FriendlyByteBuf; -import net.minecraftforge.event.network.CustomPayloadEvent; - -/** - * Server to every player: the definitions were reloaded with {@code /mcheli reload}. Clients - * reload theirs too (unless they share the server's, in single player) and drop cached models and - * textures so edited files show up. - */ -public record ContentReloadPacket() { - - public static void encode(ContentReloadPacket msg, FriendlyByteBuf buf) { - } - - public static ContentReloadPacket decode(FriendlyByteBuf buf) { - return new ContentReloadPacket(); - } - - public static void handle(ContentReloadPacket msg, CustomPayloadEvent.Context ctx) { - ClientAccess.contentReloaded(); - } -} From 42fc04b6e52a16ef5044568270655f8c467e6e63 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:35 +0800 Subject: [PATCH 28/61] Register the content pack packets and bump the protocol to 6 --- .../com/jnhprojects/mcheli/network/ModNetwork.java | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java index 15dcf10..705a2ad 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/network/ModNetwork.java @@ -13,7 +13,7 @@ public final class ModNetwork { /** Bump whenever a packet layout changes. Clients and servers must match exactly. */ - private static final int PROTOCOL_VERSION = 5; + private static final int PROTOCOL_VERSION = 6; private static final SimpleChannel CHANNEL = ChannelBuilder .named(Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, "main")) @@ -36,6 +36,9 @@ public final class ModNetwork { .addMain(LockWarningPacket.class, StreamCodec.ofMember(LockWarningPacket::encode, LockWarningPacket::decode), LockWarningPacket::handle) + .addMain(PackRequestPacket.class, + StreamCodec.ofMember(PackRequestPacket::encode, PackRequestPacket::decode), + PackRequestPacket::handle) .clientbound() .addMain(WeaponStatePacket.class, StreamCodec.ofMember(WeaponStatePacket::encode, WeaponStatePacket::decode), @@ -43,9 +46,12 @@ public final class ModNetwork { .addMain(ExplosionEffectPacket.class, StreamCodec.ofMember(ExplosionEffectPacket::encode, ExplosionEffectPacket::decode), ExplosionEffectPacket::handle) - .addMain(ContentReloadPacket.class, - StreamCodec.ofMember(ContentReloadPacket::encode, ContentReloadPacket::decode), - ContentReloadPacket::handle) + .addMain(PackListPacket.class, + StreamCodec.ofMember(PackListPacket::encode, PackListPacket::decode), + PackListPacket::handle) + .addMain(PackDataPacket.class, + StreamCodec.ofMember(PackDataPacket::encode, PackDataPacket::decode), + PackDataPacket::handle) .build(); private ModNetwork() { From 4fa545510a4d8216852ef391c067f18659b83ace Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:36 +0800 Subject: [PATCH 29/61] Route content pack packets to the client --- .../mcheli/entity/ClientAccess.java | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java index 3118c97..bf89ea5 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/entity/ClientAccess.java @@ -1,5 +1,7 @@ package com.jnhprojects.mcheli.entity; +import com.jnhprojects.mcheli.network.PackDataPacket; +import com.jnhprojects.mcheli.network.PackListPacket; import net.minecraft.world.entity.Entity; import org.jspecify.annotations.Nullable; @@ -12,23 +14,37 @@ */ public final class ClientAccess { private static Supplier<@Nullable Entity> localPlayer = () -> null; - private static Runnable contentReloaded = () -> { + private static PackReceiver packs = new PackReceiver() { + @Override + public void onPackList(PackListPacket packet) { + } + + @Override + public void onPackData(PackDataPacket packet) { + } }; private ClientAccess() { } - public static void install(Supplier<@Nullable Entity> player, Runnable onContentReloaded) { + public static void install(Supplier<@Nullable Entity> player, PackReceiver receiver) { localPlayer = player; - contentReloaded = onContentReloaded; + packs = receiver; } public static @Nullable Entity localPlayer() { return localPlayer.get(); } - /** The server reloaded the content. Runs on the client's main thread. */ - public static void contentReloaded() { - contentReloaded.run(); + /** Where the server's content pack packets go. Called on the client's main thread. */ + public static PackReceiver packs() { + return packs; + } + + /** The client half of the content pack sync. */ + public interface PackReceiver { + void onPackList(PackListPacket packet); + + void onPackData(PackDataPacket packet); } } From 0686ef1d5e9bb8a0948b559d43bbe3499c4a411f Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:36 +0800 Subject: [PATCH 30/61] Reload the content packs and send players the new list --- .../com/jnhprojects/mcheli/ContentReload.java | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/ContentReload.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/ContentReload.java index e451476..44df1d1 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/ContentReload.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/ContentReload.java @@ -1,33 +1,26 @@ package com.jnhprojects.mcheli; import com.jnhprojects.mcheli.content.ContentRegistry; -import com.jnhprojects.mcheli.network.ContentReloadPacket; -import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.PackSync; import net.minecraft.server.MinecraftServer; -import java.io.IOException; - /** * Reads the vehicle and weapon definitions from disk again while the game runs, for - * {@code /mcheli reload}. Vehicles in the world pick up their new definition on their next tick - * (see {@code AircraftEntity#definition}); clients are told to refresh models and textures. - * - *

Until content packs sync definitions (phase 6), each side reads its own copy of the imported - * folder: on a dedicated server, clients only see the change if their files changed the same way. + * {@code /mcheli reload}: the imported content and the content packs, with the server config + * applied. Vehicles in the world pick up their new definition on their next tick (see + * {@code AircraftEntity#definition}). Every player gets the new pack list, downloads what changed + * and rebuilds their content the same way. */ public final class ContentReload { private ContentReload() { } - /** - * @return the newly loaded content, already in use - * @throws IOException when the folder could not be read; the old content then stays in place - */ - public static ContentRegistry reload(MinecraftServer server) throws IOException { - ContentRegistry fresh = ContentRegistry.load(MCHeli.legacyContent().assets()); + /** @return the newly loaded content, already in use */ + public static ContentRegistry reload(MinecraftServer server) { + ContentRegistry fresh = MCHeli.packs().load(true); MCHeli.content().replaceWith(fresh); MCHeli.LOGGER.info("Content reloaded: {} vehicles, {} weapons", fresh.all().size(), fresh.weapons().size()); - ModNetwork.sendToAll(server, new ContentReloadPacket()); + PackSync.sendListToAll(server); return fresh; } } From b69e4b4603810c842d421fe23bb1537f85e9df14 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:36 +0800 Subject: [PATCH 31/61] Load content packs at startup and again when a server starts --- .../java/com/jnhprojects/mcheli/MCHeli.java | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/MCHeli.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/MCHeli.java index abb83db..bf183df 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/MCHeli.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/MCHeli.java @@ -3,6 +3,7 @@ import com.jnhprojects.mcheli.client.ClientSetup; import com.jnhprojects.mcheli.content.ContentRegistry; import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.PackSync; import com.jnhprojects.mcheli.registry.ModCreativeTabs; import com.jnhprojects.mcheli.registry.ModDataComponents; import com.jnhprojects.mcheli.registry.ModEntities; @@ -10,6 +11,7 @@ import com.jnhprojects.mcheli.registry.ModParticles; import com.jnhprojects.mcheli.registry.ModRecipes; import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.event.server.ServerAboutToStartEvent; import net.minecraftforge.eventbus.api.bus.BusGroup; import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.config.ModConfig; @@ -19,16 +21,13 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.file.Path; - @Mod(MCHeli.MOD_ID) public final class MCHeli { public static final String MOD_ID = "mcheli"; public static final Logger LOGGER = LoggerFactory.getLogger(MCHeli.class); private static LegacyContent legacyContent; + private static ContentPacks packs; private static ContentRegistry content; public MCHeli(FMLJavaModLoadingContext context) { @@ -36,7 +35,10 @@ public MCHeli(FMLJavaModLoadingContext context) { legacyContent = LegacyContent.prepare(FMLPaths.GAMEDIR.get()); legacyContent.registerPacks(); - content = loadContent(legacyContent.assets()); + packs = new ContentPacks(FMLPaths.GAMEDIR.get(), legacyContent); + content = packs.load(false); + // The server config (allow list, size limits) exists from here on, before any world loads. + ServerAboutToStartEvent.BUS.addListener(event -> content.replaceWith(packs.load(true))); context.registerConfig(ModConfig.Type.SERVER, ServerConfig.SPEC); context.registerConfig(ModConfig.Type.CLIENT, ClientConfig.SPEC); ModDataComponents.register(modBus); @@ -46,6 +48,7 @@ public MCHeli(FMLJavaModLoadingContext context) { ModCreativeTabs.register(modBus); ModRecipes.register(modBus); ModNetwork.register(); + PackSync.init(); McheliCommand.register(); if (FMLEnvironment.dist == Dist.CLIENT) { @@ -54,24 +57,21 @@ public MCHeli(FMLJavaModLoadingContext context) { } /** - * Vehicle and weapon definitions, imported from the original zip. Available from mod - * construction onwards. Always the same object; a reload changes what it holds. + * Vehicle and weapon definitions, from the imported original content and the content packs. + * Available from mod construction onwards. Always the same object; a reload changes what it + * holds. */ public static ContentRegistry content() { return content; } - /** Where the vehicle content came from, and why it is missing if it is. */ + /** Where the original vehicle content came from, and why it is missing if it is. */ public static LegacyContent legacyContent() { return legacyContent; } - private static ContentRegistry loadContent(Path assets) { - try { - return ContentRegistry.load(assets); - } catch (IOException e) { - LOGGER.error("Could not read vehicle definitions from {}", assets, e); - throw new UncheckedIOException(e); - } + /** The content packs in {@code mcheli/packs} and the ones in use. */ + public static ContentPacks packs() { + return packs; } } From f26b3fc70073a6319215fd7f241212c6c358ce8c Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:37 +0800 Subject: [PATCH 32/61] Keep 1.0.0 items for the imported vehicles only --- .../main/java/com/jnhprojects/mcheli/registry/ModItems.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java index d59afe0..73db594 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/registry/ModItems.java @@ -3,6 +3,7 @@ import com.jnhprojects.mcheli.MCHeli; import com.jnhprojects.mcheli.content.ContentRegistry; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.pack.ContentSource; import com.jnhprojects.mcheli.item.AircraftItem; import com.jnhprojects.mcheli.item.LegacyAircraftItem; import net.minecraft.world.item.Item; @@ -38,7 +39,8 @@ private static RegistryObject kind(String kind) { public static void register(BusGroup modBus, ContentRegistry content) { for (AircraftDefinition def : content.all()) { String name = def.name(); - if (BY_KIND.containsKey(name)) { + // Only the imported content existed in 1.0.0; pack vehicles never had their own item. + if (!def.pack().equals(ContentSource.ORIGINAL) || BY_KIND.containsKey(name)) { // 1.0.0 could not have had such an item either: it would have clashed the same way. continue; } From 91352fbcee50146f87aeeac9a557077290d422ec Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:37 +0800 Subject: [PATCH 33/61] Use the kind's item model for pack vehicles --- .../com/jnhprojects/mcheli/item/AircraftItem.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java index 94d93fa..de94baf 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/item/AircraftItem.java @@ -3,6 +3,7 @@ import com.jnhprojects.mcheli.MCHeli; import com.jnhprojects.mcheli.ServerConfig; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.pack.ContentSource; import com.jnhprojects.mcheli.entity.AircraftEntity; import com.jnhprojects.mcheli.registry.ModDataComponents; import com.jnhprojects.mcheli.registry.ModEntities; @@ -47,11 +48,17 @@ public String kind() { return kind; } - /** A stack that places {@code def}. */ + /** + * A stack that places {@code def}. Vehicles from the import show the item model the import + * wrote for them; item models only load with the resources, so pack vehicles keep their kind's + * own model. + */ public static ItemStack stackOf(AircraftDefinition def) { ItemStack stack = new ItemStack(ModItems.forKind(def.kind())); stack.set(ModDataComponents.VEHICLE.get(), def.name()); - stack.set(DataComponents.ITEM_MODEL, Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, def.name())); + if (def.pack().equals(ContentSource.ORIGINAL)) { + stack.set(DataComponents.ITEM_MODEL, Identifier.fromNamespaceAndPath(MCHeli.MOD_ID, def.name())); + } return stack; } From f40c5e25eeb1bbe66cc3fddc736195ed0b1ca995 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:38 +0800 Subject: [PATCH 34/61] Add /mcheli packs and report pack problems after a reload --- .../com/jnhprojects/mcheli/McheliCommand.java | 47 ++++++++++++++----- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/McheliCommand.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/McheliCommand.java index 79f8b97..0e4c226 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/McheliCommand.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/McheliCommand.java @@ -2,6 +2,7 @@ import com.jnhprojects.mcheli.content.ContentRegistry; import com.jnhprojects.mcheli.content.aircraft.AircraftDefinition; +import com.jnhprojects.mcheli.content.pack.ContentPack; import com.jnhprojects.mcheli.item.AircraftItem; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.IntegerArgumentType; @@ -18,19 +19,20 @@ import net.minecraft.util.Prediction; import net.minecraftforge.event.RegisterCommandsEvent; -import java.io.IOException; import java.util.Collection; import java.util.List; import java.util.stream.Collectors; /** * {@code /mcheli}: what content is loaded, and operator tools to hand out vehicles and reload the - * definitions. + * definitions and content packs. *

  *   /mcheli                              content source and counts
  *   /mcheli list [helicopter|plane|...]  loaded vehicles
+ *   /mcheli packs                        content packs in use, and their problems
+|plane|...]  loaded vehicles
  *   /mcheli give <players> <vehicle> [count]   (operators)
- *   /mcheli reload                       re-read the definitions (operators)
+ *   /mcheli reload                       re-read the definitions and packs (operators)
  * 
*/ public final class McheliCommand { @@ -53,6 +55,8 @@ private static void register(CommandDispatcher dispatcher) { .then(Commands.argument("kind", StringArgumentType.word()) .suggests((ctx, builder) -> SharedSuggestionProvider.suggest(KINDS, builder)) .executes(ctx -> list(ctx, StringArgumentType.getString(ctx, "kind"))))) + .then(Commands.literal("packs") + .executes(McheliCommand::packs)) .then(Commands.literal("give") .requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)) .then(Commands.argument("targets", EntityArgument.players()) @@ -74,9 +78,31 @@ private static int status(CommandContext ctx) { source.sendSuccess(() -> Component.translatable("commands.mcheli.status", content.helicopters().size(), content.planes().size(), content.tanks().size(), content.vehicles().size(), content.weapons().size(), MCHeli.legacyContent().assets().toString()), false); + List packs = MCHeli.packs().active(); + source.sendSuccess(() -> Component.translatable("commands.mcheli.status.packs", packs.size(), + packs.stream().map(ContentPack::id).collect(Collectors.joining(", "))), false); return content.all().size(); } + private static int packs(CommandContext ctx) { + CommandSourceStack source = ctx.getSource(); + List packs = MCHeli.packs().active(); + if (packs.isEmpty()) { + source.sendSuccess(() -> Component.translatable("commands.mcheli.packs.none", MCHeli.packs().folder().toString()), false); + } else { + source.sendSuccess(() -> Component.translatable("commands.mcheli.packs.header", packs.size(), MCHeli.packs().folder().toString()), false); + } + ContentRegistry content = MCHeli.content(); + for (ContentPack pack : packs) { + long vehicles = content.all().stream().filter(def -> def.pack().equals(pack.id())).count(); + long weapons = content.weapons().stream().filter(def -> def.pack().equals(pack.id())).count(); + source.sendSuccess(() -> Component.translatable("commands.mcheli.packs.entry", pack.id(), pack.manifest().version(), + pack.manifest().name(), vehicles, weapons, ContentPack.shortHash(pack.hash())), false); + } + MCHeli.packs().reportProblems(source::sendFailure); + return packs.size(); + } + private static int list(CommandContext ctx, String kind) { List defs = MCHeli.content().all().stream() .filter(def -> kind == null || def.kind().equals(kind)) @@ -106,15 +132,10 @@ private static int give(CommandContext ctx, int count) throw private static int reload(CommandContext ctx) { CommandSourceStack source = ctx.getSource(); - try { - ContentRegistry fresh = ContentReload.reload(source.getServer()); - source.sendSuccess(() -> Component.translatable("commands.mcheli.reload.success", - fresh.all().size(), fresh.weapons().size()), true); - return fresh.all().size(); - } catch (IOException e) { - MCHeli.LOGGER.error("Could not reload the MCHeli content", e); - source.sendFailure(Component.translatable("commands.mcheli.reload.failed", e.getMessage())); - return 0; - } + ContentRegistry fresh = ContentReload.reload(source.getServer()); + source.sendSuccess(() -> Component.translatable("commands.mcheli.reload.success", + fresh.all().size(), fresh.weapons().size(), MCHeli.packs().active().size()), true); + MCHeli.packs().reportProblems(source::sendFailure); + return fresh.all().size(); } } From 78e2c43478ba48250f49c71c1279b5b5799d37d5 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:38 +0800 Subject: [PATCH 35/61] Swap the runtime asset roots in one step and release stale textures --- .../mcheli/client/RuntimeAssets.java | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java index 32e6b93..c19c881 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/RuntimeAssets.java @@ -17,7 +17,6 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArrayList; /** * Vehicle textures, models and sounds that can change while the game runs, without a resource @@ -26,7 +25,7 @@ * *

Lookups try the resource manager first, so resource packs keep the last word, then the * runtime roots: folders laid out like {@code assets/mcheli} that content can add at any time. - * Content packs (phase 6) and the workshop register their folders here. + * Content packs and, later, the workshop register their folders here. *

    *
  • Textures found only in a runtime root are uploaded as a {@link DynamicTexture} the first * time a vehicle draws them. {@link #refreshTextures} uploads every vehicle texture again @@ -39,22 +38,20 @@ public final class RuntimeAssets { private static final Logger LOGGER = LoggerFactory.getLogger(RuntimeAssets.class); - private static final List ROOTS = new CopyOnWriteArrayList<>(); + /** Searched in order, so the pack loaded last comes first. Replaced as a whole. */ + private static volatile List roots = List.of(); /** Every vehicle texture asked for, and whether we uploaded it ourselves. */ private static final Map TEXTURES = new ConcurrentHashMap<>(); private RuntimeAssets() { } - /** Adds a folder laid out like {@code assets/mcheli}, searched after the resource packs. */ - public static void addRoot(Path root) { - if (!ROOTS.contains(root)) { - ROOTS.add(root); - } - } - - public static void removeRoot(Path root) { - ROOTS.remove(root); + /** + * Sets the folders laid out like {@code assets/mcheli} that are searched after the resource + * packs, the first one first. Content packs set theirs whenever the packs in use change. + */ + public static void setRoots(List folders) { + roots = List.copyOf(folders); } /** Opens an {@code mcheli} asset from the resource packs, or else from a runtime root. */ @@ -83,11 +80,16 @@ public static Identifier texture(Identifier id) { /** * Uploads every texture vehicles have drawn so far again, read fresh from disk. Resource pack - * textures are replaced too, until the next resource reload loads them the normal way. + * textures are replaced too, until the next resource reload loads them the normal way. One that + * no longer exists anywhere, because its pack is gone, is released. */ public static void refreshTextures() { - for (Identifier id : TEXTURES.keySet()) { - TEXTURES.put(id, upload(id)); + for (Map.Entry entry : TEXTURES.entrySet()) { + boolean ours = upload(entry.getKey()); + if (!ours && entry.getValue()) { + Minecraft.getInstance().getTextureManager().release(entry.getKey()); + } + entry.setValue(ours); } } @@ -128,7 +130,7 @@ public static void clearTextures() { } private static @Nullable Path find(String path) { - for (Path root : ROOTS) { + for (Path root : roots) { Path file = root.resolve(path).normalize(); if (file.startsWith(root) && Files.isRegularFile(file)) { return file; From 501c51964984806722a09b33ea97106169dd0980 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:39 +0800 Subject: [PATCH 36/61] Turn the client reload into a refresh of models, textures and tabs --- .../mcheli/client/ClientContentReload.java | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java index 3f0735a..70bcc23 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientContentReload.java @@ -1,34 +1,21 @@ package com.jnhprojects.mcheli.client; -import com.jnhprojects.mcheli.MCHeli; import com.jnhprojects.mcheli.client.render.MeshCache; -import com.jnhprojects.mcheli.content.ContentRegistry; import com.jnhprojects.mcheli.registry.ModCreativeTabs; import net.minecraft.client.Minecraft; -import net.minecraft.network.chat.Component; import net.minecraft.world.item.CreativeModeTab; -import java.io.IOException; - -/** The client half of {@code /mcheli reload}. */ +/** + * Makes the client show the content it now holds: after the server's pack list was applied, which + * also happens after {@code /mcheli reload}. + */ final class ClientContentReload { private ClientContentReload() { } - static void run() { + /** Drops cached models and textures so edited and new files load, and rebuilds the creative tabs. */ + static void refresh() { Minecraft mc = Minecraft.getInstance(); - // In single player the integrated server already swapped the shared registry. - if (mc.getSingleplayerServer() == null) { - try { - MCHeli.content().replaceWith(ContentRegistry.load(MCHeli.legacyContent().assets())); - } catch (IOException e) { - MCHeli.LOGGER.error("Could not reload the MCHeli content", e); - if (mc.player != null) { - mc.player.sendSystemMessage(Component.translatable("commands.mcheli.reload.failed", e.getMessage())); - } - } - } - MeshCache.INSTANCE.clear(); RuntimeAssets.refreshTextures(); if (mc.level != null && mc.player != null) { From 7438ef94a810f2ceba8c4ad43e2cc6cb6ce6832d Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:39 +0800 Subject: [PATCH 37/61] Download, cache and apply the server's content packs on the client --- .../mcheli/client/ClientPacks.java | 325 ++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java new file mode 100644 index 0000000..6679322 --- /dev/null +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientPacks.java @@ -0,0 +1,325 @@ +package com.jnhprojects.mcheli.client; + +import com.jnhprojects.mcheli.ClientConfig; +import com.jnhprojects.mcheli.MCHeli; +import com.jnhprojects.mcheli.content.ContentRegistry; +import com.jnhprojects.mcheli.content.pack.ContentPack; +import com.jnhprojects.mcheli.content.pack.ContentSource; +import com.jnhprojects.mcheli.content.pack.PackException; +import com.jnhprojects.mcheli.entity.ClientAccess; +import com.jnhprojects.mcheli.network.ModNetwork; +import com.jnhprojects.mcheli.network.PackDataPacket; +import com.jnhprojects.mcheli.network.PackListPacket; +import com.jnhprojects.mcheli.network.PackRequestPacket; +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; +import net.minecraftforge.fml.loading.FMLPaths; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * The client half of the content pack sync (the server half is {@code PackSync}). + * + *

    When the server sends its pack list, every pack this client has neither in its own + * {@code mcheli/packs} nor in {@code mcheli/cache} is requested. Each arrives in chunks, is checked + * against its hash and kept as {@code mcheli/cache/.zip}. Once all are there, the content is + * rebuilt from the client's own import plus exactly the server's packs, in the server's order, so + * the vehicle a player flies (which their client simulates) has the server's definition. + * + *

    In single player, and for the host of a LAN game, client and server share one registry, so + * only the client's view of the server's packs is refreshed. + * + *

    The cache is capped by {@code packs.cacheSize} in the client config: after a download, the + * packs used longest ago go first. The current server's packs are always kept. + */ +final class ClientPacks implements ClientAccess.PackReceiver { + static final ClientPacks INSTANCE = new ClientPacks(); + + private static final Pattern CACHED = Pattern.compile("[0-9a-f]{64}\\.zip"); + + private final Path cache = FMLPaths.GAMEDIR.get().resolve(MCHeli.MOD_ID).resolve("cache"); + private List wanted = List.of(); + private final Map downloads = new LinkedHashMap<>(); + /** Packs this class opened for the current server; closed when the next list replaces them. */ + private List mounted = List.of(); + private int downloaded; + + private ClientPacks() { + } + + @Override + public void onPackList(PackListPacket packet) { + if (Minecraft.getInstance().hasSingleplayerServer()) { + useRoots(MCHeli.packs().active()); + ClientContentReload.refresh(); + return; + } + + wanted = packet.packs(); + downloads.clear(); + downloaded = 0; + long cap = ClientConfig.packCacheBytes(); + List request = new ArrayList<>(); + long bytes = 0; + for (PackListPacket.Entry entry : wanted) { + if (find(entry) != null) { + continue; + } + if (entry.size() > cap || entry.size() <= 0) { + say(Component.translatable("message.mcheli.pack.too_large", entry.id(), mb(entry.size()), cap >> 20)); + continue; + } + downloads.put(entry.hash(), new Download(entry)); + request.add(entry.hash()); + bytes += entry.size(); + } + + if (request.isEmpty()) { + apply(); + } else { + say(Component.translatable("message.mcheli.pack.downloading", request.size(), mb(bytes))); + ModNetwork.sendToServer(new PackRequestPacket(request)); + } + } + + @Override + public void onPackData(PackDataPacket packet) { + Download download = downloads.get(packet.hash()); + if (download == null) { + return; + } + if (!download.accept(packet)) { + fail(download, "the server sent a chunk out of order"); + return; + } + if (!download.complete()) { + return; + } + downloads.remove(packet.hash()); + try { + store(download); + downloaded++; + } catch (IOException | PackException e) { + fail(download, e.getMessage()); + return; + } + if (downloads.isEmpty()) { + apply(); + } + } + + /** Leaving a server: its packs are no longer needed. */ + void loggedOut() { + downloads.clear(); + wanted = List.of(); + RuntimeAssets.setRoots(List.of()); + close(mounted); + mounted = List.of(); + } + + private void fail(Download download, String reason) { + downloads.remove(download.entry.hash()); + MCHeli.LOGGER.error("Content pack {} could not be downloaded: {}", download.entry.id(), reason); + say(Component.translatable("message.mcheli.pack.failed", download.entry.id(), reason)); + if (downloads.isEmpty()) { + apply(); + } + } + + /** Writes a finished download to the cache once its content matches the hash it was listed with. */ + private void store(Download download) throws IOException, PackException { + String hash = download.entry.hash(); + Files.createDirectories(cache); + Path part = cache.resolve(hash + ".download.zip"); + Files.write(part, download.data); + try (ContentPack pack = ContentPack.open(part)) { + if (!pack.hash().equals(hash)) { + throw new PackException("its files do not match the hash the server listed"); + } + } catch (IOException | PackException e) { + Files.deleteIfExists(part); + throw e; + } + Files.move(part, cache.resolve(hash + ".zip"), StandardCopyOption.REPLACE_EXISTING); + } + + /** Rebuilds the content from the import and the server's packs that are now on disk. */ + private void apply() { + List opened = new ArrayList<>(); + List sources = new ArrayList<>(); + sources.add(new ContentSource(ContentSource.ORIGINAL, MCHeli.legacyContent().assets())); + for (PackListPacket.Entry entry : wanted) { + Path file = find(entry); + if (file == null) { + continue; + } + try { + ContentPack pack = ContentPack.open(file); + if (!pack.hash().equals(entry.hash())) { + pack.close(); + MCHeli.LOGGER.warn("Content pack {} changed on disk and no longer matches the server's", file); + continue; + } + opened.add(pack); + sources.add(pack.contentSource()); + if (file.startsWith(cache)) { + Files.setLastModifiedTime(file, FileTime.from(Instant.now())); + } + } catch (IOException | PackException e) { + MCHeli.LOGGER.error("Could not open content pack {}", file, e); + } + } + + ContentRegistry registry = ContentRegistry.load(sources); + MCHeli.content().replaceWith(registry); + List old = mounted; + mounted = List.copyOf(opened); + useRoots(mounted); + close(old); + ClientContentReload.refresh(); + trimCache(); + if (downloaded > 0) { + say(Component.translatable("message.mcheli.pack.ready", downloaded)); + downloaded = 0; + } + } + + /** The pack on disk with the entry's content: one of the player's own packs, or a cached download. */ + private @Nullable Path find(PackListPacket.Entry entry) { + for (ContentPack own : MCHeli.packs().active()) { + if (own.hash().equals(entry.hash())) { + return own.source(); + } + } + Path cached = cache.resolve(entry.hash() + ".zip"); + return Files.isRegularFile(cached) ? cached : null; + } + + /** Deletes the cached packs used longest ago until the cache fits its cap. */ + private void trimCache() { + Set keep = new HashSet<>(); + for (PackListPacket.Entry entry : wanted) { + keep.add(entry.hash() + ".zip"); + } + List files; + try (Stream list = Files.list(cache)) { + files = list.filter(p -> CACHED.matcher(p.getFileName().toString()).matches()) + .sorted(Comparator.comparing(ClientPacks::lastUsed)) + .toList(); + } catch (IOException e) { + return; + } + long total = 0; + for (Path file : files) { + total += size(file); + } + long cap = ClientConfig.packCacheBytes(); + for (Path file : files) { + if (total <= cap) { + break; + } + if (keep.contains(file.getFileName().toString())) { + continue; + } + try { + long size = size(file); + Files.delete(file); + total -= size; + MCHeli.LOGGER.info("Removed {} from the content pack cache", file.getFileName()); + } catch (IOException e) { + MCHeli.LOGGER.warn("Could not remove {} from the content pack cache", file, e); + } + } + } + + private static FileTime lastUsed(Path file) { + try { + return Files.getLastModifiedTime(file); + } catch (IOException e) { + return FileTime.fromMillis(0); + } + } + + private static long size(Path file) { + try { + return Files.size(file); + } catch (IOException e) { + return 0; + } + } + + /** The pack loaded last overrides the others, so it is searched first. */ + private static void useRoots(List packs) { + List roots = new ArrayList<>(); + for (ContentPack pack : packs) { + roots.addFirst(pack.assets()); + } + RuntimeAssets.setRoots(roots); + } + + private static void close(List packs) { + for (ContentPack pack : packs) { + try { + pack.close(); + } catch (IOException e) { + MCHeli.LOGGER.warn("Could not close content pack {}", pack, e); + } + } + } + + private static void say(Component message) { + Minecraft mc = Minecraft.getInstance(); + if (mc.player != null) { + mc.player.sendSystemMessage(message); + } else { + MCHeli.LOGGER.info(message.getString()); + } + } + + private static String mb(long bytes) { + return "%.1f".formatted(bytes / 1048576.0); + } + + /** One pack arriving from the server. */ + private static final class Download { + private final PackListPacket.Entry entry; + private byte @Nullable [] data; + private int received; + + Download(PackListPacket.Entry entry) { + this.entry = entry; + } + + boolean accept(PackDataPacket packet) { + if (packet.total() != entry.size() || packet.offset() != received + || received + packet.data().length > entry.size()) { + return false; + } + if (data == null) { + data = new byte[entry.size()]; + } + System.arraycopy(packet.data(), 0, data, received, packet.data().length); + received += packet.data().length; + return true; + } + + boolean complete() { + return data != null && received == data.length; + } + } +} From 4aa595eccb415588a30ab8fae53ab9a0e6a9c624 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:39 +0800 Subject: [PATCH 38/61] Install the client pack sync and close packs on logout --- .../main/java/com/jnhprojects/mcheli/client/ClientSetup.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientSetup.java b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientSetup.java index 3d11436..e29e911 100644 --- a/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientSetup.java +++ b/forge-26.3/src/main/java/com/jnhprojects/mcheli/client/ClientSetup.java @@ -28,7 +28,7 @@ private ClientSetup() { } public static void init(BusGroup modBus) { - ClientAccess.install(() -> Minecraft.getInstance().player, ClientContentReload::run); + ClientAccess.install(() -> Minecraft.getInstance().player, ClientPacks.INSTANCE); EntityRenderersEvent.RegisterRenderers.BUS.addListener(event -> { event.registerEntityRenderer(ModEntities.HELICOPTER.get(), AircraftRenderer::new); event.registerEntityRenderer(ModEntities.PLANE.get(), AircraftRenderer::new); @@ -56,6 +56,7 @@ public static void init(BusGroup modBus) { // Without the original zip there are no vehicles, so say so where the player will see it. ClientPlayerNetworkEvent.LoggingIn.BUS.addListener(event -> MCHeli.legacyContent().problem().ifPresent(event.getPlayer()::sendSystemMessage)); + ClientPlayerNetworkEvent.LoggingOut.BUS.addListener(event -> ClientPacks.INSTANCE.loggedOut()); CrewController.init(); WeaponController.init(); VehiclePicker.init(); From 2feb9ae69d8b7929fbc9c644ea52757564e65d8a Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:40 +0800 Subject: [PATCH 39/61] Add English text for content packs --- .../main/resources/assets/mcheli/lang/en_us.json | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/common/src/main/resources/assets/mcheli/lang/en_us.json b/common/src/main/resources/assets/mcheli/lang/en_us.json index f4f2e9d..5155990 100644 --- a/common/src/main/resources/assets/mcheli/lang/en_us.json +++ b/common/src/main/resources/assets/mcheli/lang/en_us.json @@ -1,9 +1,12 @@ { "commands.mcheli.give": "Gave %s %s to %s players", "commands.mcheli.list": "%s vehicles: %s", - "commands.mcheli.reload.failed": "Could not reload the MCHeli content: %s. See logs/latest.log.", - "commands.mcheli.reload.success": "Reloaded %s vehicles and %s weapons", + "commands.mcheli.packs.entry": "%s %s, %s: %s vehicles, %s weapons, hash %s", + "commands.mcheli.packs.header": "%s content packs from %s:", + "commands.mcheli.packs.none": "No content packs in use. Put them in %s", + "commands.mcheli.reload.success": "Reloaded %s vehicles and %s weapons from the import and %s content packs", "commands.mcheli.status": "MCHeli content: %s helicopters, %s planes, %s tanks, %s ground vehicles, %s weapons, from %s", + "commands.mcheli.status.packs": "%s content packs: %s", "commands.mcheli.unknown_vehicle": "No vehicle called %s", "death.attack.mcheli.bullet": "%1$s was shot down", "death.attack.mcheli.bullet.item": "%1$s was shot down by %2$s using %3$s", @@ -61,6 +64,12 @@ "key.mcheli.weapon_mode": "Weapon Mode", "message.mcheli.content.import_failed": "MCHeli could not import the original vehicles: %s. See logs/latest.log.", "message.mcheli.content.missing": "MCHeli has no vehicles yet. Download %s from the MCHeli CurseForge page, put it in %s and restart the game.", + "message.mcheli.pack.downloading": "MCHeli: downloading %s content packs (%s MB) from the server", + "message.mcheli.pack.failed": "MCHeli: content pack %s could not be downloaded: %s", + "message.mcheli.pack.more_problems": "MCHeli: and %s more problems with content packs, see logs/latest.log", + "message.mcheli.pack.problem": "MCHeli content pack %s: %s", + "message.mcheli.pack.ready": "MCHeli: %s content packs downloaded and ready", + "message.mcheli.pack.too_large": "MCHeli: content pack %s is %s MB, more than your cache allows (packs.cacheSize in mcheli-client.toml, %s MB). Its vehicles stay invisible.", "message.mcheli.vehicle.disabled": "%s is disabled on this server", "message.mcheli.vehicle.unknown": "This server has no vehicle called %s", "pack.mcheli.content": "MCHeli Content", From cee98eaceedcf06bc7b0b14a38bf3171b5a41576 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:40 +0800 Subject: [PATCH 40/61] Add Japanese text for content pack downloads --- common/src/main/resources/assets/mcheli/lang/ja_jp.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/common/src/main/resources/assets/mcheli/lang/ja_jp.json b/common/src/main/resources/assets/mcheli/lang/ja_jp.json index 58d3ebd..2ac248b 100644 --- a/common/src/main/resources/assets/mcheli/lang/ja_jp.json +++ b/common/src/main/resources/assets/mcheli/lang/ja_jp.json @@ -22,6 +22,10 @@ "key.mcheli.vtol": "VTOLモード", "message.mcheli.content.import_failed": "MCHeli: 元の機体データを読み込めませんでした: %s。logs/latest.log を確認してください。", "message.mcheli.content.missing": "MCHeli: 機体データがありません。MCHeli の CurseForge ページから %s をダウンロードし、%s に置いてからゲームを再起動してください。", + "message.mcheli.pack.downloading": "MCHeli: サーバーからコンテンツパックを %s 個 (%s MB) ダウンロードしています", + "message.mcheli.pack.failed": "MCHeli: コンテンツパック %s をダウンロードできませんでした: %s", + "message.mcheli.pack.ready": "MCHeli: コンテンツパック %s 個のダウンロードが完了しました", + "message.mcheli.pack.too_large": "MCHeli: コンテンツパック %s は %s MB で、キャッシュの上限 (mcheli-client.toml の packs.cacheSize、%s MB) を超えています。このパックの機体は表示されません。", "pack.mcheli.content": "MCHeli コンテンツ", "pack.mcheli.content.description": "EMB4 氏の MCHeli 1.0.3 から読み込み" } From 75695de1fb8e363938707225723c2b643b4027b0 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:41 +0800 Subject: [PATCH 41/61] Add the content pack author guide --- docs/content/packs.md | 143 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/content/packs.md diff --git a/docs/content/packs.md b/docs/content/packs.md new file mode 100644 index 0000000..96c77d0 --- /dev/null +++ b/docs/content/packs.md @@ -0,0 +1,143 @@ +# Content packs + +A content pack adds vehicles and weapons to MCHeli Rework without changing the mod. It is a zip +file (or a folder) holding definition files, models, textures and sounds, plus a short manifest. +Players put packs in `/mcheli/packs`. On a server only the server needs them: every +player who joins gets the server's packs sent automatically. + +A pack is data only. The game refuses a pack that holds anything but the file types listed +below, so installing or downloading one can never run code on your machine. + +The [example pack](../../examples/rework-example-pack) in this repository has one helicopter, the +Rework Scout, and is the quickest way to start: copy it, rename it and change it. + +## Layout + +``` +my-pack.zip + pack.properties the manifest (required) + README.md optional; a licence file is fine too + assets/mcheli/ + helicopters/my-heli.txt definitions, one file per vehicle + planes/ tanks/ vehicles/ the other vehicle kinds + weapons/my-gun.txt weapon definitions + models/helicopters/my-heli.obj model, same name as the definition + textures/helicopters/my-heli.png default paint, same name as the definition + textures/helicopters/my-heli-desert.png more paint schemes (AddTexture) + sounds/my-heli.ogg sounds, named by the definition's Sound key +``` + +The paths are the ones the original MCHeli used, so a definition, model and texture from an old +content pack can be moved into a pack as they are. A zip whose files all sit inside one folder +(what zipping a folder in Explorer or Finder gives you) works too. + +Rules the game checks when it loads a pack: + +* Only `pack.properties`, a readme (`.md`, `.txt`) and a licence may sit at the top. +* Under `assets/mcheli/` only definitions (`.txt`), models (`.obj`, `.mqo`, and the `.mtl` file + exporters write next to an OBJ, which the game ignores), textures (`.png`) and sounds (`.ogg`) + are allowed. +* File names under `assets/mcheli/` must be lower case. Minecraft identifiers cannot hold capital + letters, and a zip is case sensitive even where your disk is not. +* At most 10,000 files and 512 MB unpacked. Servers set much smaller limits of their own (see + [Servers](#servers)). + +Files your system leaves behind (`.DS_Store`, `Thumbs.db`, `desktop.ini`, `__MACOSX`) are ignored. + +## The manifest + +`pack.properties` names the pack: + +```properties +id = my-pack +name = My helicopters +author = Your name +version = 1.0.0 +description = Two light helicopters for search and rescue. +``` + +Only `id` is required. It is 1 to 64 characters of lower case letters, digits, `-`, `_` and `.`, +and must be unique: two packs with the same id cannot both load. `original` is reserved for the +imported 1.0.3 content. Raise `version` whenever you share a new build; players see it in +`/mcheli packs`. + +## Definitions, models, textures and sounds + +* **Definitions** use the same keys as the original vehicles: see [aircraft.md](aircraft.md) and + [weapons.md](weapons.md). The file name (without `.txt`) is the vehicle's id, used by + `/mcheli give` and saved in the world, so pick a name nobody else is likely to use (prefix it + with your pack id) and do not rename it once players have the vehicle. +* **Models** follow [modeling.md](modeling.md): nose along +Z, Y up, one unit per block, moving + parts as `$` objects. +* **Textures** are PNG files with the same UV layout as the model. Partly transparent pixels are + drawn as glass. +* **Sounds** are OGG files. A vehicle's engine plays the sound its `Sound` key names, so + `Sound = my-heli` plays `sounds/my-heli.ogg`. + +A vehicle can use weapons from its own pack, from another pack or from the imported content. +HUD scripts, crafting recipes and item icons are not part of packs yet: name an imported HUD in +the `HUD` key (`civilian_aircraft`, `ah-64` and so on; without the import the game shows a plain +text readout), hand pack vehicles out with the creative tabs or `/mcheli give`, and expect the +item to show the generic icon of its kind. + +## Load order and replacing vehicles + +The imported 1.0.3 content loads first, then the packs in id order. A definition with the same +file name as one loaded earlier replaces it, even if it is a different kind, so a pack can rework +an original vehicle by shipping its own `ah-64.txt`. The log says when that happens. + +Models and textures are found the other way round: resource packs first, then the packs, the one +loaded last first. The import's own models and textures come from its resource pack and therefore +win over a content pack. A pack that replaces an original vehicle keeps the original model and +default paint; it can add paint schemes of its own with `AddTexture`. To change the model too, +give the vehicle a new file name instead. + +## Trying a pack out + +1. Put the zip or folder in `/mcheli/packs`. +2. Start the game, or in a world you are an operator in, run `/mcheli reload`. +3. `/mcheli packs` lists the packs in use with their vehicle and weapon counts, and any problems. + +A broken pack never stops the game. A pack that cannot be used (bad manifest, a file type that is +not allowed, a zip that cannot be read) is left out; a definition with errors is skipped while the +rest of the pack loads; a bad line in a definition is skipped while the rest of the file loads. +Everything is written to `logs/latest.log`, and operators (and the owner of a single player +world) see the first few problems in the chat when they join and after each reload. + +Edit, save and run `/mcheli reload` again: vehicles in the world pick up the new definition +immediately, and every player sees the new model and textures. + +## Servers + +The server is the source of truth. When a player joins, the server sends the list of its packs; +the player's game downloads the ones it does not have yet and keeps them in +`/mcheli/cache`, named by content hash, so the next visit (or another server with the +same pack) needs no download. Players then use exactly the server's packs, in the server's order: +packs the player has installed but the server does not are switched off while they are connected. +The imported 1.0.3 content is never sent; every player imports their own copy. + +Server settings, in `serverconfig/mcheli-server.toml` of the world, section `[packs]`: + +| Setting | Default | Meaning | +|---------|---------|---------| +| `allowList` | `[]` | Pack ids the server loads. Empty loads every pack in the folder. | +| `maxPackSize` | `8` | Largest pack the server loads and sends, in MB. | +| `maxTotalSize` | `64` | Most the server loads and sends in all, in MB. Packs are taken in id order until the next one would go over. | + +A pack left out by these settings is reported like any other problem. Changes apply when the +world starts or after `/mcheli reload`, which also sends every player the new list. + +Client setting, in `config/mcheli-client.toml`, section `[packs]`: + +| Setting | Default | Meaning | +|---------|---------|---------| +| `cacheSize` | `256` | How much `mcheli/cache` may hold, in MB. The packs used longest ago are deleted first; the current server's packs are always kept. A single pack larger than this is not downloaded and its vehicles stay invisible. | + +Downloads run at about 5 MB a second per player alongside the game. A pack that arrives damaged or +does not match the server's hash is thrown away and reported in the chat. + +## Sharing a pack + +Zip the pack folder and share the zip. Players can drop it into `mcheli/packs`, or simply join a +server that has it. Only share what you have the right to: models, textures and sounds taken from +other mods or games usually may not be passed on. From 7b936deaf6d71ee45f613307fc85d281c55bb4e4 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:41 +0800 Subject: [PATCH 42/61] Point the modeling guide at content packs --- docs/content/modeling.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/content/modeling.md b/docs/content/modeling.md index e450c76..4c655bf 100644 --- a/docs/content/modeling.md +++ b/docs/content/modeling.md @@ -3,7 +3,7 @@ This guide covers the model side of a new vehicle: the part you build outside Minecraft. The vehicle itself (seats, rotors, wings, engine, weapons, flight values) is described by a definition file, see [aircraft.md](aircraft.md). The vehicle workshop planned for 1.1.0 (see -[ROADMAP.md](../ROADMAP.md), phase 5) will let you do that second half in game by clicking on the +[ROADMAP.md](../ROADMAP.md), phases 9 and 10) will let you do that second half in game by clicking on the model instead of typing coordinates. The model rules below are the same either way, so a model made today keeps working in the workshop. @@ -116,11 +116,12 @@ definition or click in the workshop. * Materials: off (the game uses the PNG named in the definition) 4. Save the texture as PNG. -## Before 1.1.0 +## Getting it into the game -1.0.0 has no supported way to add new vehicles: it loads only the imported 1.0.3 content. You can -still build and texture a model now with this guide, and check it in Blender or Blockbench. It -will load once vehicle packages arrive in 1.1.0. +Since 1.1.0-beta.2 a model, its textures and a definition go into a content pack, which players +install by dropping it into `mcheli/packs` and servers send to everyone who joins. See +[packs.md](packs.md), and the example pack in `examples/rework-example-pack`, whose Rework Scout +follows this guide. ## What the workshop will do with the model From 5b69efc6da18e83b65f956a368de7a65057fc588 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:42 +0800 Subject: [PATCH 43/61] Describe content packs and their sync in the architecture --- docs/ARCHITECTURE.md | 90 +++++++++++++++++++++++++++++++++----------- 1 file changed, 69 insertions(+), 21 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index aed482e..23ef79a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,7 +1,7 @@ # Architecture This document explains how MCHeli Rework is organized and why. It reflects the code as of -`1.1.0-beta.1`. +`1.1.0-beta.2`. ## Goals @@ -18,17 +18,18 @@ This document explains how MCHeli Rework is organized and why. It reflects the c ``` ┌────────────────────────────────────────────────────────────────┐ │ client renderer, mesh cache, runtime assets, crew │ client only - │ controller, sound, HUD renderer │ + │ controller, sound, HUD renderer, pack download │ ├────────────────────────────────────────────────────────────────┤ platform module │ entity AircraftEntity and one subclass per kind │ (forge-26.3) │ item AircraftItem, VehicleIngredient │ - │ network PilotStatePacket, CrewActionPacket, ... │ Minecraft + Forge + │ network PilotStatePacket, CrewActionPacket, PackSync │ Minecraft + Forge │ particle GlowParticleOptions │ │ registry entities, items, components, particles, tabs │ - │ (root) MCHeli, configs, McheliCommand, reload │ + │ (root) MCHeli, configs, McheliCommand, ContentPacks │ ├────────────────────────────────────────────────────────────────┤ │ content Definition, DefinitionReader, ContentRegistry, │ common module - │ aircraft/* definitions, recipe conversion │ + │ aircraft/* definitions, recipe conversion, │ + │ pack/* content packs │ │ model Mesh, MqoLoader, ObjLoader │ pure Java │ hud HudScript, Expression, HudVariable │ │ physics AircraftRotation, GroundImpact │ @@ -55,7 +56,7 @@ original, so existing resource paths and content packs keep working. MCHeli constructor ├─ LegacyContent.prepare import the original zip into mcheli/imported ├─ LegacyContent.registerPacks that folder as a required resource + data pack - ├─ ContentRegistry.load(mcheli/imported/assets/mcheli) parse helicopters, planes, tanks, vehicles + ├─ ContentPacks.load(false) the import plus every pack in mcheli/packs ├─ registerConfig(SERVER, ServerConfig.SPEC) serverconfig/mcheli-server.toml ├─ registerConfig(CLIENT, ClientConfig.SPEC) config/mcheli-client.toml ├─ ModDataComponents.register mcheli:vehicle @@ -65,6 +66,7 @@ MCHeli constructor ├─ ModCreativeTabs.register ├─ ModRecipes.register mcheli:vehicle ingredient, recipe book ├─ ModNetwork.register + ├─ PackSync.init pack list on join, downloads ├─ McheliCommand.register /mcheli └─ ClientSetup.init (client only) renderers, keys, reload listener, controller ``` @@ -72,8 +74,8 @@ MCHeli constructor Definitions are still parsed in the constructor, but only because the 1.0.0 items have to be registered before Forge freezes the registries (see [Vehicle items](#vehicle-items)); nothing else depends on them existing that early any more. The files are read straight from the imported -folder (see [Assets](#assets)). Models and textures, by contrast, go through the resource manager, -so resource packs can replace them. +folder and the content packs (see [Assets](#assets) and [Content packs](#content-packs)). Models +and textures, by contrast, go through the resource manager, so resource packs can replace them. ## Content definitions @@ -131,14 +133,60 @@ vehicle. ### Reloading content -`/mcheli reload` (operators) runs `ContentReload`: it loads the imported folder again into a new -registry and, if that worked, `replaceWith`s it into the live one, then sends -`ContentReloadPacket` to every player. Vehicles in the world pick up their new definition on -their next tick. On the client, `ClientContentReload` reloads the client's own registry (except -in single player, where client and integrated server share it), clears the mesh cache and glass -splits, uploads every vehicle texture again from disk (`RuntimeAssets.refreshTextures`), and -rebuilds the creative tabs. Until content packs sync definitions (phase 6), a dedicated server and -its clients each read their own files. +`/mcheli reload` (operators) runs `ContentReload`: `ContentPacks` scans the import and the packs +again with the server config applied, the result is `replaceWith`ed into the live registry, and +every player gets the new `PackListPacket`. Vehicles in the world pick up their new definition on +their next tick. Each client then rebuilds its own registry the way it does on joining (see +[Content packs](#content-packs)), except in single player, where client and integrated server share +it. `ClientContentReload.refresh` then clears the mesh cache and glass splits, uploads every +vehicle texture again from disk (`RuntimeAssets.refreshTextures`) and rebuilds the creative tabs. + +### Content packs + +A content pack (`content.pack.ContentPack`) is a zip or folder with a `pack.properties` manifest +and an `assets/mcheli` tree laid out like the import. Opening one walks every file: only +definitions, models, textures and sounds are allowed (so a pack can never carry code), file names +must be lower case, and the file count and unpacked size are capped while reading, not trusted +from the zip. The content hash is SHA-256 over each file's path and its own SHA-256, so it depends +on the files rather than on how they were zipped; servers and clients compare it to decide what +to send. Zips are read in place through a zip file system and stay open while in use. + +`ContentRegistry.load` takes a list of `ContentSource`s, each a pack id and an `assets/mcheli` +folder. Weapons load first from every source, so vehicles can use weapons from another pack; +then each source's vehicles in order, a later definition replacing an earlier one of the same +name whatever its kind. Every definition records its pack (`Definition.pack()`). Problems are +collected as `PackProblem`s instead of thrown: a file that cannot be read, a rejected definition, +a bad line, an unknown weapon. Those of the import are only logged, as before. + +`ContentPacks` (platform) owns `/mcheli/packs`. The constructor loads the import plus +every pack, so the vehicles exist before any world does. `ServerAboutToStartEvent`, the first +moment the per-world server config exists, loads again with the allow list and size limits +applied, and so does `/mcheli reload`. A replaced set of packs is closed one reload later, so the +client of a single player game never reads from a zip that was just closed. Only the import's +vehicles get a 1.0.0 item and an `item_model` component; item models only load with resources, so +pack vehicles show their kind's generic item. + +``` + server client + ────── ────── + player joins + ── PackListPacket(id, version, hash, size) ────▶ own mcheli/packs or mcheli/cache/.zip? + ◀─────────────── PackRequestPacket(hashes) ──── for the ones it has neither + PackSync, every tick, per player + ── PackDataPacket(hash, offset, 128 KiB) ──────▶ assembled in memory, size checked + (2 chunks a tick, one pack after another) written as .download.zip, opened, + hash checked, renamed to .zip + all there: ClientPacks.apply + import + the server's packs, in its order + replaceWith, RuntimeAssets roots, refresh + trim the cache to packs.cacheSize +``` + +The server only sends packs from its current list, by hash. `ClientPacks` uses exactly the +server's packs while connected, so the vehicle a player simulates has the server's definition; a +pack of the player's own counts as cached when its hash matches. The import is never sent. In +single player and for a LAN host, the client points `RuntimeAssets` at the server's open packs +instead. `RuntimeAssets.setRoots` swaps the folder list in one write, the pack loaded last first. ### Server config and command @@ -442,11 +490,11 @@ convert again (1.1.0-beta.1 bumped it to 2 for the generic item recipes). ### Runtime assets -Vehicle content can change while the game runs (`/mcheli reload` now, content packs and the -workshop later), and a resource reload is slow and does not exist on a server. The client -therefore reads vehicle assets through `client.RuntimeAssets`: the resource manager first, so -resource packs keep the last word, then any number of runtime roots, folders laid out like -`assets/mcheli` that content can register at any time. +Vehicle content can change while the game runs (`/mcheli reload`, content packs arriving from a +server, and the workshop later), and a resource reload is slow and does not exist on a server. +The client therefore reads vehicle assets through `client.RuntimeAssets`: the resource manager +first, so resource packs keep the last word, then the runtime roots, the `assets/mcheli` folders +of the content packs in use. - **Models**: `MeshCache` opens them through `RuntimeAssets.open`; clearing the cache is enough for edited or new files to load on the next frame. From 5f0148f208b541c332b5e516f8e3e82b46001a5d Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:42 +0800 Subject: [PATCH 44/61] Tick off the content pack items of phase 6 --- docs/ROADMAP.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3103af3..1794812 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -132,7 +132,7 @@ Rules that hold for every beta: | Phase | Version | Theme | State | |-------|---------|-------|-------| | 5 | `1.1.0-beta.1` | Groundwork: runtime registry, config, multi-version build | Released | -| 6 | `1.1.0-beta.2` | Content packs and multiplayer sync | Planned | +| 6 | `1.1.0-beta.2` | Content packs and multiplayer sync | In progress | | 7 | `1.1.0-beta.3` | Older Minecraft versions | Planned | | 8 | `1.1.0-beta.4` | Equipment and the rest of phase 4 | Planned | | 9 | `1.1.0-beta.5` | Workshop: model, parts and tuning | Planned | @@ -171,15 +171,15 @@ and the command. The external content packs from phase 4, in the package format the workshop will write later ([format](#vehicle-packages)). -- [ ] Package format: definition, OBJ or MQO model, PNG textures, OGG sounds and a manifest (id, +- [x] Package format: definition, OBJ or MQO model, PNG textures, OGG sounds and a manifest (id, author, version, content hash). Data only, never code. -- [ ] Packs loaded from `/mcheli/packs`; the 1.0.3 import becomes one more pack -- [ ] Load errors reported per pack (log, and chat for operators); a broken pack never stops the +- [x] Packs loaded from `/mcheli/packs`; the 1.0.3 import becomes one more pack +- [x] Load errors reported per pack (log, and chat for operators); a broken pack never stops the game from starting -- [ ] Server to client sync: id and hash list on join, missing packs sent in chunks and cached in +- [x] Server to client sync: id and hash list on join, missing packs sent in chunks and cached in `mcheli/cache/.zip` ([details](#multiplayer)) -- [ ] Server limits and an allow list in the config; clients cap the cache size -- [ ] Pack author guide in `docs/content/packs.md`, with a small example pack in the repo made +- [x] Server limits and an allow list in the config; clients cap the cache size +- [x] Pack author guide in `docs/content/packs.md`, with a small example pack in the repo made from our own assets (no EMB4 files) - [x] Night glow: tracers, rocket motors, flares and explosions glow, faint by day and bright at night; a crash keeps burning with a glow and the wreck burns for 30 seconds. Client config `effects.nightGlow` turns it off. From 0b0576ade94632a1042b8b5f487e4f2f1cd0c3ab Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:42 +0800 Subject: [PATCH 45/61] Add content packs to the phase 6 checklist --- docs/TESTING.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/TESTING.md b/docs/TESTING.md index a613ca5..a2e0d64 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -335,6 +335,32 @@ Set the time with `/time set midnight` and compare with `/time set noon`. stutter on each hit; the HP still goes down. Same driving a tank. - [ ] A rocket exploding next to an empty parked vehicle rocks it slightly, much less than before. +### Content packs + +Copy `examples/rework-example-pack` into `/mcheli/packs` first. + +- [ ] Single player: the Rework Scout is in the Helicopters tab (generic icon), places, renders + with its rotors turning and flies. `AddTexture` cycles to the red rescue paint. +- [ ] `/mcheli packs` lists `rework-example 1.0.0` with 1 vehicle; `/mcheli` shows 1 content pack. +- [ ] Zip the pack folder and replace the folder with the zip: same result after `/mcheli reload`. +- [ ] Edit `rework-scout.txt` (for example `Speed = 1.2`), `/mcheli reload`: a Scout already in the + world flies with the new speed without being picked up. +- [ ] Break the pack: add a `test.jar` at its top, `/mcheli reload`. The chat names the pack and the + file, the Scout is gone, everything else still works. Remove the jar, reload, it is back. +- [ ] Put `MaxHp = lots` in the definition: the chat names the file and line, the Scout still loads. +- [ ] Dedicated server with the pack, client without it: on joining, the chat says one pack is + downloading, then ready; the Scout renders and flies for the client. + `mcheli/cache/.zip` exists on the client. Rejoin: no download this time. +- [ ] A second client with the pack in its own `mcheli/packs`: no download. +- [ ] Client with a pack the server does not have: its vehicles are not in the tabs while connected. +- [ ] Server `packs.allowList = ["something-else"]`, `/mcheli reload`: the Scout disappears for + everyone and operators are told why. Empty list again, reload: it is back for everyone. +- [ ] Server `packs.maxPackSize = 1` with a pack over 1 MB: the pack is left out and reported. +- [ ] Client `packs.cacheSize = 16` with a server pack over 16 MB: the chat says it is too large, + nothing is downloaded and the game keeps running. +- [ ] Remove the pack from the server and restart it: a world with a Scout still loads; the Scout + stays in place, inert and invisible, and comes back when the pack does. + ## Reporting problems Attach `logs/latest.log` (and the crash report, if any) with the helicopter name, what you did From 9b157aa5ea915637371dd6a8b0c0defd4efcec30 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:43 +0800 Subject: [PATCH 46/61] Document content packs, /mcheli packs and the pack settings --- README.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 82ab940..fb5483f 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,8 @@ bottom right shows hit points, the weapon, ammunition, reload, heat, lock and fl | `/mcheli` | everyone | Where the vehicles were loaded from, and how many of each kind | | `/mcheli list [helicopter\|plane\|tank\|vehicle]` | everyone | The loaded vehicles by name | | `/mcheli give [count]` | operators | Hands out vehicles, for example `/mcheli give @s ah-64` | -| `/mcheli reload` | operators | Reads the vehicle and weapon files again without a restart | +| `/mcheli packs` | everyone | The content packs in use, and any problems with them | +| `/mcheli reload` | operators | Reads the vehicle and weapon files and the content packs again without a restart, and sends players the new packs | Each world has `serverconfig/mcheli-server.toml` (created on first start, edits apply while the server runs): @@ -235,12 +236,24 @@ server runs): | `vehicles.damageFactor` | `1.0` | Multiplies the damage vehicles take; `0.5` makes them twice as tough | | `vehicles.disabled` | `[]` | Vehicles that cannot be placed, by name, such as `["ah-64", "f-35b"]` | | `weapons.infiniteAmmo` | `false` | Vehicle weapons never run out of ammunition | +| `packs.allowList` | `[]` | Content packs the server loads, by id; empty loads all of them | +| `packs.maxPackSize` | `8` | Largest content pack the server loads and sends to players, in MB | +| `packs.maxTotalSize` | `64` | Most the server loads and sends in all, in MB | Each player also has `config/mcheli-client.toml`, which only changes what that player sees: | Setting | Default | Meaning | |---------|---------|---------| | `effects.nightGlow` | `true` | Tracers, rocket motors, flares and explosions glow: faintly by day, brightly at night, in caves and under water | +| `packs.cacheSize` | `256` | How much the content packs downloaded from servers may take up in `mcheli/cache`, in MB | + +## Content packs + +Content packs add vehicles and weapons: drop a pack (a zip or a folder) into +`/mcheli/packs`. On a server only the server needs it; players download the server's +packs automatically when they join. [examples/rework-example-pack](examples/rework-example-pack) +adds a small helicopter, the Rework Scout. To make your own, see +[docs/content/packs.md](docs/content/packs.md). ## Documentation @@ -249,7 +262,7 @@ Each player also has `config/mcheli-client.toml`, which only changes what that p * [Architecture](docs/ARCHITECTURE.md): how the mod is put together * [Development](docs/DEVELOPMENT.md): building, running, testing and releasing * [In-game test plan](docs/TESTING.md) -* Content authoring: [modeling in Blockbench or Blender](docs/content/modeling.md), [vehicles](docs/content/aircraft.md), [weapons](docs/content/weapons.md), [HUD scripts](docs/content/hud.md) +* Content authoring: [content packs](docs/content/packs.md), [modeling in Blockbench or Blender](docs/content/modeling.md), [vehicles](docs/content/aircraft.md), [weapons](docs/content/weapons.md), [HUD scripts](docs/content/hud.md) * [Contributing](CONTRIBUTING.md): rules for pull requests and bug reports ## Building from source From b205738439c862ebe752119cb6013130c7200fb8 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:10:43 +0800 Subject: [PATCH 47/61] Add content packs and sync to the changelog --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e44cbf..e250424 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,12 +17,30 @@ way to 1.0.0; see [docs/ROADMAP.md](docs/ROADMAP.md). - Wrecks stay for 30 seconds instead of 25 and keep burning until they disappear: flames, a fire glow and rising embers that die down over the last five seconds. Sunk wrecks do not burn. - Screenshots of night glow in `docs/images/phase-6`, shown in the README. +- Content packs: zips or folders in `/mcheli/packs` that add vehicles and weapons, with + their models, textures and sounds and a `pack.properties` manifest. Packs are data only; one + holding anything else is refused. A pack can replace an original vehicle by using its name. +- Servers send their packs to players when they join: each player downloads only the packs they + do not have yet and keeps them in `mcheli/cache`, so the next visit is instant. While connected, + players use exactly the server's packs. The original 1.0.3 content is never sent. +- Server settings under `[packs]` in `mcheli-server.toml`: an allow list, and size limits per pack + (8 MB) and for all packs together (64 MB). Client setting `packs.cacheSize` (256 MB) caps the + download cache, deleting the packs used longest ago first. +- Problems with a pack (a bad manifest, a refused file, a broken definition or line, an unknown + weapon) are written to the log and shown to operators in the chat on joining and after + `/mcheli reload`. A broken pack never stops the game from starting. +- `/mcheli packs` lists the packs in use with their vehicle and weapon counts. +- An example pack with our own helicopter, the Rework Scout (two paint schemes), in + `examples/rework-example-pack`, and a pack author guide, `docs/content/packs.md`. ### Changed - Vehicles no longer flash red when hit. - Hits no longer jolt a vehicle that someone is flying or driving, and explosions no longer push it. An empty vehicle still rocks a little from a nearby blast (a quarter of the old push). +- `/mcheli reload` now reloads the content packs too, with the server's settings, and sends every + player the new pack list. Before, a dedicated server and its players each reread their own files. +- The network protocol changed: players need the same version as the server. ## [1.1.0-beta.1] - 2026-10-29 From d3c68c5b8ee2c68030937dd3b7f5db2e86d51d3c Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:20:55 +0800 Subject: [PATCH 48/61] Dedicate the example pack to the public domain under CC0 1.0 --- examples/rework-example-pack/LICENSE | 121 +++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 examples/rework-example-pack/LICENSE diff --git a/examples/rework-example-pack/LICENSE b/examples/rework-example-pack/LICENSE new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/examples/rework-example-pack/LICENSE @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. From fd3a830634880161b5cf31a6b22a07534f161d4b Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:20:55 +0800 Subject: [PATCH 49/61] Explain the example pack's CC0 licence in its readme --- examples/rework-example-pack/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/rework-example-pack/README.md b/examples/rework-example-pack/README.md index bb40279..5659c1f 100644 --- a/examples/rework-example-pack/README.md +++ b/examples/rework-example-pack/README.md @@ -4,6 +4,10 @@ A content pack with one vehicle, the **Rework Scout**, a two seat light helicopt schemes. Everything in it was made for MCHeli Rework (none of it comes from the original 1.0.3 content), and it is meant as the starting point for your own pack. +The pack is dedicated to the public domain under [CC0 1.0](LICENSE): copy it, change it, rename +it and share or sell the result, with or without credit. This applies to this folder only, not to +MCHeli Rework itself. + To try it, copy this folder (or a zip of it) into `/mcheli/packs` and start the game, or run `/mcheli reload` in a world. The Scout appears in the MCHeli Helicopters creative tab. From 9f26021af0cfbdf9153b2ebaadd356f46f87babd Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:20:56 +0800 Subject: [PATCH 50/61] Note the example pack's licence in its manifest --- examples/rework-example-pack/pack.properties | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/rework-example-pack/pack.properties b/examples/rework-example-pack/pack.properties index c8a519e..136c720 100644 --- a/examples/rework-example-pack/pack.properties +++ b/examples/rework-example-pack/pack.properties @@ -1,4 +1,5 @@ # The manifest of a content pack. Only id is required; see docs/content/packs.md. +# This example pack is CC0 1.0 (public domain), see LICENSE. id = rework-example name = MCHeli Rework example pack author = JayMar921 From f8f7e67b7b0b0b2b8d0a07ee3c16f0243310765e Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:20:56 +0800 Subject: [PATCH 51/61] Mention that the example pack is public domain --- docs/content/packs.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/content/packs.md b/docs/content/packs.md index 96c77d0..92a4770 100644 --- a/docs/content/packs.md +++ b/docs/content/packs.md @@ -9,7 +9,8 @@ A pack is data only. The game refuses a pack that holds anything but the file ty below, so installing or downloading one can never run code on your machine. The [example pack](../../examples/rework-example-pack) in this repository has one helicopter, the -Rework Scout, and is the quickest way to start: copy it, rename it and change it. +Rework Scout, and is the quickest way to start: copy it, rename it and change it. It is public +domain (CC0 1.0), so what you build from it is entirely yours. ## Layout From c7d72f17b484d0dfd42121f641c8848b1ecd332b Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:26:00 +0800 Subject: [PATCH 52/61] Add the mod icon for the mods list --- forge-26.3/src/main/resources/mcheli_logo.png | Bin 0 -> 23745 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 forge-26.3/src/main/resources/mcheli_logo.png diff --git a/forge-26.3/src/main/resources/mcheli_logo.png b/forge-26.3/src/main/resources/mcheli_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..95c72198d76d40c8c513965b3c40c83712ee54cf GIT binary patch literal 23745 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 V`gRq~qyPf8|I~FK)~Q-Y{U4=vt$qLi literal 0 HcmV?d00001 From 2d1c127b902cb0c6d147b89593aa662cd2470f12 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:26:01 +0800 Subject: [PATCH 53/61] Point the mod metadata at the icon --- forge-26.3/src/main/resources/META-INF/mods.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/forge-26.3/src/main/resources/META-INF/mods.toml b/forge-26.3/src/main/resources/META-INF/mods.toml index fb64f88..b6481de 100644 --- a/forge-26.3/src/main/resources/META-INF/mods.toml +++ b/forge-26.3/src/main/resources/META-INF/mods.toml @@ -8,6 +8,7 @@ modId="${mod_id}" version="${mod_version}" displayName="${mod_name}" displayURL="https://github.com/JnH-Projects/MCHeli-Rework-Forge" +logoFile="mcheli_logo.png" authors="${mod_authors}" credits="EMB4 created the original MCHeli mod and all of its vehicle content. Fureniku maintains the CurseForge project." description=''' From 5f374552b2a14e3a0936c810841f2451d092a99b Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:26:01 +0800 Subject: [PATCH 54/61] Add the full size mod icon for the README and CurseForge --- docs/images/mcheli-rework-icon.png | Bin 0 -> 54774 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/images/mcheli-rework-icon.png diff --git a/docs/images/mcheli-rework-icon.png b/docs/images/mcheli-rework-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cd3fa7fd4c9c7a0eb5b506f6a9cbad46be73b6fc GIT binary patch literal 54774 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%0AEg1mZVJ#RQ7m<=bkudzC|pn6krcf4H&8Y_ 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 A_5c6? literal 0 HcmV?d00001 From 3a909999ced32d33cb7af776fef034786679e5fb Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:26:02 +0800 Subject: [PATCH 55/61] Show the mod icon at the top of the README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index fb5483f..7c20501 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # MCHeli Rework +MCHeli Rework icon + Helicopters, planes, tanks and ground vehicles for **Minecraft 26.3** on **Forge 66.1**. MCHeli Rework is a ground-up port of MCHeli 1.0.3, a mod that stopped at Minecraft 1.7.10. Every From 1e0ff76a2b6b6024e7aeaf28945d4aef0b5f3ae8 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:26:02 +0800 Subject: [PATCH 56/61] Note the mod icon in the changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e250424..94b7179 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ way to 1.0.0; see [docs/ROADMAP.md](docs/ROADMAP.md). - `/mcheli packs` lists the packs in use with their vehicle and weapon counts. - An example pack with our own helicopter, the Rework Scout (two paint schemes), in `examples/rework-example-pack`, and a pack author guide, `docs/content/packs.md`. +- A mod icon, shown in the mods list and the README. ### Changed From 17960561164e4a8cdd661f5edb8d51d886faba52 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:23:50 +0800 Subject: [PATCH 57/61] Add the MCHeli Rework Licence --- LICENSE | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..10fb3b9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,61 @@ +MCHeli Rework Licence, version 1.0 + +Copyright (c) 2026 JayMar921 (JnH Projects). All rights reserved, except as granted below. + +MCHeli Rework ("the Mod") is a rework of MCHeli 1.0.3 by EMB4 for newer versions of Minecraft. +The original MCHeli and its content (models, textures, sounds, definitions and documentation) +belong to EMB4, are not covered by this licence and are not included in this repository or in +the Mod's releases. + +1. What you may do + + a. Use. You may download the official releases of the Mod and use them, for free, in single + player, on servers (including public and paid servers) and in videos and streams. + + b. Modpacks. You may include an unmodified official release in a modpack, on CurseForge, + Modrinth or elsewhere, as long as the modpack is free to download and credits the Mod. + + c. Read and build. You may read the source code, build it, and run your own builds for + personal use, testing and development. + + d. Contribute. You may fork this repository to propose changes through pull requests. By + sending a contribution you agree that it may be distributed under this licence, and under + any later version of it, as part of the Mod. + + e. Learn. You may study how the Mod works and write your own code based on what you learned, + as long as you do not copy the Mod's source code into it. + +2. What you may not do without written permission + + a. Redistribute the source code, or builds made from it, modified or not, except as allowed + in 1(b) and 1(d). + + b. Publish the Mod, or a fork of it, as your own project or under another name. + + c. Sell the Mod or charge for access to it, including putting it behind a paywall. + + d. Remove or change the copyright notices or the credits to EMB4 and Fureniku. + +3. Content made for the Mod + + Content packs, models, textures and sounds you make for the Mod are yours. This licence does + not apply to them. The example pack in examples/rework-example-pack is public domain (CC0 1.0, + see its own LICENSE file). + +4. No warranty + + The Mod is provided "as is", without warranty of any kind. The authors are not liable for any + damage or loss arising from its use, including lost worlds or corrupted saves. Keep backups. + +5. Ending the licence + + If you break these terms, your rights under this licence end automatically. They come back + if you fix the breach within 30 days of being told about it. + +6. Later versions + + The copyright holder may publish new versions of this licence. Each release of the Mod is + covered by the version included with it. + +Questions and permission requests: open an issue at +https://github.com/JnH-Projects/MCHeli-Rework-Forge/issues From 0c694f41202410907ea6c4b0d108dcfa3b2a7c35 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:23:50 +0800 Subject: [PATCH 58/61] Declare the MCHeli Rework Licence in the mod metadata --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 755d9be..9e2b3dd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,7 +15,7 @@ mod_name=MCHeli Rework mod_group=com.jnhprojects.mcheli mod_version=1.1.0-beta.1 mod_authors=JayMar921 (rework), EMB4 (original author), Fureniku (CurseForge maintainer) -mod_license=All Rights Reserved +mod_license=MCHeli Rework Licence 1.0 (see LICENSE) # Platform modules built by this project, one per Minecraft version. Each has its own # gradle.properties with the Minecraft, loader and Java versions. From 07f53c8c3632bef18ab98a458ff0334e5dadbb79 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:23:51 +0800 Subject: [PATCH 59/61] Ship the licence inside the jar --- forge-26.3/build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/forge-26.3/build.gradle b/forge-26.3/build.gradle index c312562..70b4337 100644 --- a/forge-26.3/build.gradle +++ b/forge-26.3/build.gradle @@ -91,6 +91,8 @@ tasks.named('jar', Jar) { } exclude 'data/mcheli/recipe/**' includeEmptyDirs = false + // The licence travels with every copy of the jar. + from(rootProject.file('LICENSE')) manifest { attributes([ 'Specification-Title' : mod_id, From 800d26bad78008cf31ae08722d965408304a5829 Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:23:51 +0800 Subject: [PATCH 60/61] Explain the MCHeli Rework Licence in the licensing note --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7c20501..48dbd8e 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,13 @@ original files: only the new Java code and the rework's own translations. Player original zip from the CurseForge page themselves, and the mod converts it on their machine. This repository holds no original files either; the unit tests import the developer's own copy -of the zip. The project metadata declares All Rights Reserved for the rework's own code. +of the zip. + +The rework's own code is under the [MCHeli Rework Licence](LICENSE): play it anywhere, include +official releases in free modpacks, read and build the source, and send pull requests. +Redistributing the source, modified builds or forks, and selling the mod need written +permission. Content packs you make are yours. Because the engine is a rework of EMB4's All Rights +Reserved original, the code is source available rather than open source. ## Requirements From 6907bd4faa7111f5017980fbe48829f6ea98f74c Mon Sep 17 00:00:00 2001 From: jaymar921 Date: Thu, 29 Oct 2026 22:23:51 +0800 Subject: [PATCH 61/61] Note the new licence in the changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94b7179..0a10139 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,10 @@ way to 1.0.0; see [docs/ROADMAP.md](docs/ROADMAP.md). - `/mcheli reload` now reloads the content packs too, with the server's settings, and sends every player the new pack list. Before, a dedicated server and its players each reread their own files. - The network protocol changed: players need the same version as the server. +- The rework's own code is now under the MCHeli Rework Licence (`LICENSE`, also inside the jar) + instead of plain All Rights Reserved: free to play, to include in free modpacks, to read, build + and contribute to; redistributing the source or modified builds and selling the mod still need + permission. EMB4's original content is not affected. ## [1.1.0-beta.1] - 2026-10-29