From e7596d726f49b6d47b117495927f3ed45fdb7964 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 9 Aug 2026 21:21:23 +0800 Subject: [PATCH 1/4] refactor: remove unused string hash methods from MurmurHash2 --- .../org/jackhuang/hmcl/util/MurmurHash2.java | 89 ------------------- 1 file changed, 89 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java index 5538217ec12..234b247342c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java @@ -17,8 +17,6 @@ */ package org.jackhuang.hmcl.util; -import java.nio.charset.StandardCharsets; - /** * Implementation of the MurmurHash2 32-bit and 64-bit hash functions. * @@ -131,50 +129,6 @@ public static int hash32(final byte[] data, final int length) { return hash32(data, length, 0x9747b28c); } - /** - * Generates a 32-bit hash from a string with a default seed. - *

- * Before 1.14 the string was converted using default encoding. - * Since 1.14 the string is converted to bytes using UTF-8 encoding. - *

- * This is a helper method that will produce the same result as: - * - *
-     * int seed = 0x9747b28c;
-     * byte[] bytes = data.getBytes(StandardCharsets.UTF_8);
-     * int hash = MurmurHash2.hash32(bytes, bytes.length, seed);
-     * 
- * - * @param text The input string - * @return The 32-bit hash - * @see #hash32(byte[], int, int) - */ - public static int hash32(final String text) { - final byte[] bytes = text.getBytes(StandardCharsets.UTF_8); - return hash32(bytes, bytes.length); - } - - /** - * Generates a 32-bit hash from a substring with a default seed value. - * The string is converted to bytes using the default encoding. - * This is a helper method that will produce the same result as: - * - *
-     * int seed = 0x9747b28c;
-     * byte[] bytes = text.substring(from, from + length).getBytes(StandardCharsets.UTF_8);
-     * int hash = MurmurHash2.hash32(bytes, bytes.length, seed);
-     * 
- * - * @param text The input string - * @param from The starting index - * @param length The length of the substring - * @return The 32-bit hash - * @see #hash32(byte[], int, int) - */ - public static int hash32(final String text, final int from, final int length) { - return hash32(text.substring(from, from + length)); - } - /** * Generates a 64-bit hash from byte array of the given length and seed. * @@ -251,47 +205,4 @@ public static long hash64(final byte[] data, final int length) { return hash64(data, length, 0xe17a1465); } - /** - * Generates a 64-bit hash from a string with a default seed. - *

- * Before 1.14 the string was converted using default encoding. - * Since 1.14 the string is converted to bytes using UTF-8 encoding. - *

- * This is a helper method that will produce the same result as: - * - *
-     * int seed = 0xe17a1465;
-     * byte[] bytes = data.getBytes(StandardCharsets.UTF_8);
-     * int hash = MurmurHash2.hash64(bytes, bytes.length, seed);
-     * 
- * - * @param text The input string - * @return The 64-bit hash - * @see #hash64(byte[], int, int) - */ - public static long hash64(final String text) { - final byte[] bytes = text.getBytes(StandardCharsets.UTF_8); - return hash64(bytes, bytes.length); - } - - /** - * Generates a 64-bit hash from a substring with a default seed value. - * The string is converted to bytes using the default encoding. - * This is a helper method that will produce the same result as: - * - *
-     * int seed = 0xe17a1465;
-     * byte[] bytes = text.substring(from, from + length).getBytes(StandardCharsets.UTF_8);
-     * int hash = MurmurHash2.hash64(bytes, bytes.length, seed);
-     * 
- * - * @param text The The input string - * @param from The starting index - * @param length The length of the substring - * @return The 64-bit hash - * @see #hash64(byte[], int, int) - */ - public static long hash64(final String text, final int from, final int length) { - return hash64(text.substring(from, from + length)); - } } From ae99b7ec7033adb0f3b3a14b727074060663a5fc Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 9 Aug 2026 21:32:41 +0800 Subject: [PATCH 2/4] refactor: update MurmurHash2 to implement Checksum interface and optimize hash calculation --- .../CurseForgeRemoteAddonRepository.java | 17 +- .../org/jackhuang/hmcl/util/MurmurHash2.java | 194 ++---------------- 2 files changed, 28 insertions(+), 183 deletions(-) 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..0f18dee87f4 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 @@ -207,21 +207,26 @@ public SearchResult search(DownloadProvider downloadProvider, String gameVersion @Override public Optional getRemoteVersionByLocalFile(Path file) throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); + var murmur = new MurmurHash2(1); try (InputStream stream = Files.newInputStream(file)) { - byte[] buf = new byte[1024]; + byte[] input = new byte[1024]; + byte[] hashBuffer = new byte[1024]; int len; - while ((len = stream.read(buf, 0, buf.length)) != -1) { + while ((len = stream.read(input, 0, input.length)) > 0) { + int hashBufferLen = 0; + for (int i = 0; i < len; i++) { - byte b = buf[i]; + byte b = input[i]; if (b != 0x9 && b != 0xa && b != 0xd && b != 0x20) { - baos.write(b); + hashBuffer[hashBufferLen++] = b; } } + + murmur.update(hashBuffer, 0, hashBufferLen); } } - long hash = Integer.toUnsignedLong(MurmurHash2.hash32(baos.toByteArray(), baos.size(), 1)); + long hash = murmur.getValue(); if (hash == 811513880) { // Workaround for https://github.com/HMCL-dev/HMCL/issues/4597 return Optional.empty(); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java index 234b247342c..874c1e0f637 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java @@ -17,192 +17,32 @@ */ package org.jackhuang.hmcl.util; -/** - * Implementation of the MurmurHash2 32-bit and 64-bit hash functions. - * - *

MurmurHash is a non-cryptographic hash function suitable for general - * hash-based lookup. The name comes from two basic operations, multiply (MU) - * and rotate (R), used in its inner loop. Unlike cryptographic hash functions, - * it is not specifically designed to be difficult to reverse by an adversary, - * making it unsuitable for cryptographic purposes.

- * - *

This contains a Java port of the 32-bit hash function {@code MurmurHash2} - * and the 64-bit hash function {@code MurmurHash64A} from Austin Applyby's - * original {@code c++} code in SMHasher.

- * - *

This is a re-implementation of the original C code plus some additional - * features.

- * - *

This is public domain code with no copyrights. From home page of - * SMHasher:

- * - *
- * "All MurmurHash versions are public domain software, and the author - * disclaims all copyright to their code." - *
- * - * @see MurmurHash - * @see - * Original MurmurHash2 c++ code - * @since 1.13 - */ -public final class MurmurHash2 { - - // Constants for 32-bit variant - private static final int M32 = 0x5bd1e995; - private static final int R32 = 24; +import java.util.zip.Checksum; - // Constants for 64-bit variant - private static final long M64 = 0xc6a4a7935bd1e995L; - private static final int R64 = 47; +public final class MurmurHash2 implements Checksum { + private final int seed; - /** - * No instance methods. - */ - private MurmurHash2() { + public MurmurHash2(int seed) { + this.seed = seed; } - /** - * Generates a 32-bit hash from byte array with the given length and seed. - * - * @param data The input byte array - * @param length The length of the array - * @param seed The initial seed value - * @return The 32-bit hash - */ - public static int hash32(final byte[] data, final int length, final int seed) { - // Initialize the hash to a random value - int h = seed ^ length; - - // Mix 4 bytes at a time into the hash - final int nblocks = length >> 2; - - // body - for (int i = 0; i < nblocks; i++) { - final int index = (i << 2); - int k = ByteArray.getIntLE(data, index); - k *= M32; - k ^= k >>> R32; - k *= M32; - h *= M32; - h ^= k; - } - - // Handle the last few bytes of the input array - final int index = (nblocks << 2); - switch (length - index) { - case 3: - h ^= (data[index + 2] & 0xff) << 16; - // fallthrough - case 2: - h ^= (data[index + 1] & 0xff) << 8; - // fallthrough - case 1: - h ^= (data[index] & 0xff); - h *= M32; - } - - // Do a few final mixes of the hash to ensure the last few - // bytes are well-incorporated. - h ^= h >>> 13; - h *= M32; - h ^= h >>> 15; - - return h; + @Override + public void update(int b) { + // TODO } - /** - * Generates a 32-bit hash from byte array with the given length and a default seed value. - * This is a helper method that will produce the same result as: - * - *
-     * int seed = 0x9747b28c;
-     * int hash = MurmurHash2.hash32(data, length, seed);
-     * 
- * - * @param data The input byte array - * @param length The length of the array - * @return The 32-bit hash - * @see #hash32(byte[], int, int) - */ - public static int hash32(final byte[] data, final int length) { - return hash32(data, length, 0x9747b28c); + @Override + public void update(byte[] b, int off, int len) { + // TODO } - /** - * Generates a 64-bit hash from byte array of the given length and seed. - * - * @param data The input byte array - * @param length The length of the array - * @param seed The initial seed value - * @return The 64-bit hash of the given array - */ - public static long hash64(final byte[] data, final int length, final int seed) { - long h = (seed & 0xffffffffL) ^ (length * M64); - - final int nblocks = length >> 3; - - // body - for (int i = 0; i < nblocks; i++) { - final int index = (i << 3); - long k = ByteArray.getLongLE(data, index); - - k *= M64; - k ^= k >>> R64; - k *= M64; - - h ^= k; - h *= M64; - } - - final int index = (nblocks << 3); - switch (length - index) { - case 7: - h ^= ((long) data[index + 6] & 0xff) << 48; - // fallthrough - case 6: - h ^= ((long) data[index + 5] & 0xff) << 40; - // fallthrough - case 5: - h ^= ((long) data[index + 4] & 0xff) << 32; - // fallthrough - case 4: - h ^= ((long) data[index + 3] & 0xff) << 24; - // fallthrough - case 3: - h ^= ((long) data[index + 2] & 0xff) << 16; - // fallthrough - case 2: - h ^= ((long) data[index + 1] & 0xff) << 8; - // fallthrough - case 1: - h ^= ((long) data[index] & 0xff); - h *= M64; - } - - h ^= h >>> R64; - h *= M64; - h ^= h >>> R64; - - return h; + @Override + public long getValue() { + return 0; // TODO } - /** - * Generates a 64-bit hash from byte array with given length and a default seed value. - * This is a helper method that will produce the same result as: - * - *
-     * int seed = 0xe17a1465;
-     * int hash = MurmurHash2.hash64(data, length, seed);
-     * 
- * - * @param data The input byte array - * @param length The length of the array - * @return The 64-bit hash - * @see #hash64(byte[], int, int) - */ - public static long hash64(final byte[] data, final int length) { - return hash64(data, length, 0xe17a1465); + @Override + public void reset() { + // TODO } - } From 8d156ab291c85a7365081447fdedbaa312339896 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 9 Aug 2026 21:33:16 +0800 Subject: [PATCH 3/4] refactor: update MurmurHash2 to implement Checksum interface and optimize hash calculation --- .../CurseForgeRemoteAddonRepositoryTest.java | 54 ------------------- 1 file changed, 54 deletions(-) delete mode 100644 HMCLCore/src/test/java/org/jackhuang/hmcl/addon/curse/CurseForgeRemoteAddonRepositoryTest.java 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); - } -} From 93db810855937953f7511864ae580a0acc62b2e3 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 9 Aug 2026 21:51:43 +0800 Subject: [PATCH 4/4] Implement the MurmurHash2 32-bit checksum variant Assisted-by: codex:gpt-5.6-sol --- .../CurseForgeRemoteAddonRepository.java | 1 - .../org/jackhuang/hmcl/util/MurmurHash2.java | 182 +++++++++++++++++- .../jackhuang/hmcl/util/MurmurHash2Test.java | 124 ++++++++++++ 3 files changed, 302 insertions(+), 5 deletions(-) create mode 100644 HMCLCore/src/test/java/org/jackhuang/hmcl/util/MurmurHash2Test.java 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 0f18dee87f4..453b7235f8e 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 @@ -33,7 +33,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; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java index 874c1e0f637..e6e423cadbf 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java @@ -17,32 +17,206 @@ */ package org.jackhuang.hmcl.util; +import org.jetbrains.annotations.NotNullByDefault; + +import java.util.Arrays; +import java.util.Objects; import java.util.zip.Checksum; +/// Computes the 32-bit MurmurHash2 value of a sequence of bytes. +/// +/// Bytes supplied by successive update operations are treated as one contiguous +/// input. Four-byte words are decoded in little-endian order, matching the +/// reference implementation on little-endian platforms. [#getValue()] returns +/// the resulting unsigned 32-bit value as a `long`. +/// +/// Instances are not safe for concurrent use. +/// +/// @apiNote MurmurHash2 is a non-cryptographic hash and must not be used where +/// collision resistance or protection against malicious input is required. +/// @implNote The reference algorithm incorporates the final input length before +/// processing any words, so its state cannot be finalized incrementally. This +/// implementation retains one pre-mixed value for every complete input word. +/// Storage therefore grows linearly with the largest input processed by an +/// instance and is reused after [#reset()]. +/// @see MurmurHash2.cpp +@NotNullByDefault public final class MurmurHash2 implements Checksum { + /// Multiplication constant used by the 32-bit MurmurHash2 mixer. + private static final int MIX_MULTIPLIER = 0x5bd1e995; + + /// Right-shift distance used by the 32-bit MurmurHash2 mixer. + private static final int MIX_SHIFT = 24; + + /// Initial capacity of the complete-word buffer. + private static final int INITIAL_BLOCK_CAPACITY = 16; + + /// Seed mixed into the initial hash value. private final int seed; + /// Pre-mixed values of all complete four-byte input words. + private int[] mixedBlocks = new int[INITIAL_BLOCK_CAPACITY]; + + /// Number of entries in [#mixedBlocks] that contain input words. + private int blockCount; + + /// Incomplete trailing word assembled in little-endian order. + private int tail; + + /// Number of input bytes currently stored in [#tail]. + private int tailLength; + + /// Total input length modulo 232. + private int length; + + /// Cached unsigned hash value returned by [#getValue()]. + private long value; + + /// Whether [#value] represents all input supplied so far. + private boolean valueValid; + + /// Creates an empty MurmurHash2 checksum with the specified seed. + /// + /// @param seed the 32-bit seed mixed into the hash public MurmurHash2(int seed) { this.seed = seed; } + /// Appends the low eight bits of `b` to the input sequence. + /// + /// @param b the value whose low eight bits are appended @Override public void update(int b) { - // TODO + valueValid = false; + length++; + + tail |= (b & 0xff) << (tailLength * Byte.SIZE); + tailLength++; + + if (tailLength == Integer.BYTES) { + addBlock(tail); + tail = 0; + tailLength = 0; + } } + /// Appends `len` bytes starting at `off` in `b` to the input sequence. + /// + /// A zero-length update leaves the current value unchanged. + /// + /// @param b the array containing the bytes to append + /// @param off the offset of the first byte to append + /// @param len the number of bytes to append + /// @throws NullPointerException if `b` is `null` + /// @throws IndexOutOfBoundsException if `off` or `len` is negative, or if + /// `off + len` is greater than the array length @Override public void update(byte[] b, int off, int len) { - // TODO + Objects.checkFromIndexSize(off, len, b.length); + if (len == 0) { + return; + } + + valueValid = false; + length += len; + + int end = off + len; + + // Complete a word left over from a preceding update before processing + // directly from the caller's array. + while (tailLength != 0 && off < end) { + tail |= (b[off++] & 0xff) << (tailLength * Byte.SIZE); + tailLength++; + + if (tailLength == Integer.BYTES) { + addBlock(tail); + tail = 0; + tailLength = 0; + } + } + + while (off <= end - Integer.BYTES) { + addBlock(ByteArray.getIntLE(b, off)); + off += Integer.BYTES; + } + + while (off < end) { + tail |= (b[off++] & 0xff) << (tailLength * Byte.SIZE); + tailLength++; + } } + /// Returns the MurmurHash2 value of all bytes supplied since construction + /// or the last call to [#reset()]. + /// + /// The value is in the range `0` through `0xffff_ffffL`. This method does + /// not reset the checksum; later updates append to the same input sequence. + /// + /// @return the current unsigned 32-bit hash value @Override public long getValue() { - return 0; // TODO + if (valueValid) { + return value; + } + + int hash = seed ^ length; + + for (int i = 0; i < blockCount; i++) { + hash *= MIX_MULTIPLIER; + hash ^= mixedBlocks[i]; + } + + if (tailLength != 0) { + hash ^= tail; + hash *= MIX_MULTIPLIER; + } + + hash ^= hash >>> 13; + hash *= MIX_MULTIPLIER; + hash ^= hash >>> 15; + + value = Integer.toUnsignedLong(hash); + valueValid = true; + return value; } + /// Discards all accumulated input while retaining the seed. + /// + /// After this method returns, [#getValue()] produces the hash of an empty + /// input with the seed supplied to [#MurmurHash2(int)]. @Override public void reset() { - // TODO + blockCount = 0; + tail = 0; + tailLength = 0; + length = 0; + value = 0; + valueValid = false; + } + + /// Pre-mixes and stores one complete little-endian input word. + /// + /// @param block the input word to store + private void addBlock(int block) { + if (blockCount == mixedBlocks.length) { + int newCapacity = mixedBlocks.length << 1; + if (newCapacity <= mixedBlocks.length) { + newCapacity = Integer.MAX_VALUE; + } + mixedBlocks = Arrays.copyOf(mixedBlocks, newCapacity); + } + + mixedBlocks[blockCount++] = mixBlock(block); + } + + /// Applies the MurmurHash2 word mixer to one complete input word. + /// + /// @param block the input word + /// @return the mixed word + private static int mixBlock(int block) { + block *= MIX_MULTIPLIER; + block ^= block >>> MIX_SHIFT; + block *= MIX_MULTIPLIER; + return block; } } diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/MurmurHash2Test.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/MurmurHash2Test.java new file mode 100644 index 00000000000..dd54f1937d2 --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/MurmurHash2Test.java @@ -0,0 +1,124 @@ +/* + * 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.util; + +import org.jetbrains.annotations.NotNullByDefault; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/// Tests the 32-bit MurmurHash2 implementation against values produced by the +/// reference C++ implementation. +@NotNullByDefault +public final class MurmurHash2Test { + + /// Verifies fixed reference values, including every possible tail length. + @Test + public void testReferenceValues() { + assertHash(0, new byte[0], 0L); + assertHash(1, new byte[0], 1_540_447_798L); + assertHash(1, new byte[]{0, 1}, 788_976_164L); + assertHash(0, "hello".getBytes(StandardCharsets.UTF_8), 3_848_350_155L); + assertHash(0x9747b28c, "hello".getBytes(StandardCharsets.UTF_8), 2_132_663_229L); + assertHash(1, "The quick brown fox jumps over the lazy dog".getBytes(StandardCharsets.UTF_8), + 504_383_975L); + + assertHash(1, sequentialBytes(256), 253_525_554L); + assertHash(-1, new byte[]{0, -1, -128, 127, 1, 2, 3, 4, 5}, 1_934_485_809L); + } + + /// Verifies that update boundaries do not affect the resulting hash. + @Test + public void testFragmentedUpdates() { + byte[] input = sequentialBytes(256); + + var byteAtATime = new MurmurHash2(1); + for (byte value : input) { + byteAtATime.update(value); + } + assertEquals(253_525_554L, byteAtATime.getValue()); + + var chunks = new MurmurHash2(1); + int offset = 0; + int chunkSize = 1; + while (offset < input.length) { + int length = Math.min(chunkSize, input.length - offset); + chunks.update(input, offset, length); + offset += length; + chunkSize = chunkSize % 7 + 1; + } + assertEquals(253_525_554L, chunks.getValue()); + } + + /// Verifies repeated finalization, continued updates, and reset behavior. + @Test + public void testLifecycle() { + var hash = new MurmurHash2(1); + hash.update(0x168); + assertEquals(3_451_942_824L, hash.getValue()); + assertEquals(3_451_942_824L, hash.getValue()); + + hash.update(new byte[]{'e', 'l', 'l'}, 0, 3); + assertEquals(1_799_137_576L, hash.getValue()); + + hash.update('o'); + assertEquals(2_788_266_382L, hash.getValue()); + + hash.reset(); + assertEquals(1_540_447_798L, hash.getValue()); + } + + /// Verifies that invalid slice ranges are rejected without changing state. + @Test + public void testInvalidRangeDoesNotChangeState() { + var hash = new MurmurHash2(1); + byte[] input = {0, 1, 2}; + hash.update(input, 0, 2); + long value = hash.getValue(); + + assertThrows(IndexOutOfBoundsException.class, () -> hash.update(input, -1, 1)); + assertThrows(IndexOutOfBoundsException.class, () -> hash.update(input, 0, 4)); + assertEquals(value, hash.getValue()); + } + + /// Creates a byte array containing consecutive values starting at zero. + /// + /// @param length the array length + /// @return the generated array + private static byte[] sequentialBytes(int length) { + byte[] result = new byte[length]; + for (int i = 0; i < result.length; i++) { + result[i] = (byte) i; + } + return result; + } + + /// Verifies a hash against a fixed reference value. + /// + /// @param seed the hash seed + /// @param input the complete input + /// @param expected the expected unsigned hash value + private static void assertHash(int seed, byte[] input, long expected) { + var hash = new MurmurHash2(seed); + hash.update(input, 0, input.length); + assertEquals(expected, hash.getValue()); + } +}