Parse server properties numbers like Java

This commit is contained in:
voidsip committed 2026-11-06 05:48:05 -06:00
1 parent f4dcf8a2fb
commit 82dc682cbc
5 files changed
+289 -22

No files matched your search

+59 -4
View File
@@ -2,6 +2,15 @@
/// JDK 25 Integer.parseInt decimal semantics, including BMP decimal digits.
pub fn parse_java_integer(text: &str) -> Option<i32> {
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<i64> {
parse_java_decimal(text, i128::from(i64::MAX)).map(|value| value as i64)
}
fn parse_java_decimal(text: &str, max: i128) -> Option<i128> {
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<i32> {
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);
}
}
@@ -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<dyn std::error::Error>> {
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 {
"<random>".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(())
}
+58 -18
View File
@@ -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::<i32>().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::<i32>() {
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::<i32>() {
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::<i32>().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::<i32>().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<i64> {
if s.is_empty() {
return None;
}
Some(
s.parse::<i64>()
.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();
@@ -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() ? "<random>" : 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;
}
}
@@ -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()