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:
RVZO6andCodex committed 2026-10-24 22:52:51 -04:00
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(())
}
+1
View File
@@ -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;
+111
View File
@@ -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);
}
}
+56
View File
@@ -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()