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:
TheMrZZandCodex gpt-6.1-sol committed 2026-11-04 13:28:54 +01:00
1 parent c2b104827c
commit 33ba27d9f5
4 files changed
+356 -22

No files matched your search

+35 -22
View File
@@ -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);
}
}
+99
View File
@@ -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()