diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java index e33849960bf..64a97fb491f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java @@ -23,7 +23,6 @@ import org.jackhuang.hmcl.addon.mod.ModLoaderType; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.util.Immutable; -import org.jackhuang.hmcl.util.MurmurHash2; import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.HttpRequest; @@ -33,7 +32,6 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.URI; @@ -205,23 +203,70 @@ public SearchResult search(DownloadProvider downloadProvider, String gameVersion } } - @Override - public Optional getRemoteVersionByLocalFile(Path file) throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); + /// Calculates the CurseForge fingerprint without retaining the filtered file in memory. + static long calculateFingerprint(Path file) throws IOException { + byte[] buffer = new byte[8192]; + long filteredLength = 0; + try (InputStream stream = Files.newInputStream(file)) { - byte[] buf = new byte[1024]; int len; - while ((len = stream.read(buf, 0, buf.length)) != -1) { + while ((len = stream.read(buffer)) != -1) { for (int i = 0; i < len; i++) { - byte b = buf[i]; + byte b = buffer[i]; if (b != 0x9 && b != 0xa && b != 0xd && b != 0x20) { - baos.write(b); + filteredLength++; } } } } - long hash = Integer.toUnsignedLong(MurmurHash2.hash32(baos.toByteArray(), baos.size(), 1)); + final int multiplier = 0x5bd1e995; + int hash = 1 ^ (int) filteredLength; + int block = 0; + int blockSize = 0; + + try (InputStream stream = Files.newInputStream(file)) { + int len; + while ((len = stream.read(buffer)) != -1) { + for (int i = 0; i < len; i++) { + byte b = buffer[i]; + if (b == 0x9 || b == 0xa || b == 0xd || b == 0x20) { + continue; + } + + block |= (b & 0xff) << (blockSize * 8); + blockSize++; + if (blockSize == 4) { + int mixed = block; + mixed *= multiplier; + mixed ^= mixed >>> 24; + mixed *= multiplier; + hash *= multiplier; + hash ^= mixed; + + block = 0; + blockSize = 0; + } + } + } + } + + if (blockSize > 0) { + hash ^= block; + hash *= multiplier; + } + + hash ^= hash >>> 13; + hash *= multiplier; + hash ^= hash >>> 15; + + return Integer.toUnsignedLong(hash); + } + + /// Finds the remote CurseForge version matching a local file. + @Override + public Optional getRemoteVersionByLocalFile(Path file) throws IOException { + long hash = calculateFingerprint(file); if (hash == 811513880) { // Workaround for https://github.com/HMCL-dev/HMCL/issues/4597 return Optional.empty(); } diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/addon/curse/CurseForgeRemoteAddonRepositoryTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/addon/curse/CurseForgeRemoteAddonRepositoryTest.java deleted file mode 100644 index 6ded248177e..00000000000 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/addon/curse/CurseForgeRemoteAddonRepositoryTest.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2021 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.addon.curse; - -import org.jackhuang.hmcl.util.MurmurHash2; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -import java.io.ByteArrayOutputStream; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Paths; - -import static org.junit.jupiter.api.Assertions.*; - -public class CurseForgeRemoteAddonRepositoryTest { - - @Test - @Disabled - public void testMurmurHash() throws Exception { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (InputStream is = Files.newInputStream(Paths.get("C:\\Users\\huang\\Downloads\\JustEnoughCalculation-1.16.5-3.8.5.jar"))) { - byte[] buf = new byte[1024]; - int len; - while ((len = is.read(buf, 0, buf.length)) > 0) { - for (int i = 0; i < len; i++) { - byte b = buf[i]; - if (b != 9 && b != 10 && b != 13 && b != 32) { - baos.write(b); - } - } - } - - } - long hash = Integer.toUnsignedLong(MurmurHash2.hash32(baos.toByteArray(), baos.size(), 1)); - - assertEquals(hash, 3333498611L); - } -} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepositoryTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepositoryTest.java new file mode 100644 index 00000000000..26ee62ceb6a --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepositoryTest.java @@ -0,0 +1,73 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.addon.repository; + +import org.jackhuang.hmcl.util.MurmurHash2; +import org.jetbrains.annotations.NotNullByDefault; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/// Tests CurseForge fingerprint calculation for local files. +@NotNullByDefault +public final class CurseForgeRemoteAddonRepositoryTest { + + /// Verifies that streaming calculation remains identical to the previous in-memory algorithm. + @Test + public void calculatesFingerprintWithoutChangingTheResult(@TempDir Path tempDir) throws IOException { + byte[] boundarySample = new byte[8192 + 17]; + new Random(0).nextBytes(boundarySample); + for (int i = 0; i < boundarySample.length; i += 97) { + boundarySample[i] = 0x20; + } + + byte[][] samples = { + {}, + {0x9, 0xa, 0xd, 0x20}, + {1}, + {1, 2}, + {1, 2, 3}, + {1, 2, 3, 4}, + {1, 0x20, 2, 0xa, 3, 0xd, 4, 0x9, 5}, + boundarySample + }; + + for (int i = 0; i < samples.length; i++) { + byte[] sample = samples[i]; + Path file = tempDir.resolve("sample-" + i); + Files.write(file, sample); + + ByteArrayOutputStream filtered = new ByteArrayOutputStream(); + for (byte b : sample) { + if (b != 0x9 && b != 0xa && b != 0xd && b != 0x20) { + filtered.write(b); + } + } + long expected = Integer.toUnsignedLong(MurmurHash2.hash32(filtered.toByteArray(), filtered.size(), 1)); + + assertEquals(expected, CurseForgeRemoteAddonRepository.calculateFingerprint(file)); + } + } +}