Skip to main content

steel_utils/random/
name_hash.rs

1/// Precomputed hash of a resource name for both random implementations.
2///
3/// Holds an MD5 digest (for Xoroshiro) and a Java `String.hashCode()` (for Legacy).
4/// All fields are computed at compile time when used with `const` bindings:
5/// ```ignore
6/// const OFFSET: NameHash = NameHash::new("minecraft:offset");
7/// ```
8#[derive(Clone, Copy)]
9pub struct NameHash {
10    /// MD5 digest split into two big-endian u64s (matches `md5` crate output layout).
11    pub md5: [u64; 2],
12    /// Java `String.hashCode()` for ASCII strings.
13    pub java_hash: i32,
14}
15
16impl NameHash {
17    /// Compute a `NameHash` from an ASCII resource name.
18    ///
19    /// This is a `const fn` so it can be evaluated at compile time.
20    /// Panics if the string is >= 56 bytes (MD5 single-block limit).
21    #[must_use]
22    pub const fn new(name: &str) -> Self {
23        let digest = const_md5(name.as_bytes());
24        let lo = u64::from_be_bytes([
25            digest[0], digest[1], digest[2], digest[3], digest[4], digest[5], digest[6], digest[7],
26        ]);
27        let hi = u64::from_be_bytes([
28            digest[8], digest[9], digest[10], digest[11], digest[12], digest[13], digest[14],
29            digest[15],
30        ]);
31
32        Self {
33            md5: [lo, hi],
34            java_hash: java_hash_code(name),
35        }
36    }
37}
38
39/// Java `String.hashCode()` for ASCII strings.
40///
41/// Vanilla uses UTF-16 code units, but for ASCII strings each byte maps 1:1.
42const fn java_hash_code(s: &str) -> i32 {
43    let bytes = s.as_bytes();
44    let mut hash = 0_i32;
45    let mut i = 0;
46    while i < bytes.len() {
47        hash = hash.wrapping_mul(31).wrapping_add(bytes[i] as i32);
48        i += 1;
49    }
50    hash
51}
52
53// ── Const MD5 (single-block, messages < 56 bytes) ──────────────────────────
54
55const S: [u32; 64] = [
56    7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9,
57    14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15,
58    21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
59];
60
61// RFC 1321 precomputed table: T[i] = floor(2^32 * abs(sin(i+1)))
62#[expect(
63    clippy::unreadable_literal,
64    reason = "RFC 1321 precomputed table; separators would obscure the standard values"
65)]
66const T: [u32; 64] = [
67    0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
68    0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821,
69    0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
70    0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a,
71    0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
72    0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
73    0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
74    0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391,
75];
76
77/// MD5 digest for messages shorter than 56 bytes (single 64-byte block).
78// RFC 1321 uses single-letter variable names (a, b, c, d, f, g) — conventional for MD5.
79#[expect(
80    clippy::many_single_char_names,
81    reason = "matches RFC 1321 algorithm notation"
82)]
83// RFC 1321 initial hash values and round constants are unseparated by convention.
84#[expect(
85    clippy::unreadable_literal,
86    reason = "RFC 1321 initial hash values; underscores would obscure the standard constants"
87)]
88const fn const_md5(data: &[u8]) -> [u8; 16] {
89    assert!(
90        data.len() < 56,
91        "const_md5: messages >= 56 bytes not supported"
92    );
93
94    // Pad into a single 64-byte block
95    let mut block = [0u8; 64];
96    let mut i = 0;
97    while i < data.len() {
98        block[i] = data[i];
99        i += 1;
100    }
101    block[data.len()] = 0x80;
102
103    // Append original length in bits as 64-bit LE at bytes 56..64
104    let bit_len = (data.len() as u64) * 8;
105    let len_bytes = bit_len.to_le_bytes();
106    i = 0;
107    while i < 8 {
108        block[56 + i] = len_bytes[i];
109        i += 1;
110    }
111
112    // Parse into 16 little-endian u32 words
113    let mut m = [0u32; 16];
114    i = 0;
115    while i < 16 {
116        m[i] = u32::from_le_bytes([
117            block[i * 4],
118            block[i * 4 + 1],
119            block[i * 4 + 2],
120            block[i * 4 + 3],
121        ]);
122        i += 1;
123    }
124
125    let mut a: u32 = 0x67452301;
126    let mut b: u32 = 0xefcdab89;
127    let mut c: u32 = 0x98badcfe;
128    let mut d: u32 = 0x10325476;
129
130    i = 0;
131    while i < 64 {
132        let f;
133        let g;
134        if i < 16 {
135            f = (b & c) | (!b & d);
136            g = i;
137        } else if i < 32 {
138            f = (d & b) | (!d & c);
139            g = (5 * i + 1) % 16;
140        } else if i < 48 {
141            f = b ^ c ^ d;
142            g = (3 * i + 5) % 16;
143        } else {
144            f = c ^ (b | !d);
145            g = (7 * i) % 16;
146        }
147
148        let temp = d;
149        d = c;
150        c = b;
151        let x = a.wrapping_add(f).wrapping_add(T[i]).wrapping_add(m[g]);
152        b = b.wrapping_add(x.rotate_left(S[i]));
153        a = temp;
154
155        i += 1;
156    }
157
158    a = a.wrapping_add(0x67452301);
159    b = b.wrapping_add(0xefcdab89);
160    c = c.wrapping_add(0x98badcfe);
161    d = d.wrapping_add(0x10325476);
162
163    let ab = a.to_le_bytes();
164    let bb = b.to_le_bytes();
165    let cb = c.to_le_bytes();
166    let db = d.to_le_bytes();
167    [
168        ab[0], ab[1], ab[2], ab[3], bb[0], bb[1], bb[2], bb[3], cb[0], cb[1], cb[2], cb[3], db[0],
169        db[1], db[2], db[3],
170    ]
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn const_md5_matches_crate() {
179        let names = [
180            "minecraft:clay_bands",
181            "minecraft:offset",
182            "minecraft:aquifer",
183            "minecraft:ore",
184            "minecraft:terrain",
185            "minecraft:overworld",
186            "octave_0",
187            "octave_-7",
188            "TEST STRING",
189            "test_noise",
190        ];
191        for name in names {
192            let expected = md5::compute(name.as_bytes());
193            let actual = const_md5(name.as_bytes());
194            assert_eq!(
195                &actual, &*expected,
196                "MD5 mismatch for {name:?}: expected {expected:?}, got {actual:?}"
197            );
198        }
199    }
200
201    #[test]
202    fn java_hash_matches_legacy() {
203        // Known Java String.hashCode() values for ASCII strings
204        assert_eq!(java_hash_code("minecraft:offset"), {
205            let mut hash = 0_i32;
206            for b in "minecraft:offset".encode_utf16() {
207                hash = hash.wrapping_mul(31).wrapping_add(i32::from(b));
208            }
209            hash
210        });
211    }
212
213    #[test]
214    fn name_hash_is_const() {
215        // Verify it compiles as a const
216        const HASH: NameHash = NameHash::new("minecraft:offset");
217        let _ = HASH;
218    }
219}