From e9ceb9936b74c8175bc8e17dcd7d7e691f2e403b Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Wed, 9 Sep 2026 10:14:36 +0100 Subject: [PATCH 1/2] Fix legacy Codec linkage compatibility --- .../mc/orespawn/api/LegacyCodecBridge.java | 171 ++++++++++ .../mc/orespawn/api/OrePatternType.java | 15 +- .../orespawn/api/StandardPatternSettings.java | 2 +- .../mc/orespawn/LegacyCodecLinkageTest.java | 302 ++++++++++++++++++ src/test/resources/codec-conflict/README.txt | 10 + .../legacy-api-consumer.java.txt | 28 ++ .../probe/LegacyApiConsumer.class | Bin 0 -> 2719 bytes 7 files changed, 520 insertions(+), 8 deletions(-) create mode 100644 src/main/java/zone/moddev/mc/orespawn/api/LegacyCodecBridge.java create mode 100644 src/test/java/zone/moddev/mc/orespawn/LegacyCodecLinkageTest.java create mode 100644 src/test/resources/codec-conflict/README.txt create mode 100644 src/test/resources/codec-conflict/legacy-api-consumer.java.txt create mode 100644 src/test/resources/codec-conflict/probe/LegacyApiConsumer.class diff --git a/src/main/java/zone/moddev/mc/orespawn/api/LegacyCodecBridge.java b/src/main/java/zone/moddev/mc/orespawn/api/LegacyCodecBridge.java new file mode 100644 index 00000000..7517f8a4 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/api/LegacyCodecBridge.java @@ -0,0 +1,171 @@ +package zone.moddev.mc.orespawn.api; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.Proxy; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; + +import com.google.gson.JsonElement; +import com.mojang.serialization.Codec; + +/** + * Keeps the legacy public Codec descriptor while avoiding class-versus-interface + * invocation bytecode in OreSpawn's own startup and profile decoding paths. + */ +final class LegacyCodecBridge { + private static final Map> DECODERS = new IdentityHashMap<>(); + + private LegacyCodecBridge() { + } + + @SuppressWarnings("unchecked") + static Codec create(Function decoder) { + if (decoder == null) throw new NullPointerException("decoder"); + Class codecType = Codec.class; + Object codec; + try { + if (codecType.isInterface()) { + codec = Proxy.newProxyInstance(codecType.getClassLoader(), new Class[] { codecType }, + new CodecInvocationHandler<>(decoder)); + } else { + Method factory = codecType.getMethod("of", Function.class); + codec = factory.invoke(null, decoder); + } + } catch (ReflectiveOperationException exception) { + throw linkageFailure("create the legacy ore-pattern codec", codecType, exception); + } + synchronized (DECODERS) { + DECODERS.put(codec, decoder); + } + return (Codec) codec; + } + + static Object decode(Object codec, JsonElement input) { + Function decoder; + synchronized (DECODERS) { + decoder = DECODERS.get(codec); + } + if (decoder != null) { + return decoder.apply(input); + } + + try { + ClassLoader loader = codec.getClass().getClassLoader(); + Class jsonOps = Class.forName("com.mojang.serialization.JsonOps", true, loader); + Object operations = jsonOps.getField("INSTANCE").get(null); + Method parse = findParse(codec.getClass(), operations, input); + Object result = parse.invoke(codec, operations, input); + return unwrapResult(result); + } catch (InvocationTargetException exception) { + Throwable cause = exception.getCause() == null ? exception : exception.getCause(); + throw new IllegalArgumentException(message(cause), cause); + } catch (ReflectiveOperationException exception) { + throw linkageFailure("decode legacy ore-pattern settings", codec.getClass(), exception); + } + } + + private static Method findParse(Class codecType, Object operations, JsonElement input) + throws NoSuchMethodException { + for (Method method : codecType.getMethods()) { + if (!"parse".equals(method.getName()) || method.getParameterCount() != 2) continue; + Class[] parameters = method.getParameterTypes(); + if (parameters[0].isInstance(operations) + && (input == null || parameters[1].isInstance(input) + || parameters[1] == Object.class)) { + return method; + } + } + throw new NoSuchMethodException(codecType.getName() + + " has no JSON-compatible parse(operations, input) method"); + } + + private static Object unwrapResult(Object dataResult) throws ReflectiveOperationException { + if (dataResult == null) { + throw new IllegalArgumentException("Codec returned no result"); + } + Object value = dataResult.getClass().getMethod("result").invoke(dataResult); + if (value instanceof Optional && ((Optional) value).isPresent()) { + return ((Optional) value).get(); + } + Object error = dataResult.getClass().getMethod("error").invoke(dataResult); + String detail = error instanceof Optional && ((Optional) error).isPresent() + ? String.valueOf(((Optional) error).get()) : "unknown codec error"; + throw new IllegalArgumentException(detail); + } + + private static IllegalStateException linkageFailure(String action, Class codecType, + ReflectiveOperationException exception) { + String origin = "unknown origin"; + if (codecType.getProtectionDomain() != null + && codecType.getProtectionDomain().getCodeSource() != null) { + origin = String.valueOf(codecType.getProtectionDomain().getCodeSource().getLocation()); + } + return new IllegalStateException("Cannot " + action + " using " + codecType.getName() + + " from " + origin, exception); + } + + private static String message(Throwable failure) { + String text = failure.getMessage(); + return text == null || text.trim().isEmpty() ? failure.getClass().getSimpleName() : text; + } + + private static final class CodecInvocationHandler implements InvocationHandler { + private final Function decoder; + + private CodecInvocationHandler(Function decoder) { + this.decoder = decoder; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] arguments) throws Throwable { + if (method.getDeclaringClass() == Object.class) { + switch (method.getName()) { + case "toString": return "OreSpawn legacy Codec bridge"; + case "hashCode": return System.identityHashCode(proxy); + case "equals": return proxy == arguments[0]; + default: throw new UnsupportedOperationException(method.toString()); + } + } + if ("parse".equals(method.getName()) && arguments != null && arguments.length == 2 + && arguments[1] instanceof JsonElement) { + try { + return dataResult(method.getReturnType(), decoder.apply((JsonElement) arguments[1]), null); + } catch (RuntimeException exception) { + return dataResult(method.getReturnType(), null, message(exception)); + } + } + throw new UnsupportedOperationException("OreSpawn's legacy Codec bridge supports JSON parse only: " + + method); + } + } + + private static Object dataResult(Class resultType, Object value, String error) + throws ReflectiveOperationException { + if (error == null) { + return invokeStaticFactory(resultType, "success", value, null); + } + return invokeStaticFactory(resultType, "error", error, () -> error); + } + + private static Object invokeStaticFactory(Class type, String name, Object direct, + Supplier supplied) throws ReflectiveOperationException { + for (Method method : type.getMethods()) { + if (!name.equals(method.getName()) || !Modifier.isStatic(method.getModifiers()) + || method.getParameterCount() != 1) continue; + Class parameter = method.getParameterTypes()[0]; + if (supplied != null && Supplier.class.isAssignableFrom(parameter)) { + return method.invoke(null, supplied); + } + if (direct == null || parameter.isInstance(direct) || parameter == Object.class) { + return method.invoke(null, direct); + } + } + throw new NoSuchMethodException(type.getName() + "." + name + "(value)"); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OrePatternType.java b/src/main/java/zone/moddev/mc/orespawn/api/OrePatternType.java index 67cb73f2..522b029f 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/OrePatternType.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/OrePatternType.java @@ -5,8 +5,6 @@ import com.google.gson.JsonElement; import com.mojang.serialization.Codec; -import com.mojang.serialization.DataResult; -import com.mojang.serialization.JsonOps; import net.minecraftforge.registries.IForgeRegistryEntry; @@ -35,11 +33,14 @@ public Codec codec() { } public CompiledOrePattern decode(JsonElement configuration) { - DataResult result = codec.parse(JsonOps.INSTANCE, configuration); - Object value = result.result().orElseThrow(() -> new IllegalArgumentException( - "Invalid settings for ore pattern " + getRegistryName() + ": " - + result.error().map(Object::toString).orElse("unknown codec error"))); - return compile(value); + try { + return compile(LegacyCodecBridge.decode(codec, configuration)); + } catch (RuntimeException exception) { + String detail = exception.getMessage() == null + ? exception.getClass().getSimpleName() : exception.getMessage(); + throw new IllegalArgumentException("Invalid settings for ore pattern " + + getRegistryName() + ": " + detail, exception); + } } private CompiledOrePattern compile(Object configuration) { diff --git a/src/main/java/zone/moddev/mc/orespawn/api/StandardPatternSettings.java b/src/main/java/zone/moddev/mc/orespawn/api/StandardPatternSettings.java index f39b5977..7329e83a 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/StandardPatternSettings.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/StandardPatternSettings.java @@ -6,7 +6,7 @@ /** Shared bounded settings understood by OreSpawn's six built-in patterns. */ public final class StandardPatternSettings { - public static final Codec CODEC = Codec.of(element -> { + public static final Codec CODEC = LegacyCodecBridge.create(element -> { JsonObject json = element == null || !element.isJsonObject() ? new JsonObject() : element.getAsJsonObject(); return new StandardPatternSettings(integer(json, "spread", 8), diff --git a/src/test/java/zone/moddev/mc/orespawn/LegacyCodecLinkageTest.java b/src/test/java/zone/moddev/mc/orespawn/LegacyCodecLinkageTest.java new file mode 100644 index 00000000..351801f7 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/LegacyCodecLinkageTest.java @@ -0,0 +1,302 @@ +package zone.moddev.mc.orespawn; + +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import javax.tools.JavaCompiler; +import javax.tools.StandardJavaFileManager; +import javax.tools.ToolProvider; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.JsonOps; + +import zone.moddev.mc.orespawn.api.CompiledOrePattern; +import zone.moddev.mc.orespawn.api.OrePatternType; +import zone.moddev.mc.orespawn.api.StandardPatternSettings; + +class LegacyCodecLinkageTest { + private static final String LEGACY_CONSUMER_SHA256 = + "BA3AECFB53AAA31B7FB24CD6F7A8376EF11185F61425024A9543FA2D10220564"; + + @TempDir + Path temporaryDirectory; + + @Test + void bundledClassCodecStillDecodesAndKeepsItsPublicDescriptors() throws Exception { + assertFalse(Codec.class.isInterface()); + DataResult parsed = StandardPatternSettings.CODEC.parse( + JsonOps.INSTANCE, json("{\"spread\":17,\"length\":21}")); + assertEquals(17, parsed.result().get().spread()); + assertEquals(21, parsed.result().get().length()); + + Field codecField = StandardPatternSettings.class.getField("CODEC"); + assertEquals(Codec.class, codecField.getType()); + Method create = OrePatternType.class.getMethod("create", Codec.class, Function.class); + assertEquals(OrePatternType.class, create.getReturnType()); + assertEquals(Codec.class, OrePatternType.class.getMethod("codec").getReturnType()); + } + + @Test + void builtInPatternDecodeUsesTheBridgeAndReportsInvalidSettings() { + AtomicReference decoded = new AtomicReference<>(); + OrePatternType type = OrePatternType.create(StandardPatternSettings.CODEC, settings -> { + decoded.set(settings); + return context -> false; + }); + CompiledOrePattern pattern = type.decode(json( + "{\"spread\":19,\"vertical_spread\":7,\"node_size\":5,\"length\":23}")); + assertNotNull(pattern); + assertEquals(19, decoded.get().spread()); + assertEquals(7, decoded.get().verticalSpread()); + assertEquals(5, decoded.get().nodeSize()); + assertEquals(23, decoded.get().length()); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> type.decode(json("{\"spread\":\"not-a-number\"}"))); + assertTrue(error.getMessage().contains("Invalid settings for ore pattern")); + assertTrue(error.getMessage().contains("not-a-number")); + } + + @Test + void interfaceFirstRuntimeInitializesAndDecodesWithoutLinkageErrors() throws Exception { + Path classes = compileInterfaceFixture(true); + ProcessResult result = run(classes, "probe.InterfaceCodecProbe"); + assertEquals(0, result.exitCode, result.output); + assertTrue(result.output.contains("CODEC_INTERFACE=true"), result.output); + assertTrue(result.output.contains("DIRECT_CODEC_PARSE_OK"), result.output); + assertTrue(result.output.contains("ORE_PATTERN_DECODE_OK"), result.output); + assertFalse(result.output.contains("IncompatibleClassChangeError"), result.output); + } + + @Test + void consumerCompiledAgainstFourZeroSixteenRunsUnchangedWithInterfaceCodec() throws Exception { + Path classes = compileInterfaceFixture(false); + Path consumer = classes.resolve("probe/LegacyApiConsumer.class"); + Files.createDirectories(consumer.getParent()); + try (InputStream input = getClass().getResourceAsStream( + "/codec-conflict/probe/LegacyApiConsumer.class")) { + assertNotNull(input, "missing sealed 4.0.16 API consumer"); + Files.copy(input, consumer); + } + assertEquals(LEGACY_CONSUMER_SHA256, sha256(consumer)); + + ProcessResult result = run(classes, "probe.LegacyApiConsumer"); + assertEquals(0, result.exitCode, result.output); + assertTrue(result.output.contains("LEGACY_4_0_16_API_CONSUMER_OK"), result.output); + assertFalse(result.output.contains("LinkageError"), result.output); + } + + @Test + void startupClassesContainNoDirectSerializationMethodReferences() throws Exception { + for (Class type : Arrays.asList(StandardPatternSettings.class, OrePatternType.class, + Class.forName("zone.moddev.mc.orespawn.api.LegacyCodecBridge"))) { + List forbidden = new ArrayList<>(); + for (MethodReference reference : methodReferences(type)) { + if (reference.owner.startsWith("com/mojang/serialization/")) { + forbidden.add(reference.owner + "." + reference.name); + } + } + assertTrue(forbidden.isEmpty(), + type.getName() + " directly invokes a classpath-sensitive serialization method: " + + forbidden); + } + } + + private Path compileInterfaceFixture(boolean includeProbe) throws Exception { + Path sources = temporaryDirectory.resolve(includeProbe ? "interface-probe-src" : "interface-src"); + Path classes = temporaryDirectory.resolve(includeProbe ? "interface-probe-classes" : "interface-classes"); + write(sources.resolve("com/mojang/serialization/DynamicOps.java"), + "package com.mojang.serialization; public interface DynamicOps {}\n"); + write(sources.resolve("com/mojang/serialization/JsonOps.java"), + "package com.mojang.serialization;" + + " import com.google.gson.JsonElement;" + + " public final class JsonOps implements DynamicOps {" + + " public static final JsonOps INSTANCE = new JsonOps(); private JsonOps() {} }\n"); + write(sources.resolve("com/mojang/serialization/DataResult.java"), + "package com.mojang.serialization;" + + " import java.util.Optional; import java.util.function.Supplier;" + + " public final class DataResult { private final A value; private final String failure;" + + " private DataResult(A value,String failure){this.value=value;this.failure=failure;}" + + " public static DataResult success(A value){return new DataResult(value,null);}" + + " public static DataResult error(Supplier error){" + + " return new DataResult(null,error.get());}" + + " public Optional result(){return Optional.ofNullable(value);}" + + " public Optional error(){return Optional.ofNullable(failure);} }\n"); + write(sources.resolve("com/mojang/serialization/Codec.java"), + "package com.mojang.serialization; public interface Codec {" + + " DataResult parse(DynamicOps operations,T input); }\n"); + if (includeProbe) { + write(sources.resolve("probe/InterfaceCodecProbe.java"), interfaceProbeSource()); + } + + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + assertNotNull(compiler, "tests require a JDK, not a JRE"); + Files.createDirectories(classes); + List javaSources = new ArrayList<>(); + Files.walk(sources).filter(path -> path.toString().endsWith(".java")) + .forEach(path -> javaSources.add(path.toFile())); + try (StandardJavaFileManager manager = compiler.getStandardFileManager(null, null, + StandardCharsets.UTF_8)) { + List options = Arrays.asList("-source", "8", "-target", "8", "-proc:none", + "-classpath", System.getProperty("java.class.path"), "-d", classes.toString()); + Boolean compiled = compiler.getTask(null, manager, null, options, null, + manager.getJavaFileObjectsFromFiles(javaSources)).call(); + assertEquals(Boolean.TRUE, compiled, "could not compile interface-first fixture"); + } + return classes; + } + + private static String interfaceProbeSource() { + return "package probe;" + + " import java.util.concurrent.atomic.AtomicReference;" + + " import com.google.gson.JsonElement; import com.google.gson.JsonParser;" + + " import com.mojang.serialization.Codec; import com.mojang.serialization.DataResult;" + + " import com.mojang.serialization.JsonOps;" + + " import zone.moddev.mc.orespawn.api.CompiledOrePattern;" + + " import zone.moddev.mc.orespawn.api.OrePatternType;" + + " import zone.moddev.mc.orespawn.api.StandardPatternSettings;" + + " public final class InterfaceCodecProbe { public static void main(String[] args) {" + + " System.out.println(\"CODEC_INTERFACE=\"+Codec.class.isInterface());" + + " JsonElement json=new JsonParser().parse(\"{\\\"spread\\\":29,\\\"length\\\":31}\");" + + " DataResult direct=StandardPatternSettings.CODEC.parse(JsonOps.INSTANCE,json);" + + " if(!direct.result().isPresent()||direct.result().get().spread()!=29)" + + " throw new AssertionError(\"direct proxy parse failed\");" + + " System.out.println(\"DIRECT_CODEC_PARSE_OK\");" + + " AtomicReference decoded=new AtomicReference();" + + " OrePatternType type=OrePatternType.create(StandardPatternSettings.CODEC,s->{decoded.set(s);return c->false;});" + + " CompiledOrePattern pattern=type.decode(json);" + + " if(pattern==null||decoded.get()==null||decoded.get().length()!=31)" + + " throw new AssertionError(\"OrePatternType bridge decode failed\");" + + " System.out.println(\"ORE_PATTERN_DECODE_OK\"); } }\n"; + } + + private static ProcessResult run(Path firstClasspathEntry, String mainClass) throws Exception { + Path java = new File(System.getProperty("java.home"), "bin/java.exe").toPath(); + if (!Files.isRegularFile(java)) java = new File(System.getProperty("java.home"), "bin/java").toPath(); + String classpath = firstClasspathEntry + File.pathSeparator + System.getProperty("java.class.path"); + Process process = new ProcessBuilder(java.toString(), "-cp", classpath, mainClass) + .redirectErrorStream(true).start(); + boolean finished = process.waitFor(30, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + throw new AssertionError(mainClass + " did not finish within 30 seconds"); + } + return new ProcessResult(process.exitValue(), read(process.getInputStream())); + } + + private static JsonElement json(String text) { + return new JsonParser().parse(text); + } + + private static void write(Path file, String contents) throws IOException { + Files.createDirectories(file.getParent()); + Files.write(file, contents.getBytes(StandardCharsets.UTF_8)); + } + + private static String read(InputStream input) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + for (int count = input.read(buffer); count >= 0; count = input.read(buffer)) { + if (count > 0) output.write(buffer, 0, count); + } + return new String(output.toByteArray(), StandardCharsets.UTF_8); + } + + private static String sha256(Path file) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream input = Files.newInputStream(file)) { + byte[] buffer = new byte[4096]; + for (int count = input.read(buffer); count >= 0; count = input.read(buffer)) { + if (count > 0) digest.update(buffer, 0, count); + } + } + StringBuilder result = new StringBuilder(); + for (byte value : digest.digest()) result.append(String.format("%02X", value & 0xff)); + return result.toString(); + } + + private static List methodReferences(Class type) throws Exception { + String resource = "/" + type.getName().replace('.', '/') + ".class"; + try (DataInputStream input = new DataInputStream(type.getResourceAsStream(resource))) { + assertEquals(0xCAFEBABE, input.readInt()); + input.readUnsignedShort(); + input.readUnsignedShort(); + int count = input.readUnsignedShort(); + int[] tags = new int[count]; + Object[] values = new Object[count]; + for (int index = 1; index < count; index++) { + int tag = input.readUnsignedByte(); + tags[index] = tag; + switch (tag) { + case 1: values[index] = input.readUTF(); break; + case 3: case 4: input.readInt(); break; + case 5: case 6: input.readLong(); index++; break; + case 7: case 8: case 16: case 19: case 20: + values[index] = input.readUnsignedShort(); break; + case 9: case 10: case 11: case 12: case 17: case 18: + values[index] = new int[] { input.readUnsignedShort(), input.readUnsignedShort() }; break; + case 15: + values[index] = new int[] { input.readUnsignedByte(), input.readUnsignedShort() }; break; + default: throw new IOException("Unknown constant-pool tag " + tag); + } + } + List references = new ArrayList<>(); + for (int index = 1; index < count; index++) { + if (tags[index] != 10 && tags[index] != 11) continue; + int[] reference = (int[]) values[index]; + int classNameIndex = (Integer) values[reference[0]]; + int[] nameAndType = (int[]) values[reference[1]]; + references.add(new MethodReference((String) values[classNameIndex], + (String) values[nameAndType[0]])); + } + return references; + } + } + + private static final class MethodReference { + private final String owner; + private final String name; + + private MethodReference(String owner, String name) { + this.owner = owner; + this.name = name; + } + } + + private static final class ProcessResult { + private final int exitCode; + private final String output; + + private ProcessResult(int exitCode, String output) { + this.exitCode = exitCode; + this.output = output; + } + } +} diff --git a/src/test/resources/codec-conflict/README.txt b/src/test/resources/codec-conflict/README.txt new file mode 100644 index 00000000..4b2bb5d9 --- /dev/null +++ b/src/test/resources/codec-conflict/README.txt @@ -0,0 +1,10 @@ +The retained LegacyApiConsumer.class has SHA-256 +BA3AECFB53AAA31B7FB24CD6F7A8376EF11185F61425024A9543FA2D10220564. + +It was compiled with exact Temurin 8.0.502+7 against the published +OreSpawn-4.0.16.112021.jar whose SHA-256 is +7A33DF1BB2B856F79421D69425282DBB4756E03824EB7263EDCCB8DE43D87F07. + +The adjacent Java source is retained for review. The binary proves that an +existing consumer of StandardPatternSettings.CODEC and OrePatternType.create +continues to run unchanged after the internal compatibility repair. diff --git a/src/test/resources/codec-conflict/legacy-api-consumer.java.txt b/src/test/resources/codec-conflict/legacy-api-consumer.java.txt new file mode 100644 index 00000000..dc5bedaa --- /dev/null +++ b/src/test/resources/codec-conflict/legacy-api-consumer.java.txt @@ -0,0 +1,28 @@ +package probe; + +import java.util.concurrent.atomic.AtomicReference; + +import com.google.gson.JsonParser; +import com.mojang.serialization.Codec; + +import zone.moddev.mc.orespawn.api.CompiledOrePattern; +import zone.moddev.mc.orespawn.api.OrePatternType; +import zone.moddev.mc.orespawn.api.StandardPatternSettings; + +public final class LegacyApiConsumer { + public static void main(String[] arguments) { + Codec codec = StandardPatternSettings.CODEC; + AtomicReference decoded = new AtomicReference(); + OrePatternType type = OrePatternType.create(codec, settings -> { + decoded.set(settings); + return context -> false; + }); + CompiledOrePattern pattern = type.decode(new JsonParser().parse( + "{\"spread\":19,\"vertical_spread\":7,\"node_size\":5,\"length\":23}")); + if (pattern == null || decoded.get() == null || decoded.get().spread() != 19 + || decoded.get().length() != 23) { + throw new AssertionError("Legacy OrePatternType API did not decode the expected settings"); + } + System.out.println("LEGACY_4_0_16_API_CONSUMER_OK"); + } +} diff --git a/src/test/resources/codec-conflict/probe/LegacyApiConsumer.class b/src/test/resources/codec-conflict/probe/LegacyApiConsumer.class new file mode 100644 index 0000000000000000000000000000000000000000..8d41511f089a92f6010715ab865297257b73abb0 GIT binary patch literal 2719 zcmb_eU0V}J6n=-Vg}6uz3Sz5`DQbY|il3EOTa2jKK&Vk$YHd2~4q;(;XS=&W!S-MD z-u{4Iv|eBzefn|bMW6nrwr4g$g4k61^k!%0%sFSydFQ-m=J!9J{sQ0(zD{5WxgPZ5 zxP}u6v?G^N3kjUXISuC%(2(oKduqI(gcC|9ui;{XY~EL&OBz1t#)k`iizEW^3+(2yXEOha?>?w>^e| z?A_urU*(3wy{b`;0^6(Rm0@o=;^xW>_hWqxGa676ihJ3?50>HjT-6xG5F|NllS2u1ilCuCy$%YPhB$0}=ZCzGraXrYN{) z@xYqnQ6vJdETV{_3iBf^tqRihZAVz8Kr{^VTVZ-N#~D7!a3^$hF%OF6fGJ(0DrMCXMm3b4af3c{JP1X=kbjs7 z{XlRlGckT4msu4-WSiWnYzoikG9DE~CA8N?X5wrv;|Q-BEoCN7o&HA0U3{+N9=^~~ z!IuoD|HC2a;8@UMVu4}*R$)zs6kcRYZz>2R#d)cYVS4MZZmszuJvld>wrnfy$tZ0J zlOjz=OCl{E_`-~Yl@1%Fps=sd&$V;VAnl0a+nYvji z5iOQ<*jU!F0xO9Tg4NOCf~tSe59ER{b{r_3$yV&8g=N~Eq5l<3VK}6Y!p1cWNcap#8$6BaUeAnp^>VVL zy&qpOR9n;Ke->q+QAW8$eC{Yp18y zt!Miw(zczx;v>U&GuoTYH4fxu0#7+{(@`PU3!kA>5BZ-oTZgo>R>*aGRZt!8&A#Hm zY2SK}h%b(5n$|l5!@lMmHYfZKN zvW$v zA{?$8=9}o&NV7?@%}_PPX7$%!tZ&)cwz&)%o(tS@%623Ou7?aqcHr}>e5ePu*26fR z-Vi-`fDU@9&^1Zlw`q2eMn+@j=o7FX>4#c5eY;|zjvgGsJM^zqf2M~{Cyk4b(N=th z_W391m>J7GRl=XpHGBLSG?I0vdNz>Qz^;jop^oRs<~E=|$6+=6hTaYAPWFF?)Qf3f za_|v)pCLJ)+VcddUmv4CwRZ#iR4i?AEJrB{U^fNc2c4c@gV>7^4A9$a5M@g19;G8F zp)_d?q3tgcc4|1RA*1144Z|9a06ocss=7uh$H;XXsqY#c+rYq6a)cjfTF*TZFYTeU zQe;F`+Aummqk^if{reZXb(~U!%&1zy81B=UAiWyauuAb5G#{XqlcY0_Q#c*}UB@)8 F{|)X)42S># literal 0 HcmV?d00001 From 8471b3e6bcabe45ad6f5f6d8589586ed194b6897 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Wed, 9 Sep 2026 10:19:52 +0100 Subject: [PATCH 2/2] Prepare OreSpawn 4.0.17.112021 --- .github/workflows/ci.yml | 6 +++--- CHANGELOG.txt | 9 +++++++++ README.md | 6 ++++-- build.gradle | 19 +++++++++++++++---- docs/VERSIONS.md | 8 +++++--- gradle.properties | 2 +- .../orespawn/compat/LegacyOs3Bridge.java | 2 +- .../zone/moddev/mc/orespawn/OreSpawn.java | 2 +- .../LegacyMineralogyProfileMigration.java | 2 +- .../worldgen/LegacyOs3ProfileMigration.java | 2 +- .../orespawn/ReleaseWorkflowContractTest.java | 5 +++++ 11 files changed, 46 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e98c70c5..31218184 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,9 +126,9 @@ jobs: if-no-files-found: error retention-days: 30 path: | - build/libs/OreSpawn-4.0.16.112021.jar - build/libs/OreSpawn-4.0.16.112021-sources.jar - build/libs/OreSpawn-4.0.16.112021-javadoc.jar + build/libs/OreSpawn-4.0.17.112021.jar + build/libs/OreSpawn-4.0.17.112021-sources.jar + build/libs/OreSpawn-4.0.17.112021-javadoc.jar build/release/SHA256SUMS CHANGELOG.txt diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 7cc77775..5bda7a94 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,12 @@ +Version 4.0.17.112021 + +* Avoid a client-startup IncompatibleClassChangeError when another legacy + modpack component supplies an interface-shaped Mojang serialization Codec + ahead of OreSpawn's 1.12 compatibility adapter. +* Preserve OreSpawn's existing public Codec descriptors and ordinary Forge 14 + behaviour while decoding built-in pattern settings through an internal + class/interface-neutral bridge. + Version 4.0.16.112021 * Adopt the shared 4.0.16 release identity. Forge 1.12 has neither diff --git a/README.md b/README.md index 3d517d5d..fb31d985 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,10 @@ It gives mods and modpacks one place to configure ores, deposit shapes, optional rock strata and geomes, provider-owned underground fluid deposits, biome palettes and world materials, flat bedrock, and bounded ore retrogen. -This branch builds target-qualified version `4.0.16.112021`: the OreSpawn 4.0.16 -feature set for Minecraft 1.12.2 and Forge. See the +This branch builds target-qualified version `4.0.17.112021`: the OreSpawn 4.0.17 +feature set for Minecraft 1.12.2 and Forge. This release avoids a startup +linkage failure when a legacy modpack supplies an interface-shaped Mojang +serialization `Codec` ahead of OreSpawn's compatibility adapter. See the [versioning policy](docs/VERSIONS.md) for the encoding and release convention. Its deprecated OS3 compatibility layer imports OreSpawn 3 configuration and diff --git a/build.gradle b/build.gradle index c0902ef7..e620098b 100644 --- a/build.gradle +++ b/build.gradle @@ -112,7 +112,7 @@ minecraft { } register('client') register('server') { - args '--nogui' + args 'nogui' } } } @@ -280,6 +280,9 @@ tasks.named('check') { } def configureFromForgeRun = { JavaExec process, String runTaskName -> process.actions.clear() + if (runTaskName == 'runServer') { + process.setArgs(['nogui']) + } process.dependsOn { JavaExec run = tasks.getByName(runTaskName) as JavaExec run.taskDependencies.getDependencies(run) @@ -1330,7 +1333,7 @@ tasks.register('verifyReleaseConfiguration') { description = 'Validates the target-qualified release, API, schemas, reports, and publishing identity.' doLast { - if (project.mod_version != '4.0.16.112021' + if (project.mod_version != '4.0.17.112021' || project.mod_group != expectedMavenGroup) { throw new GradleException("Unexpected OreSpawn release version: ${project.mod_version}") } @@ -1357,8 +1360,8 @@ tasks.register('verifyReleaseConfiguration') { 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java', 'README.md', 'CHANGELOG.txt' ].each { path -> - if (!file(path).getText('UTF-8').contains('4.0.16.112021')) { - throw new GradleException("Authoritative release location does not contain 4.0.16.112021: ${path}") + if (!file(path).getText('UTF-8').contains('4.0.17.112021')) { + throw new GradleException("Authoritative release location does not contain 4.0.17.112021: ${path}") } } if (!file('docs/API.md').getText('UTF-8').contains('orespawn@[4.0.6,5.0.0)')) { @@ -1421,6 +1424,7 @@ tasks.register('verifyReleaseArtifacts') { ['src/test/', 'src/biomeIntegrationTest/', 'src/migrationIntegrationTest/', 'src/clientIntegrationTest/', 'src/os1AbiFixture/', 'src/os3AbiFixture/', 'agent-notes/', 'surfaceprobe', 'migrationprobe', 'clientprobe', + 'codec-conflict/', 'LegacyApiConsumer', 'org/junit/', 'org/mockito/', 'net/bytebuddy/'].each { forbidden -> if (candidateZip.entries().any { it.name.contains(forbidden) }) { throw new GradleException( @@ -1787,6 +1791,9 @@ tasks.register('isolateEclipseProductionRuns') { contents = contents.replace( 'key="MC_VERSION" value="${MC_VERSION}"', "key=\"MC_VERSION\" value=\"${minecraft_version}\"") + contents = contents.replace( + 'key="org.eclipse.jdt.launching.PROGRAM_ARGUMENTS" value="--nogui"', + 'key="org.eclipse.jdt.launching.PROGRAM_ARGUMENTS" value="nogui"') launch.setText(contents.replace('\r\n', '\n'), 'UTF-8') } @@ -1862,6 +1869,10 @@ tasks.register('verifyEclipseProductionClasspath') { }.files as List allGeneratedLaunches.each { launch -> String contents = launch.getText('UTF-8') + if (contents.contains('--nogui')) { + throw new GradleException( + "${launch.name} uses invalid legacy server argument --nogui; expected bare nogui") + } if (contents.contains('${MC_VERSION}')) { throw new GradleException( "${launch.name} retains ForgeGradle's unresolved MC_VERSION token") diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md index 45ea2406..871beaad 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -52,7 +52,7 @@ Examples: | Minecraft | Loader | Target | Example full OreSpawn version | | --- | --- | ---: | --- | | 1.10.2 | Forge | `110021` | `4.0.6.110021` | -| 1.12.2 | Forge | `112021` | `4.0.16.112021` | +| 1.12.2 | Forge | `112021` | `4.0.17.112021` | | 1.13.2 | Forge | `113021` | `4.0.6.113021` | | 1.20.6 | Forge | `120061` | `4.0.6.120061` | | 1.21.11 | Forge | `121111` | `4.0.6.121111` | @@ -154,8 +154,10 @@ to 4.0.13 for provider biome-filter parity and namespaced geome support, then to conversion. Forge 1.12 has no Y-sensitive biome-cell attribution or server-side GameTest harness, so 4.0.15 and the GameTest lifecycle portion of 4.0.16 are not applicable; it adopts the shared 4.0.16 identity while retaining ordinary -benchmark auto-stop. A branch may therefore legitimately skip functional version -numbers. +benchmark auto-stop. Forge 1.12 then advances to 4.0.17 to tolerate legacy +modpacks that supply an interface-shaped Mojang serialization `Codec` before +OreSpawn's class-shaped compatibility adapter. A branch may therefore +legitimately skip functional version numbers. This provides three useful guarantees: diff --git a/gradle.properties b/gradle.properties index e152e72f..4d951308 100644 --- a/gradle.properties +++ b/gradle.properties @@ -26,7 +26,7 @@ curseforge_project_id=245586 mod_id=orespawn mod_name=MMD OreSpawn mod_license=LGPL-2.1 -mod_version=4.0.16.112021 +mod_version=4.0.17.112021 mod_group=zone.moddev.mc.orespawn mod_authors=SkyBlade1978, dshadowwolf, the MMD Team mod_description=Configurable, provider-driven terrain, ore, and deposit generation. diff --git a/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java b/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java index 206de83b..14f4b9d3 100644 --- a/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java +++ b/src/main/java/com/mcmoddev/orespawn/compat/LegacyOs3Bridge.java @@ -818,7 +818,7 @@ private static void writeHumanUpgradeReport(Path destination) throws IOException } } List lines = new ArrayList<>(); - lines.add("OreSpawn 4.0.16.112021 Upgrade Report"); + lines.add("OreSpawn 4.0.17.112021 Upgrade Report"); lines.add("================================"); lines.add(""); lines.add("RESULT: Legacy OreSpawn configuration was consumed and translated for OS4."); diff --git a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java index a9ee4786..af18f93c 100644 --- a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java +++ b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java @@ -52,7 +52,7 @@ public class OreSpawn { public static final String MODID = "orespawn"; public static final String NAME = "OreSpawn"; - public static final String VERSION = "4.0.16.112021"; + public static final String VERSION = "4.0.17.112021"; private static final Logger LOGGER = LogManager.getLogger(); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java index f0d3a3b5..81b35b3c 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java @@ -152,7 +152,7 @@ private static void writeUpgradeReport(Path worldRoot, Path configDirectory, Path report = worldRoot.resolve("serverconfig/orespawn-upgrade-report.txt"); List missing = missingBlocks(igneous, metamorphic, sedimentary); List lines = new ArrayList<>(); - lines.add("OreSpawn 4.0.16.112021 Upgrade Report"); + lines.add("OreSpawn 4.0.17.112021 Upgrade Report"); lines.add("================================"); lines.add(""); lines.add("RESULT: Existing Mineralogy " + identity.version + " world detected."); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java index f7c05855..83aed38f 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyOs3ProfileMigration.java @@ -65,7 +65,7 @@ private static void writeInitialUpgradeReport(Path configDirectory, boolean forceRetrogen, boolean flatBedrock, boolean retrogenBedrock, int bedrockLayers) throws IOException { String newline = System.lineSeparator(); - String text = "OreSpawn 4.0.16.112021 Upgrade Report" + newline + String text = "OreSpawn 4.0.17.112021 Upgrade Report" + newline + "================================" + newline + newline + "RESULT: Legacy OreSpawn settings were imported into the OS4 profile." + newline + "- Manage vanilla ores: " + manageVanilla + newline diff --git a/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java b/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java index 7d643f21..6ea2eada 100644 --- a/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java @@ -27,6 +27,11 @@ void releaseAndHostedWorkflowContractsRemainTargetNative() throws Exception { assertTrue(build.contains("dependsOn tasks.named('verifyMavenCoordinates')")); assertTrue(build.contains("expectedMavenCoordinate")); assertFalse(build.contains("Mavenizer compatibility")); + assertTrue(build.contains("args 'nogui'")); + assertFalse(build.contains("args '--nogui'")); + assertTrue(build.contains("process.setArgs(['nogui'])")); + assertTrue(build.contains("PROGRAM_ARGUMENTS\" value=\"--nogui")); + assertTrue(build.contains("contents.contains('--nogui')")); String ci = readWorkflow("ci.yml"); String codeql = readWorkflow("codeql-analysis.yml");