mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Model every text-display metadata field against the Java wire codec
Add a native Java oracle for the actual Display/TextDisplay accessors and encoded metadata, with 513 combinations of flags, opacity, billboard mode and transforms. Preserve styled text and partial updates. Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
This commit is contained in:
1 parent
f9e3244281
commit
dc1e16f472
6 files changed
+274
No files matched your search
@@ -85,6 +85,7 @@ mod sprint_probe;
|
||||
mod subtitle_fixture;
|
||||
mod tab_list_fixture;
|
||||
mod team_rules_fixture;
|
||||
mod text_display_fixture;
|
||||
mod toggle_keys_probe;
|
||||
mod tooltip_display_fixture;
|
||||
mod trading_fixture;
|
||||
@@ -170,6 +171,7 @@ probes! {
|
||||
team_rules_fixture,
|
||||
toggle_keys_probe,
|
||||
tooltip_display_fixture,
|
||||
text_display_fixture,
|
||||
trading_fixture,
|
||||
translucency_fixture,
|
||||
weather_fixture,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
use horizon_client::text_display::TextDisplayData;
|
||||
use horizon_protocol::{ids, packets::play::Clientbound};
|
||||
use std::{error::Error, io::Write};
|
||||
|
||||
pub fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let mut out = std::fs::File::create(&args[2])?;
|
||||
for line in std::fs::read_to_string(&args[1])?.lines() {
|
||||
let input: serde_json::Value = serde_json::from_str(line)?;
|
||||
let hex = input["wire"].as_str().unwrap();
|
||||
let bytes: Vec<u8> = (0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let Clientbound::SetEntityData(packet) =
|
||||
Clientbound::decode(ids::play::clientbound::SET_ENTITY_DATA, &bytes)?
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let mut display = TextDisplayData::default();
|
||||
display.apply(&packet.values);
|
||||
let mut state = serde_json::to_value(&display)?;
|
||||
state["text"] = display.text.plain().into();
|
||||
state["alignment"] = display.alignment().into();
|
||||
writeln!(out, "{}", state)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -177,6 +177,7 @@ pub mod tab_list;
|
||||
pub mod telemetry;
|
||||
pub mod testgen;
|
||||
pub mod text;
|
||||
pub mod text_display;
|
||||
pub mod tick_chunk_membership;
|
||||
pub mod ticket_levels;
|
||||
pub mod title;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Display/TextDisplay synced data (26.3), checked against vanilla accessors
|
||||
//! and ClientboundSetEntityDataPacket's own stream codec.
|
||||
use crate::text::Text;
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct TextDisplayData {
|
||||
pub interpolation_delay: i32,
|
||||
pub interpolation_duration: i32,
|
||||
pub teleport_duration: i32,
|
||||
pub translation: [f32; 3],
|
||||
pub scale: [f32; 3],
|
||||
pub left_rotation: [f32; 4],
|
||||
pub right_rotation: [f32; 4],
|
||||
pub billboard: u8,
|
||||
pub brightness: i32,
|
||||
pub view_range: f32,
|
||||
pub shadow_radius: f32,
|
||||
pub shadow_strength: f32,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub glow_color: i32,
|
||||
#[serde(skip)]
|
||||
pub text: Text,
|
||||
pub line_width: i32,
|
||||
pub background: i32,
|
||||
pub opacity: i8,
|
||||
pub flags: u8,
|
||||
}
|
||||
|
||||
impl Default for TextDisplayData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
interpolation_delay: 0,
|
||||
interpolation_duration: 0,
|
||||
teleport_duration: 0,
|
||||
translation: [0.0; 3],
|
||||
scale: [1.0; 3],
|
||||
left_rotation: [0.0, 0.0, 0.0, 1.0],
|
||||
right_rotation: [0.0, 0.0, 0.0, 1.0],
|
||||
billboard: 0,
|
||||
brightness: -1,
|
||||
view_range: 1.0,
|
||||
shadow_radius: 0.0,
|
||||
shadow_strength: 1.0,
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
glow_color: -1,
|
||||
text: Text::empty(),
|
||||
line_width: 200,
|
||||
background: 0x4000_0000,
|
||||
opacity: -1,
|
||||
flags: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TextDisplayData {
|
||||
/// Omitted fields retain their previous values.
|
||||
pub fn apply(&mut self, values: &[(u8, i32, Value)]) {
|
||||
for (id, _, value) in values {
|
||||
match (id, value) {
|
||||
(8, Value::Int(v)) => self.interpolation_delay = *v,
|
||||
(9, Value::Int(v)) => self.interpolation_duration = *v,
|
||||
(10, Value::Int(v)) => self.teleport_duration = *v,
|
||||
(11, Value::Floats(v)) if v.len() == 3 => self.translation.copy_from_slice(v),
|
||||
(12, Value::Floats(v)) if v.len() == 3 => self.scale.copy_from_slice(v),
|
||||
(13, Value::Floats(v)) if v.len() == 4 => self.left_rotation.copy_from_slice(v),
|
||||
(14, Value::Floats(v)) if v.len() == 4 => self.right_rotation.copy_from_slice(v),
|
||||
(15, Value::Byte(v)) => self.billboard = *v as u8,
|
||||
(16, Value::Int(v)) => self.brightness = *v,
|
||||
(17, Value::Float(v)) => self.view_range = *v,
|
||||
(18, Value::Float(v)) => self.shadow_radius = *v,
|
||||
(19, Value::Float(v)) => self.shadow_strength = *v,
|
||||
(20, Value::Float(v)) => self.width = *v,
|
||||
(21, Value::Float(v)) => self.height = *v,
|
||||
(22, Value::Int(v)) => self.glow_color = *v,
|
||||
(23, Value::Text(v)) => self.text = crate::entity_nbt::name_text(v),
|
||||
(24, Value::Int(v)) => self.line_width = *v,
|
||||
(25, Value::Int(v)) => self.background = *v,
|
||||
(26, Value::Byte(v)) => self.opacity = *v,
|
||||
(27, Value::Byte(v)) => self.flags = *v as u8,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn alignment(&self) -> &'static str {
|
||||
if self.flags & 8 != 0 {
|
||||
"LEFT"
|
||||
} else if self.flags & 16 != 0 {
|
||||
"RIGHT"
|
||||
} else {
|
||||
"CENTER"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn partial_updates_retain_values_and_bad_vectors_do_not_panic() {
|
||||
let mut d = TextDisplayData::default();
|
||||
d.apply(&[(24, 1, Value::Int(80)), (27, 0, Value::Byte(24))]);
|
||||
d.apply(&[(11, 0, Value::Floats(vec![1.0]))]);
|
||||
assert_eq!(d.line_width, 80);
|
||||
assert_eq!(d.alignment(), "LEFT");
|
||||
assert_eq!(d.translation, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import com.google.gson.*;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import java.nio.file.*;
|
||||
import java.lang.reflect.*;
|
||||
import java.util.*;
|
||||
import net.minecraft.nbt.TagParser;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.chat.*;
|
||||
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
|
||||
import net.minecraft.network.syncher.EntityDataAccessor;
|
||||
import net.minecraft.world.entity.*;
|
||||
import org.joml.*;
|
||||
|
||||
/** Actual accessors/defaults and wire bytes, not a second implementation of their layout. */
|
||||
public final class VanillaTextDisplayOracle {
|
||||
static final class Level extends net.minecraft.server.level.ServerLevel {
|
||||
Level() { super(null, null, null, null, null, null, false, 0L, List.of(), false); }
|
||||
@Override public int getNextEntityId() { return 7; }
|
||||
@Override public net.minecraft.core.RegistryAccess registryAccess() { return ParityData.access; }
|
||||
}
|
||||
static final String[] KEYS = {"interpolation_delay", "interpolation_duration", "teleport_duration",
|
||||
"translation", "scale", "left_rotation", "right_rotation", "billboard", "brightness", "view_range",
|
||||
"shadow_radius", "shadow_strength", "width", "height", "glow_color", "text", "line_width", "background", "opacity", "flags"};
|
||||
static final String[] FIELDS = {"DATA_TRANSFORMATION_INTERPOLATION_START_DELTA_TICKS_ID", "DATA_TRANSFORMATION_INTERPOLATION_DURATION_ID", "DATA_POS_ROT_INTERPOLATION_DURATION_ID",
|
||||
"DATA_TRANSLATION_ID", "DATA_SCALE_ID", "DATA_LEFT_ROTATION_ID", "DATA_RIGHT_ROTATION_ID", "DATA_BILLBOARD_RENDER_CONSTRAINTS_ID", "DATA_BRIGHTNESS_OVERRIDE_ID", "DATA_VIEW_RANGE_ID",
|
||||
"DATA_SHADOW_RADIUS_ID", "DATA_SHADOW_STRENGTH_ID", "DATA_WIDTH_ID", "DATA_HEIGHT_ID", "DATA_GLOW_COLOR_OVERRIDE_ID", "DATA_TEXT_ID", "DATA_LINE_WIDTH_ID", "DATA_BACKGROUND_COLOR_ID", "DATA_TEXT_OPACITY_ID", "DATA_STYLE_FLAGS_ID"};
|
||||
static final Gson GSON = new Gson();
|
||||
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
public static void main(String[] args) throws Exception {
|
||||
ParityData.init();
|
||||
var unsafeField = Class.forName("sun.misc.Unsafe").getDeclaredField("theUnsafe");
|
||||
unsafeField.setAccessible(true);
|
||||
Object unsafe = unsafeField.get(null);
|
||||
var level = (Level) unsafe.getClass().getMethod("allocateInstance", Class.class).invoke(unsafe, Level.class);
|
||||
var out = new ArrayList<String>();
|
||||
for (String line : Files.readAllLines(Path.of(args[0]))) {
|
||||
JsonObject input = JsonParser.parseString(line).getAsJsonObject();
|
||||
var type = net.minecraft.core.registries.BuiltInRegistries.ENTITY_TYPE.getValue(net.minecraft.resources.Identifier.parse("minecraft:text_display"));
|
||||
var display = new Display.TextDisplay(type, level);
|
||||
var state = new JsonObject();
|
||||
for (int i = 0; i < KEYS.length; i++) {
|
||||
Field field = (i < 15 ? Display.class : Display.TextDisplay.class).getDeclaredField(FIELDS[i]);
|
||||
field.setAccessible(true);
|
||||
EntityDataAccessor accessor = (EntityDataAccessor) field.get(null);
|
||||
Object value = display.getEntityData().get(accessor);
|
||||
if (input.has(KEYS[i])) {
|
||||
JsonElement in = input.get(KEYS[i]);
|
||||
if (value instanceof Integer) value = in.getAsInt();
|
||||
else if (value instanceof Float) value = in.getAsFloat();
|
||||
else if (value instanceof Byte) value = in.getAsByte();
|
||||
else if (value instanceof Vector3f) value = new Vector3f(in.getAsJsonArray().get(0).getAsFloat(), in.getAsJsonArray().get(1).getAsFloat(), in.getAsJsonArray().get(2).getAsFloat());
|
||||
else if (value instanceof Quaternionf) value = new Quaternionf(in.getAsJsonArray().get(0).getAsFloat(), in.getAsJsonArray().get(1).getAsFloat(), in.getAsJsonArray().get(2).getAsFloat(), in.getAsJsonArray().get(3).getAsFloat());
|
||||
else if (value instanceof Component) value = ComponentSerialization.CODEC.parse(ParityData.ops, TagParser.parseCompoundFully("{v:" + in + "}").get("v")).getOrThrow();
|
||||
display.getEntityData().set(accessor, value);
|
||||
}
|
||||
JsonElement encoded;
|
||||
if (value instanceof Vector3f v) encoded = GSON.toJsonTree(new float[]{v.x, v.y, v.z});
|
||||
else if (value instanceof Quaternionf q) encoded = GSON.toJsonTree(new float[]{q.x, q.y, q.z, q.w});
|
||||
else if (value instanceof Component c) encoded = new JsonPrimitive(c.getString());
|
||||
else encoded = GSON.toJsonTree(value);
|
||||
state.add(KEYS[i], encoded);
|
||||
}
|
||||
state.addProperty("alignment", Display.TextDisplay.getAlign(state.get("flags").getAsByte()).name());
|
||||
var values = display.getEntityData().getNonDefaultValues();
|
||||
var buf = new RegistryFriendlyByteBuf(Unpooled.buffer(), ParityData.access);
|
||||
ClientboundSetEntityDataPacket.STREAM_CODEC.encode(buf, new ClientboundSetEntityDataPacket(7, values == null ? List.of() : values));
|
||||
byte[] bytes = new byte[buf.readableBytes()]; buf.readBytes(bytes); buf.release();
|
||||
var result = new JsonObject(); result.add("expected", state); result.addProperty("wire", HexFormat.of().formatHex(bytes));
|
||||
out.add(GSON.toJson(result));
|
||||
}
|
||||
Files.write(Path.of(args[1]), out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Decode text-display metadata produced by vanilla's actual entity accessors/codecs."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
try:
|
||||
from . import lab, test_rust_parity as base, oracle_harness as harness
|
||||
except ImportError:
|
||||
import lab
|
||||
import test_rust_parity as base
|
||||
import oracle_harness as harness
|
||||
|
||||
|
||||
def main():
|
||||
build = lab.LOCAL / 'text-display-parity'
|
||||
build.mkdir(parents=True, exist_ok=True)
|
||||
(build / 'report.json').unlink(missing_ok=True)
|
||||
lock, jars, libraries = base.verify_artifacts()
|
||||
cases = [{}] + [dict(flags=flags, opacity=opacity, billboard=billboard,
|
||||
text={'text': 'Lobby 日本語', 'bold': bool(flags & 1)},
|
||||
translation=[1.25, -2.5, 0.0], scale=[0.5, 2.0, 1.0],
|
||||
left_rotation=[0, 0, 0, 1], right_rotation=[0, 1, 0, 0],
|
||||
line_width=80, background=-2147418368, brightness=15728880,
|
||||
interpolation_delay=-1, interpolation_duration=12, teleport_duration=4,
|
||||
view_range=2.0, shadow_radius=0.5, shadow_strength=0.75, width=3.0, height=2.0, glow_color=16711680)
|
||||
for flags in range(32) for opacity in (-128, -1, 0, 127) for billboard in range(4)]
|
||||
fixtures = build / 'cases.jsonl'
|
||||
fixtures.write_text(''.join(json.dumps(c, ensure_ascii=False) + '\n' for c in cases))
|
||||
classes = build / 'classes'
|
||||
classes.mkdir(exist_ok=True)
|
||||
source = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaTextDisplayOracle.java'
|
||||
sources = [Path(__file__).resolve(), source,
|
||||
lab.ROOT / 'crates/horizon-client/src/text_display.rs',
|
||||
lab.ROOT / 'crates/horizon-protocol/src/entity_data.rs', lab.ROOT / 'Cargo.lock']
|
||||
tested = base.source_hashes(sources)
|
||||
cp = os.pathsep.join(map(str, [jars['server'], *libraries]))
|
||||
run = lambda command, name: harness.run_logged(command, build, name)
|
||||
run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-sourcepath',
|
||||
lab.ROOT / 'mods/horizon/src/test/java', '-cp', cp, '-d', classes, source], 'compile-java')
|
||||
run(['java', '-Xmx1G', '-cp', str(classes) + os.pathsep + cp,
|
||||
'dev.horizonlab.parity.VanillaTextDisplayOracle', fixtures, build / 'java.jsonl'], 'java')
|
||||
command = harness.build_probe(build, 'text_display_fixture')
|
||||
run([*command, build / 'java.jsonl', build / 'rust.jsonl'], 'rust')
|
||||
expected = [json.loads(line)['expected'] for line in (build / 'java.jsonl').read_text().splitlines()]
|
||||
actual = [json.loads(line) for line in (build / 'rust.jsonl').read_text().splitlines()]
|
||||
assert len(expected) == len(actual) == len(cases)
|
||||
for index, (a, b) in enumerate(zip(expected, actual)):
|
||||
assert a == b, (index, a, b)
|
||||
base.ensure_sources_unchanged(sources, tested)
|
||||
lab.write_json(build / 'report.json', {'minecraft': lab.VERSION, 'cases': len(cases), 'pass': True,
|
||||
'sources_sha256': tested, 'java_output_sha256': lab.digest(build / 'java.jsonl', 'sha256')})
|
||||
print(f'Text display metadata parity: {len(cases)} cases match vanilla')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in new issue
Block a user