diff --git a/crates/horizon-core/src/text.rs b/crates/horizon-core/src/text.rs index f6333826f4..21027c001c 100644 --- a/crates/horizon-core/src/text.rs +++ b/crates/horizon-core/src/text.rs @@ -2,6 +2,15 @@ /// JDK 25 Integer.parseInt decimal semantics, including BMP decimal digits. pub fn parse_java_integer(text: &str) -> Option { + parse_java_decimal(text, i128::from(i32::MAX)).map(|value| value as i32) +} + +/// JDK 25 Long.parseLong decimal semantics, including BMP decimal digits. +pub fn parse_java_long(text: &str) -> Option { + parse_java_decimal(text, i128::from(i64::MAX)).map(|value| value as i64) +} + +fn parse_java_decimal(text: &str, max: i128) -> Option { let (negative, text) = if let Some(text) = text.strip_prefix('-') { (true, text) } else { @@ -17,17 +26,63 @@ pub fn parse_java_integer(text: &str) -> Option { 0x1b50, 0x1bb0, 0x1c40, 0x1c50, 0xa620, 0xa8d0, 0xa900, 0xa9d0, 0xa9f0, 0xaa50, 0xabf0, 0xff10, ]; - let mut value = 0i64; + let mut value = 0i128; for c in text.chars() { let c = c as u32; let zero = ZEROES .iter() .copied() .find(|zero| c >= *zero && c < *zero + 10)?; - value = value * 10 + i64::from(c - zero); - if value > i64::from(i32::MAX) + i64::from(negative) { + value = value * 10 + i128::from(c - zero); + if value > max + i128::from(negative) { return None; } } - Some(if negative { -value } else { value } as i32) + Some(if negative { -value } else { value }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decimal_integer_and_long_boundaries_follow_java() { + for (text, expected) in [ + ("2147483647", Some(i32::MAX)), + ("-2147483648", Some(i32::MIN)), + ("2147483648", None), + ("-2147483649", None), + ("+\u{0661}\u{ff12}", Some(12)), + ] { + assert_eq!(parse_java_integer(text), expected, "{text}"); + } + for (text, expected) in [ + ("9223372036854775807", Some(i64::MAX)), + ("-9223372036854775808", Some(i64::MIN)), + ("9223372036854775808", None), + ("-9223372036854775809", None), + ("+\u{0661}\u{ff12}", Some(12)), + ] { + assert_eq!(parse_java_long(text), expected, "{text}"); + } + for text in [ + "", + "+", + "-", + " 12", + "12 ", + "1_2", + "\u{b2}", + "\u{2460}", + "\u{1d7cf}", + "\u{2212}1", + ] { + assert_eq!(parse_java_integer(text), None, "{text}"); + assert_eq!(parse_java_long(text), None, "{text}"); + } + let zeroes = format!("{}12", "0".repeat(100)); + assert_eq!(parse_java_integer(&zeroes), Some(12)); + assert_eq!(parse_java_long(&zeroes), Some(12)); + assert_eq!(parse_java_long(&"9".repeat(100)), None); + } } diff --git a/crates/horizon-server-files/examples/properties_numbers.rs b/crates/horizon-server-files/examples/properties_numbers.rs new file mode 100644 index 0000000000..b763fac929 --- /dev/null +++ b/crates/horizon-server-files/examples/properties_numbers.rs @@ -0,0 +1,49 @@ +//! DedicatedServerProperties numeric parsing probe; no server or window. +use horizon_server_files::{ + java_properties::Properties, + properties::{Entropy, ServerProperties}, +}; +struct Fixed; +impl Entropy for Fixed { + fn next_long(&mut self) -> i64 { + 1 + } + fn next_int(&mut self, _: u32) -> u32 { + 0 + } +} +fn main() -> Result<(), Box> { + let input = std::env::args().nth(1).ok_or("INPUT")?; + for value in std::fs::read_to_string(input)?.lines() { + let mut raw = Properties::default(); + for key in ["max-players", "op-permission-level", "max-tick-time"] { + raw.put(key, value.into()); + } + let p = ServerProperties::new(raw, &mut Fixed)?; + println!( + "RESULT {} {} {}", + p.max_players, p.op_permission_level, p.max_tick_time + ); + let mut extra = Properties::default(); + for key in ["difficulty", "gamemode", "max-world-size", "level-seed"] { + extra.put(key, value.into()); + } + let q = ServerProperties::new(extra, &mut Fixed)?; + let seed = if q.random_seed { + "".into() + } else { + q.seed.to_string() + }; + println!( + "EXTRA {} {} {} {seed}", + q.difficulty, q.game_mode, q.max_world_size + ); + let mut broadcast = Properties::default(); + broadcast.put("entity-broadcast-range-percentage", value.into()); + match ServerProperties::new(broadcast, &mut Fixed) { + Ok(q) => println!("BROADCAST {}", q.entity_broadcast_range_percentage), + Err(_) => println!("BROADCAST ERROR"), + } + } + Ok(()) +} diff --git a/crates/horizon-server-files/src/properties.rs b/crates/horizon-server-files/src/properties.rs index 849e2a5418..e3e4b1685d 100644 --- a/crates/horizon-server-files/src/properties.rs +++ b/crates/horizon-server-files/src/properties.rs @@ -6,7 +6,10 @@ use super::{ java_properties::{Properties, date_string, now_millis}, json::{self, Json}, }; -use horizon_core::random::java_string_hash; +use horizon_core::{ + random::java_string_hash, + text::{parse_java_integer, parse_java_long}, +}; use horizon_protocol::crypto::md5; /// MinecraftServer.ServerResourcePackInfo. @@ -210,10 +213,10 @@ impl Reader { ) } fn int(&mut self, key: &str, default: i32) -> i32 { - self.get(key, |s| s.parse().ok(), i32::to_string, default) + self.get(key, parse_java_integer, i32::to_string, default) } fn long(&mut self, key: &str, default: i64) -> i64 { - self.get(key, |s| s.parse().ok(), i64::to_string, default) + self.get(key, parse_java_long, i64::to_string, default) } fn boolean(&mut self, key: &str, default: bool) -> bool { self.get(key, |s| Some(java_bool(s)), bool::to_string, default) @@ -224,7 +227,7 @@ impl Reader { fn permission(&mut self, key: &str, default: i32) -> i32 { self.get( key, - |s| s.parse::().ok().map(|v| v.clamp(0, 4)), + |s| parse_java_integer(s).map(|v| v.clamp(0, 4)), i32::to_string, default, ) @@ -257,18 +260,18 @@ impl ServerProperties { let enforce_whitelist = r.boolean("enforce-whitelist", false); let difficulty = r.get( "difficulty", - |s| match s.parse::() { - Ok(id) => Some(id.rem_euclid(4)), - Err(_) => DIFFICULTIES.iter().position(|n| *n == s).map(|i| i as i32), + |s| match parse_java_integer(s) { + Some(id) => Some(id.rem_euclid(4)), + None => DIFFICULTIES.iter().position(|n| *n == s).map(|i| i as i32), }, |d| DIFFICULTIES[*d as usize].to_owned(), 1, ); let game_mode = r.get( "gamemode", - |s| match s.parse::() { - Ok(id) => Some(if (0..4).contains(&id) { id } else { 0 }), - Err(_) => Some(GAME_MODES.iter().position(|n| *n == s).unwrap_or(0) as i32), + |s| match parse_java_integer(s) { + Some(id) => Some(if (0..4).contains(&id) { id } else { 0 }), + None => Some(GAME_MODES.iter().position(|n| *n == s).unwrap_or(0) as i32), }, |g| GAME_MODES[*g as usize].to_owned(), 0, @@ -311,7 +314,7 @@ impl ServerProperties { let broadcast_console_to_ops = r.boolean("broadcast-console-to-ops", true); let max_world_size = r.get( "max-world-size", - |s| s.parse::().ok().map(|v| v.clamp(1, 29_999_984)), + |s| parse_java_integer(s).map(|v| v.clamp(1, 29_999_984)), i32::to_string, 29_999_984, ); @@ -322,9 +325,11 @@ impl ServerProperties { let hide_online_players = r.boolean("hide-online-players", false); // Its deserializer is not wrapped: a non-number throws out of the constructor. let entity_broadcast_range_percentage = match r.raw("entity-broadcast-range-percentage") { - Some(s) => s.parse::().map(|v| v.clamp(10, 1000)).map_err(|_| { - format!("java.lang.NumberFormatException: For input string: \"{s}\"") - })?, + Some(s) => parse_java_integer(&s) + .map(|v| v.clamp(10, 1000)) + .ok_or_else(|| { + format!("java.lang.NumberFormatException: For input string: \"{s}\"") + })?, None => 100, }; r.p.put( @@ -490,10 +495,7 @@ pub fn parse_seed(s: &str) -> Option { if s.is_empty() { return None; } - Some( - s.parse::() - .unwrap_or_else(|_| i64::from(java_string_hash(s.encode_utf16()))), - ) + Some(parse_java_long(s).unwrap_or_else(|| i64::from(java_string_hash(s.encode_utf16())))) } fn server_pack_info( @@ -602,6 +604,44 @@ mod tests { 0 } } + #[test] + fn unicode_decimal_server_settings_match_java_number_parsers() { + for value in ["12", "\u{0661}\u{0662}", "\u{ff11}\u{ff12}"] { + let mut raw = Properties::default(); + raw.put("max-players", value.into()); + raw.put("max-tick-time", value.into()); + raw.put("function-permission-level", "-\u{0661}".into()); + let p = ServerProperties::new(raw, &mut Fixed).unwrap(); + assert_eq!(p.max_players, 12, "{value}"); + assert_eq!(p.max_tick_time, 12, "{value}"); + assert_eq!(p.function_permission_level, 0); + assert_eq!(p.raw.get("max-players"), Some("12")); + assert_eq!(p.raw.get("max-tick-time"), Some("12")); + } + } + + #[test] + fn unicode_numeric_enums_ranges_and_seeds_keep_vanilla_conversions() { + let mut raw = Properties::default(); + for (key, value) in [ + ("difficulty", "\u{0663}"), + ("gamemode", "\u{ff11}"), + ("max-world-size", "\u{0661}\u{0662}"), + ("entity-broadcast-range-percentage", "\u{ff11}\u{ff12}"), + ("level-seed", "\u{0661}\u{0662}"), + ] { + raw.put(key, value.into()); + } + let p = ServerProperties::new(raw, &mut Fixed).unwrap(); + assert_eq!((p.difficulty, p.game_mode, p.max_world_size), (3, 1, 12)); + assert_eq!(p.entity_broadcast_range_percentage, 12); + assert_eq!((p.seed, p.random_seed), (12, false)); + assert_eq!(p.raw.get("difficulty"), Some("hard")); + assert_eq!(p.raw.get("gamemode"), Some("creative")); + assert_eq!(p.raw.get("max-world-size"), Some("12")); + assert_eq!(p.raw.get("entity-broadcast-range-percentage"), Some("12")); + } + #[test] fn defaults_match_vanilla_init_settings() { let p = ServerProperties::new(Properties::default(), &mut Fixed).unwrap(); diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaServerPropertyNumbersOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaServerPropertyNumbersOracle.java new file mode 100644 index 0000000000..7fe457f678 --- /dev/null +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaServerPropertyNumbersOracle.java @@ -0,0 +1,43 @@ +package dev.horizonlab.parity; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.dedicated.DedicatedServerProperties; + +/** Actual numeric settings conversion; constructs settings, never starts a server. */ +public final class VanillaServerPropertyNumbersOracle { + public static void main(String[] args) throws Exception { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + for (String value : Files.readAllLines(Path.of(args[0]), StandardCharsets.UTF_8)) { + Properties raw = properties(value, "max-players", "op-permission-level", "max-tick-time"); + DedicatedServerProperties p = new DedicatedServerProperties(raw); + System.out.println("RESULT " + p.maxPlayers.get() + " " + p.opPermissions.get().level().id() + + " " + p.maxTickTime); + + Properties extra = properties(value, "difficulty", "gamemode", "max-world-size", "level-seed"); + DedicatedServerProperties q = new DedicatedServerProperties(extra); + String seed = value.trim().isEmpty() ? "" : Long.toString(q.worldOptions.seed()); + System.out.println("EXTRA " + q.difficulty.get().getId() + " " + q.gameMode.get().getId() + + " " + q.maxWorldSize + " " + seed); + + Properties broadcast = properties(value, "entity-broadcast-range-percentage"); + try { + System.out.println("BROADCAST " + + new DedicatedServerProperties(broadcast).entityBroadcastRangePercentage.get()); + } catch (NumberFormatException invalid) { + System.out.println("BROADCAST ERROR"); + } + } + } + + private static Properties properties(String value, String... keys) { + Properties result = new Properties(); + for (String key : keys) result.setProperty(key, value); + return result; + } +} diff --git a/tools/test_server_property_numbers_parity.py b/tools/test_server_property_numbers_parity.py new file mode 100644 index 0000000000..454dfd5e47 --- /dev/null +++ b/tools/test_server_property_numbers_parity.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""DedicatedServerProperties decimal settings, enums, bounds and seeds against pinned vanilla. + +Only constructs settings from in-memory Properties. No server, EULA, window or player data. +""" +import os +import re +import subprocess + +try: + from . import lab, test_rust_parity as base +except ImportError: + import lab + import test_rust_parity as base + + +def number_cases(): + values = ['', '+', '-', '0', '-0', '+12', '12', ' 12', '12 ', '1_2', + '2147483647', '2147483648', '-2147483648', '-2147483649', + '9223372036854775807', '9223372036854775808', + '-9223372036854775808', '-9223372036854775809', + '0' * 100 + '12', '9' * 100, '\u00b2', '\u2460', '\U0001d7cf', '\u22121'] + # Exercise every BMP decimal-digit block independently against the real JDK parser. + zeroes = [0x30, 0x660, 0x6f0, 0x7c0, 0x966, 0x9e6, 0xa66, 0xae6, 0xb66, + 0xbe6, 0xc66, 0xce6, 0xd66, 0xde6, 0xe50, 0xed0, 0xf20, 0x1040, + 0x1090, 0x17e0, 0x1810, 0x1946, 0x19d0, 0x1a80, 0x1a90, 0x1b50, + 0x1bb0, 0x1c40, 0x1c50, 0xa620, 0xa8d0, 0xa900, 0xa9d0, 0xa9f0, + 0xaa50, 0xabf0, 0xff10] + for zero in zeroes: + for value in ['12', '-1', '+2147483647', '-2147483648', + '9223372036854775807', '-9223372036854775808']: + values.append(''.join(chr(zero + int(c)) if c.isdigit() else c for c in value)) + return values + + +def main(): + lock, jars, libraries = base.verify_artifacts() + build = lab.LOCAL / 'server-property-numbers-parity' + build.mkdir(parents=True, exist_ok=True) + report = build / 'report.json' + report.unlink(missing_ok=True) + classes = build / 'classes' + classes.mkdir(exist_ok=True) + oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaServerPropertyNumbersOracle.java' + sources = [oracle, lab.ROOT / 'crates/horizon-core/src/text.rs', + lab.ROOT / 'crates/horizon-server-files/src/properties.rs', + lab.ROOT / 'crates/horizon-server-files/examples/properties_numbers.rs', + lab.ROOT / 'tools/test_server_property_numbers_parity.py', lab.ROOT / 'Cargo.lock'] + hashes = base.source_hashes(sources) + values = number_cases() + inputs = build / 'numbers.txt' + inputs.write_text('\n'.join(values) + '\n', encoding='utf-8') + classpath = os.pathsep.join(map(str, [jars['server'], *libraries])) + + def run(command, name): + result = subprocess.run(list(map(str, command)), cwd=lab.ROOT, capture_output=True, + text=True, encoding='utf-8', timeout=900) + (build / (name + '.log')).write_text(result.stdout + result.stderr, encoding='utf-8') + result.check_returncode() + return re.findall(r'(?:RESULT|EXTRA|BROADCAST) .*', result.stdout) + + run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', + '-cp', classpath, '-d', classes, oracle], 'compile-java') + expected = run(['java', '-Djava.awt.headless=true', '-cp', str(classes) + os.pathsep + classpath, + 'dev.horizonlab.parity.VanillaServerPropertyNumbersOracle', inputs], 'vanilla') + actual = run(['cargo', 'run', '--locked', '-p', 'horizon-server-files', '--example', + 'properties_numbers', '--', inputs], 'rust') + if len(expected) != len(actual) or len(actual) != len(values) * 3: + raise AssertionError(f'incomplete corpus: {len(expected)} vanilla / {len(actual)} native records') + differences = [(i, a, b) for i, (a, b) in enumerate(zip(expected, actual)) if a != b] + base.ensure_sources_unchanged(sources, hashes) + lab.write_json(report, dict(passed=not differences, inputs=len(values), records=len(actual), + differences=differences, sources_sha256=hashes, + server_jar_sha256=lab.digest(jars['server'], 'sha256'), scope=__doc__)) + assert not differences, f'{len(differences)} differences: {differences[:5]}' + print(f'{len(values)} numeric settings inputs match vanilla ({len(actual)} records): {report}') + + +if __name__ == '__main__': + main()