mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
Merge pull request #11065 from voidsipp/fix/11038-server-property-numbers
Match Java numeric server properties
This commit is contained in:
5 files changed
+289
-22
No files matched your search
@@ -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(())
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
+43
@@ -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()
|
||||
Reference in new issue
Block a user