mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Omit default fields from saved map data
Follow the optional defaults in MapItemSavedData.CODEC while retaining required fields, nondefault flags and nonempty banner/frame lists. Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
This commit is contained in:
1 parent
c2b104827c
commit
33ba27d9f5
4 files changed
+356
-22
No files matched your search
@@ -548,30 +548,40 @@ impl MapData {
|
||||
patch,
|
||||
})
|
||||
}
|
||||
/// `MapItemSavedData.<clinit>`: the CODEC save arm omits optional defaults.
|
||||
fn to_nbt(&self) -> Tag {
|
||||
let flag = |b: bool| Tag::Byte(i8::from(b));
|
||||
let mut data = vec![
|
||||
("dimension", Tag::String(self.dimension.as_str().into())),
|
||||
("xCenter", Tag::Int(self.center[0])),
|
||||
("zCenter", Tag::Int(self.center[1])),
|
||||
("colors", Tag::ByteArray(self.colors.clone())),
|
||||
];
|
||||
if self.scale != 0 {
|
||||
data.push(("scale", Tag::Byte(self.scale as i8)));
|
||||
}
|
||||
for (key, value, default) in [
|
||||
("locked", self.locked, false),
|
||||
("trackingPosition", self.tracking, true),
|
||||
("unlimitedTracking", self.unlimited, false),
|
||||
] {
|
||||
if value != default {
|
||||
data.push((key, Tag::Byte(i8::from(value))));
|
||||
}
|
||||
}
|
||||
if !self.banners.is_empty() {
|
||||
data.push((
|
||||
"banners",
|
||||
Tag::List(self.banners.iter().map(MapBanner::to_nbt).collect()),
|
||||
));
|
||||
}
|
||||
if !self.frames.is_empty() {
|
||||
data.push((
|
||||
"frames",
|
||||
Tag::List(self.frames.iter().copied().map(MapFrame::to_nbt).collect()),
|
||||
));
|
||||
}
|
||||
Tag::compound([
|
||||
(
|
||||
"data",
|
||||
Tag::compound([
|
||||
("dimension", Tag::String(self.dimension.as_str().into())),
|
||||
("xCenter", Tag::Int(self.center[0])),
|
||||
("zCenter", Tag::Int(self.center[1])),
|
||||
("scale", Tag::Byte(self.scale as i8)),
|
||||
("locked", flag(self.locked)),
|
||||
("trackingPosition", flag(self.tracking)),
|
||||
("unlimitedTracking", flag(self.unlimited)),
|
||||
("colors", Tag::ByteArray(self.colors.clone())),
|
||||
(
|
||||
"banners",
|
||||
Tag::List(self.banners.iter().map(MapBanner::to_nbt).collect()),
|
||||
),
|
||||
(
|
||||
"frames",
|
||||
Tag::List(self.frames.iter().copied().map(MapFrame::to_nbt).collect()),
|
||||
),
|
||||
]),
|
||||
),
|
||||
("data", Tag::compound(data)),
|
||||
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
|
||||
])
|
||||
}
|
||||
@@ -1886,6 +1896,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod save_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
use super::{MapBanner, MapData, MapFrame};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
const OPTIONAL: [&str; 6] = [
|
||||
"scale",
|
||||
"locked",
|
||||
"trackingPosition",
|
||||
"unlimitedTracking",
|
||||
"banners",
|
||||
"frames",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn default_map_fields_are_omitted_without_losing_nondefaults() {
|
||||
let mut map = MapData::fresh(0.0, 0.0, 0, "minecraft:overworld");
|
||||
map.colors[0] = 5;
|
||||
map.colors[16383] = 255;
|
||||
let initial = map.to_nbt();
|
||||
let data = initial.get("data").unwrap();
|
||||
for key in OPTIONAL {
|
||||
assert!(data.get(key).is_none(), "default {key}");
|
||||
}
|
||||
assert_eq!(data.get("xCenter"), Some(&Tag::Int(0)));
|
||||
assert_eq!(data.get("zCenter"), Some(&Tag::Int(0)));
|
||||
assert_eq!(
|
||||
data.get("dimension"),
|
||||
Some(&Tag::String("minecraft:overworld".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
data.get("colors"),
|
||||
Some(&Tag::ByteArray(map.colors.clone()))
|
||||
);
|
||||
|
||||
map.scale = 4;
|
||||
map.locked = true;
|
||||
map.tracking = false;
|
||||
map.unlimited = true;
|
||||
map.banners.push(MapBanner {
|
||||
pos: [7, 70, -11],
|
||||
color: "red".into(),
|
||||
name: None,
|
||||
});
|
||||
map.frames.push(MapFrame {
|
||||
pos: [-3, 80, 9],
|
||||
rotation: 270,
|
||||
entity_id: 42,
|
||||
});
|
||||
let saved = map.to_nbt();
|
||||
let data = saved.get("data").unwrap();
|
||||
for (key, expected) in [
|
||||
("scale", 4),
|
||||
("locked", 1),
|
||||
("trackingPosition", 0),
|
||||
("unlimitedTracking", 1),
|
||||
] {
|
||||
assert_eq!(data.get(key), Some(&Tag::Byte(expected)), "{key}");
|
||||
}
|
||||
for key in ["banners", "frames"] {
|
||||
assert!(matches!(data.get(key), Some(Tag::List(list)) if list.len() == 1));
|
||||
}
|
||||
for key in ["dimension", "xCenter", "zCenter", "colors"] {
|
||||
assert_eq!(
|
||||
data.get(key),
|
||||
initial.get("data").unwrap().get(key),
|
||||
"{key}"
|
||||
);
|
||||
}
|
||||
|
||||
map.scale = 0;
|
||||
map.locked = false;
|
||||
map.tracking = true;
|
||||
map.unlimited = false;
|
||||
map.banners.clear();
|
||||
map.frames.clear();
|
||||
let saved = map.to_nbt();
|
||||
for key in OPTIONAL {
|
||||
assert!(saved.get("data").unwrap().get(key).is_none(), "{key}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Sorted, typed NBT transport shared with the Java oracle for these cases.
|
||||
fn canonical(tag: &Tag) -> String {
|
||||
match tag {
|
||||
Tag::Compound(c) => format!(
|
||||
"{{{}}}",
|
||||
c.iter()
|
||||
.map(|(key, value)| format!("{}:{}", key.to_string_lossy(), canonical(value)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
),
|
||||
Tag::List(list) => format!(
|
||||
"[{}]",
|
||||
list.iter().map(canonical).collect::<Vec<_>>().join(",")
|
||||
),
|
||||
Tag::Byte(value) => format!("{value}b"),
|
||||
Tag::Int(value) => value.to_string(),
|
||||
Tag::String(value) => serde_json::to_string(&value.to_string_lossy()).unwrap(),
|
||||
Tag::ByteArray(values) => format!(
|
||||
"[B;{}]",
|
||||
values
|
||||
.iter()
|
||||
.map(|v| (*v as i8).to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
),
|
||||
Tag::IntArray(values) => format!(
|
||||
"[I;{}]",
|
||||
values
|
||||
.iter()
|
||||
.map(i32::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
),
|
||||
_ => panic!("unexpected tag in saved-map fixture: {tag:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "run through tools/test_map_saved_data_parity.py"]
|
||||
fn saved_map_codec_fixture() {
|
||||
let cases = std::fs::read_to_string(std::env::var("HORIZON_MAP_SAVE_CASES").unwrap()).unwrap();
|
||||
let cases: Vec<serde_json::Value> = serde_json::from_str(&cases).unwrap();
|
||||
let mut lines = Vec::new();
|
||||
for case in cases {
|
||||
let mut map = MapData::fresh(0.0, 0.0, 0, case["dimension"].as_str().unwrap());
|
||||
map.center = [
|
||||
case["x"].as_i64().unwrap() as i32,
|
||||
case["z"].as_i64().unwrap() as i32,
|
||||
];
|
||||
map.scale = case["scale"].as_u64().unwrap() as u8;
|
||||
map.locked = case["locked"].as_bool().unwrap();
|
||||
map.tracking = case["tracking"].as_bool().unwrap();
|
||||
map.unlimited = case["unlimited"].as_bool().unwrap();
|
||||
map.colors = (0..128 * 128).map(|i| (i * 17) as u8).collect();
|
||||
if case["banner"].as_bool().unwrap() {
|
||||
map.banners.push(MapBanner {
|
||||
pos: [7, 70, -11],
|
||||
color: "red".into(),
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
if case["frame"].as_bool().unwrap() {
|
||||
map.frames.push(MapFrame {
|
||||
pos: [-3, 80, 9],
|
||||
rotation: 270,
|
||||
entity_id: 42,
|
||||
});
|
||||
}
|
||||
let saved = map.to_nbt();
|
||||
lines.push(format!(
|
||||
"{} {}",
|
||||
case["name"].as_str().unwrap(),
|
||||
canonical(saved.get("data").unwrap())
|
||||
));
|
||||
}
|
||||
let output = std::env::var("HORIZON_MAP_SAVE_OUTPUT").unwrap();
|
||||
std::fs::write(output, lines.join("\n") + "\n").unwrap();
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import com.google.gson.JsonParser;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.TreeSet;
|
||||
import net.minecraft.nbt.*;
|
||||
import net.minecraft.world.level.saveddata.maps.MapItemSavedData;
|
||||
|
||||
/** Original transport for the real saved-map CODEC, without world or menu simulation. */
|
||||
public final class VanillaMapSavedDataOracle {
|
||||
public static void main(String[] args) throws Exception {
|
||||
net.minecraft.SharedConstants.tryDetectVersion();
|
||||
net.minecraft.server.Bootstrap.bootStrap();
|
||||
var lines = new ArrayList<String>();
|
||||
for (var element : JsonParser.parseString(Files.readString(Path.of(args[0]))).getAsJsonArray()) {
|
||||
var row = element.getAsJsonObject();
|
||||
var input = new CompoundTag();
|
||||
input.putString("dimension", row.get("dimension").getAsString());
|
||||
input.putInt("xCenter", row.get("x").getAsInt());
|
||||
input.putInt("zCenter", row.get("z").getAsInt());
|
||||
input.putByte("scale", row.get("scale").getAsByte());
|
||||
input.putBoolean("locked", row.get("locked").getAsBoolean());
|
||||
input.putBoolean("trackingPosition", row.get("tracking").getAsBoolean());
|
||||
input.putBoolean("unlimitedTracking", row.get("unlimited").getAsBoolean());
|
||||
byte[] colors = new byte[128 * 128];
|
||||
for (int i = 0; i < colors.length; i++) colors[i] = (byte)(i * 17);
|
||||
input.putByteArray("colors", colors);
|
||||
var banners = new ListTag();
|
||||
if (row.get("banner").getAsBoolean()) {
|
||||
var banner = new CompoundTag();
|
||||
banner.putIntArray("pos", new int[]{7, 70, -11});
|
||||
banner.putString("color", "red");
|
||||
banners.add(banner);
|
||||
}
|
||||
input.put("banners", banners);
|
||||
var frames = new ListTag();
|
||||
if (row.get("frame").getAsBoolean()) {
|
||||
var frame = new CompoundTag();
|
||||
frame.putIntArray("pos", new int[]{-3, 80, 9});
|
||||
frame.putInt("rotation", 270);
|
||||
frame.putInt("entity_id", 42);
|
||||
frames.add(frame);
|
||||
}
|
||||
input.put("frames", frames);
|
||||
var map = MapItemSavedData.CODEC.parse(NbtOps.INSTANCE, input).getOrThrow();
|
||||
var saved = (CompoundTag)MapItemSavedData.CODEC.encodeStart(NbtOps.INSTANCE, map).getOrThrow();
|
||||
var expected = new TreeSet<>(List.of("dimension", "xCenter", "zCenter", "colors"));
|
||||
if (row.get("scale").getAsInt() != 0) expected.add("scale");
|
||||
if (row.get("locked").getAsBoolean()) expected.add("locked");
|
||||
if (!row.get("tracking").getAsBoolean()) expected.add("trackingPosition");
|
||||
if (row.get("unlimited").getAsBoolean()) expected.add("unlimitedTracking");
|
||||
if (row.get("banner").getAsBoolean()) expected.add("banners");
|
||||
if (row.get("frame").getAsBoolean()) expected.add("frames");
|
||||
if (!saved.keySet().equals(expected)) throw new AssertionError(row.toString());
|
||||
for (var key : expected)
|
||||
if (!saved.get(key).equals(input.get(key))) throw new AssertionError(key + ": " + row);
|
||||
lines.add(row.get("name").getAsString() + " " + ParityData.canon(saved));
|
||||
}
|
||||
Files.write(Path.of(args[1]), lines);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare MapData's saved data compound with Java 26.3's real map CODEC.
|
||||
|
||||
All six default/nondefault combinations, scales 0..4, required fields, full
|
||||
color bytes and nonempty banner/frame contents are compared as typed NBT.
|
||||
This does not exercise menus, marker behavior, rendering or save/reopen.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
from . import lab, test_rust_parity as base
|
||||
except ImportError:
|
||||
import lab
|
||||
import test_rust_parity as base
|
||||
|
||||
|
||||
def corpus():
|
||||
for mask, scale in [*((mask, int(bool(mask & 1))) for mask in range(64)),
|
||||
*((63, scale) for scale in (2, 3, 4))]:
|
||||
yield dict(name=f"flags-{mask:02d}-scale-{scale}", scale=scale,
|
||||
locked=bool(mask & 2), tracking=not bool(mask & 4),
|
||||
unlimited=bool(mask & 8), banner=bool(mask & 16),
|
||||
frame=bool(mask & 32), x=-64 if mask & 1 else 128,
|
||||
z=192 if mask & 1 else 0,
|
||||
dimension="minecraft:the_nether" if mask & 1 else "minecraft:overworld")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--out", type=Path, default=lab.LOCAL / "map-saved-data-parity")
|
||||
parser.add_argument("--java-only", action="store_true")
|
||||
parser.add_argument("--allow-gaps", action="store_true")
|
||||
args = parser.parse_args()
|
||||
out = args.out.resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
(out / "report.json").unlink(missing_ok=True)
|
||||
cases = list(corpus())
|
||||
inputs = out / "cases.json"
|
||||
inputs.write_text(json.dumps(cases, sort_keys=True) + "\n", encoding="utf-8")
|
||||
lock, jars, libraries = base.verify_artifacts()
|
||||
source = lab.ROOT / "mods/horizon/src/test/java/dev/horizonlab/parity/VanillaMapSavedDataOracle.java"
|
||||
tested_sources = base.source_hashes([
|
||||
Path(__file__), source,
|
||||
lab.ROOT / "mods/horizon/src/test/java/dev/horizonlab/parity/ParityData.java",
|
||||
lab.ROOT / "crates/horizon-client/src/game/maps.rs",
|
||||
lab.ROOT / "crates/horizon-client/src/game/maps/save_tests.rs",
|
||||
lab.ROOT / "crates/horizon-client/Cargo.toml",
|
||||
lab.ROOT / "Cargo.toml", lab.ROOT / "Cargo.lock"])
|
||||
classes = out / "classes"
|
||||
classes.mkdir(exist_ok=True)
|
||||
classpath = os.pathsep.join(map(str, [jars["server"], *libraries]))
|
||||
|
||||
def run(command, name, env=None):
|
||||
with (out / (name + ".log")).open("w", encoding="utf-8") as log:
|
||||
base.run(list(map(str, command)), stdout=log, stderr=subprocess.STDOUT, env=env)
|
||||
|
||||
run(["javac", "--release", lock["minecraft"]["java_major"], "-proc:none",
|
||||
"-sourcepath", lab.ROOT / "mods/horizon/src/test/java", "-cp", classpath,
|
||||
"-d", classes, source], "compile-java")
|
||||
java = out / "java.txt"
|
||||
run(["java", "-Xmx1G", "-cp", str(classes) + os.pathsep + classpath,
|
||||
"dev.horizonlab.parity.VanillaMapSavedDataOracle", inputs, java], "java")
|
||||
reference = java.read_text(encoding="utf-8").splitlines()
|
||||
assert len(reference) == len(cases)
|
||||
assert [line.split(" ", 1)[0] for line in reference] == [case["name"] for case in cases]
|
||||
if args.java_only:
|
||||
print(f"Fresh Java map CODEC: {len(reference)} records; Rust not run.")
|
||||
return
|
||||
rust = out / "rust.txt"
|
||||
rust.unlink(missing_ok=True)
|
||||
env = dict(os.environ, HORIZON_MAP_SAVE_CASES=str(inputs), HORIZON_MAP_SAVE_OUTPUT=str(rust))
|
||||
run(["cargo", "test", "-p", "horizon-client", "--lib",
|
||||
"game::maps::save_tests::saved_map_codec_fixture", "--", "--ignored", "--exact"],
|
||||
"rust", env)
|
||||
actual = rust.read_text(encoding="utf-8").splitlines()
|
||||
assert len(actual) == len(reference), (len(actual), len(reference))
|
||||
mismatches = [case["name"] for case, expected, value in zip(cases, reference, actual)
|
||||
if expected != value]
|
||||
base.ensure_sources_unchanged([
|
||||
lab.ROOT / path for path in tested_sources], tested_sources)
|
||||
report = dict(schema=1, minecraft=lock["minecraft"]["version"],
|
||||
tested_sources=tested_sources,
|
||||
cases=len(cases), matched=len(cases) - len(mismatches),
|
||||
mismatches=mismatches, scope=__doc__,
|
||||
input_sha256=hashlib.sha256(inputs.read_bytes()).hexdigest(),
|
||||
java_sha256=hashlib.sha256(java.read_bytes()).hexdigest())
|
||||
(out / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"{report['matched']}/{len(cases)} saved-map records match")
|
||||
if mismatches and not args.allow_gaps:
|
||||
raise SystemExit(f"{len(mismatches)} mismatches; see {out / 'report.json'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user