532 auto created =
Create(expected_profile);
534 return created.status();
535 if (text.size() > 4 * 1024 * 1024) {
536 return absl::InvalidArgumentError(
"Lyra terrain document exceeds 4 MiB");
540 bool duplicate_key =
false;
541 std::vector<std::set<std::string>> object_keys;
544 if (event == Json::parse_event_t::object_start)
545 object_keys.emplace_back();
546 if (event == Json::parse_event_t::key &&
547 !object_keys.back().insert(value.
get<std::string>()).second) {
548 duplicate_key =
true;
550 if (event == Json::parse_event_t::object_end)
551 object_keys.pop_back();
555 return absl::InvalidArgumentError(
"Duplicate JSON keys");
558 if (!
json.is_object() || !
json.contains(
"schema") ||
559 json[
"schema"] !=
"yaze.lyra-terrain" ||
560 !
json.contains(
"schema_version") ||
562 return absl::InvalidArgumentError(
"Invalid Lyra terrain schema header");
565 int version =
json[
"schema_version"].get<
int>();
568 {
"schema",
"schema_version",
"columns",
"rows",
"map_width",
569 "map_height",
"tile_pixels",
"profile",
"maps"})) {
570 return absl::InvalidArgumentError(
571 "Malformed Lyra schema 1 header keys");
573 }
else if (version == 2) {
574 if (!HasKeys(
json, {
"schema",
"schema_version",
"columns",
"rows",
575 "map_width",
"map_height",
"tile_pixels",
"profile",
576 "maps",
"flight_points"})) {
577 return absl::InvalidArgumentError(
578 "Malformed Lyra schema 2 header keys");
580 }
else if (version == 3 || version == 4) {
581 if (!HasKeys(
json, {
"schema",
"schema_version",
"columns",
"rows",
582 "map_width",
"map_height",
"tile_pixels",
"profile",
583 "maps",
"flight_points",
"edge_connections"})) {
584 return absl::InvalidArgumentError(
585 version == 3 ?
"Malformed Lyra schema 3 header keys"
586 :
"Malformed Lyra schema 4 header keys");
588 }
else if (version == 5) {
590 {
"schema",
"schema_version",
"columns",
"rows",
"map_width",
591 "map_height",
"tile_pixels",
"profile",
"maps",
592 "flight_points",
"edge_connections",
"gameplay_areas"})) {
593 return absl::InvalidArgumentError(
594 "Malformed Lyra schema 5 header keys");
597 return absl::InvalidArgumentError(
"Unsupported Lyra schema version");
605 return absl::InvalidArgumentError(
606 "Lyra terrain requires an 8x4 map grid and 32x32 Tile16 maps");
612 {
"graphics_sheets",
"main_palette",
"palette_set",
613 "animated_sheet",
"tile_count",
614 "tile16_definition_fingerprint",
"graphics_fingerprint",
615 "palette_fingerprint",
"animation_fingerprint"}) ||
616 !
profile[
"graphics_sheets"].is_array() ||
617 profile[
"graphics_sheets"].size() != 8 ||
618 !IntegerIn(
profile[
"main_palette"], 0, 255) ||
619 !IntegerIn(
profile[
"palette_set"], 0, 255) ||
620 !IntegerIn(
profile[
"animated_sheet"], 0, 254) ||
621 !IntegerIn(
profile[
"tile_count"], 1, 65536)) {
622 return absl::InvalidArgumentError(
"Malformed Lyra terrain profile");
624 for (
const auto& sheet :
profile[
"graphics_sheets"]) {
625 if (!IntegerIn(sheet, 0, 254))
626 return absl::InvalidArgumentError(
"Malformed Lyra graphics sheet ID");
628 if (
profile != ProfileJson(expected_profile)) {
629 return absl::FailedPreconditionError(
630 "Lyra terrain profile does not match the current Tile16 atlas");
633 return absl::InvalidArgumentError(
"Lyra terrain requires all 32 maps");
635 auto& document = *created;
636 bool has_background_palette =
false;
637 std::array<bool, kMapCount> seen{};
638 for (
const auto& map :
json[
"maps"]) {
640 if (!HasKeys(map, {
"map_id",
"tiles16"})) {
641 return absl::InvalidArgumentError(
642 "Lyra schema 1 map requires map_id and tiles16");
645 if (!HasKeys(map, {
"map_id",
"tiles16",
"authoring"})) {
646 return absl::InvalidArgumentError(
647 "Lyra schema 2+ map requires map_id, tiles16, and authoring");
651 !map[
"tiles16"].is_array() || map[
"tiles16"].size() !=
kMapSize) {
652 return absl::InvalidArgumentError(
653 "Lyra map requires an A0-BF ID and 32 Tile16 rows");
655 const int index = map[
"map_id"].get<
int>() -
kFirstMap;
657 return absl::InvalidArgumentError(
"Duplicate Lyra map ID");
659 for (
int y = 0; y <
kMapSize; ++y) {
660 const auto& row = map[
"tiles16"][y];
661 if (!row.is_array() || row.size() !=
kMapSize) {
662 return absl::InvalidArgumentError(
"Lyra map rows require 32 tiles");
664 for (
int x = 0; x <
kMapSize; ++x) {
665 if (!IntegerIn(row[x], 0, expected_profile.
tile_count - 1)) {
666 return absl::InvalidArgumentError(
667 "Lyra tile ID is outside the bound Tile16 atlas");
669 document.maps_[index][y *
kMapSize + x] = row[x].get<uint16_t>();
674 const auto& auth = map[
"authoring"];
675 if (!HasKeys(auth, {
"properties",
"sprites",
"doors",
"exits",
676 "asset_bindings"})) {
677 return absl::InvalidArgumentError(
"Malformed map authoring keys");
680 const auto& props = auth[
"properties"];
681 const bool has_override = props.contains(
"background_palette");
682 if ((has_override && version < 4) ||
686 {
"graphics_sheets",
"main_palette",
"palette_set",
687 "animated_sheet",
"sprite_graphics",
"sprite_palettes",
688 "music",
"message_id",
"background_palette"})
690 {
"graphics_sheets",
"main_palette",
"palette_set",
691 "animated_sheet",
"sprite_graphics",
692 "sprite_palettes",
"music",
"message_id"}))) {
693 return absl::InvalidArgumentError(
"Malformed map properties keys");
695 if (!props[
"graphics_sheets"].is_array() ||
696 props[
"graphics_sheets"].size() != 8 ||
697 !IntegerIn(props[
"main_palette"], 0, 255) ||
698 !IntegerIn(props[
"palette_set"], 0, 255) ||
699 !IntegerIn(props[
"animated_sheet"], 0, 255) ||
700 !props[
"sprite_graphics"].is_array() ||
701 props[
"sprite_graphics"].size() != 3 ||
702 !props[
"sprite_palettes"].is_array() ||
703 props[
"sprite_palettes"].size() != 3 ||
704 !props[
"music"].is_array() || props[
"music"].size() != 4 ||
705 !IntegerIn(props[
"message_id"], 0, 65535)) {
706 return absl::InvalidArgumentError(
"Malformed map properties values");
710 const auto& colors = props[
"background_palette"];
711 if (!colors.is_array() || colors.size() != 128)
712 return absl::InvalidArgumentError(
713 "Lyra background palette requires exactly 128 BGR555 colors");
714 std::array<uint16_t, 128> palette;
715 for (
size_t i = 0; i < palette.size(); ++i) {
716 if (!IntegerIn(colors[i], 0, 0x7FFF))
717 return absl::InvalidArgumentError(
718 "Lyra background palette colors require integers 0..32767");
719 palette[i] = colors[i].get<uint16_t>();
722 has_background_palette =
true;
724 for (
int i = 0; i < 8; ++i) {
725 if (!IntegerIn(props[
"graphics_sheets"][i], 0, 255)) {
726 return absl::InvalidArgumentError(
727 "Invalid graphics sheet in properties");
730 props[
"graphics_sheets"][i].get<uint8_t>();
732 map_properties.
main_palette = props[
"main_palette"].get<uint8_t>();
733 map_properties.
palette_set = props[
"palette_set"].get<uint8_t>();
734 map_properties.
animated_sheet = props[
"animated_sheet"].get<uint8_t>();
735 for (
int i = 0; i < 3; ++i) {
736 if (!IntegerIn(props[
"sprite_graphics"][i], -1, 255)) {
737 return absl::InvalidArgumentError(
738 "Invalid sprite_graphics in properties");
741 props[
"sprite_graphics"][i].get<
int>();
743 for (
int i = 0; i < 3; ++i) {
744 if (!IntegerIn(props[
"sprite_palettes"][i], -1, 255)) {
745 return absl::InvalidArgumentError(
746 "Invalid sprite_palettes in properties");
749 props[
"sprite_palettes"][i].get<
int>();
751 for (
int i = 0; i < 4; ++i) {
752 if (!IntegerIn(props[
"music"][i], 0, 255)) {
753 return absl::InvalidArgumentError(
"Invalid music in properties");
755 map_properties.
music[i] = props[
"music"][i].get<uint8_t>();
757 map_properties.
message_id = props[
"message_id"].get<uint16_t>();
758 document.authoring_.maps[index].properties = map_properties;
761 if (!auth[
"sprites"].is_array()) {
762 return absl::InvalidArgumentError(
"sprites must be an array");
764 for (
const auto& s : auth[
"sprites"]) {
765 if (!HasKeys(s, {
"id",
"sprite_id",
"x",
"y",
"state"}) ||
766 !IntegerIn(s[
"id"], 1, 0xFFFFFFFFULL) ||
767 !IntegerIn(s[
"sprite_id"], 0, 255) || !IntegerIn(s[
"x"], 0, 31) ||
768 !IntegerIn(s[
"y"], 0, 31) || !IntegerIn(s[
"state"], 0, 2)) {
769 return absl::InvalidArgumentError(
"Malformed sprite placement");
771 document.authoring_.maps[index].sprites.push_back(
772 {s[
"id"].get<uint32_t>(), s[
"sprite_id"].get<uint8_t>(),
773 s[
"x"].get<
int>(), s[
"y"].get<int>(), s[
"state"].get<
int>()});
777 if (!auth[
"doors"].is_array()) {
778 return absl::InvalidArgumentError(
"doors must be an array");
780 for (
const auto& d : auth[
"doors"]) {
781 if (!HasKeys(d, {
"id",
"x",
"y",
"entrance_id"}) ||
782 !IntegerIn(d[
"id"], 1, 0xFFFFFFFFULL) ||
783 !IntegerIn(d[
"x"], 0, 31) || !IntegerIn(d[
"y"], 0, 31)) {
784 return absl::InvalidArgumentError(
"Malformed door placement");
786 std::optional<int> entrance_id;
787 if (!d[
"entrance_id"].is_null()) {
788 if (!IntegerIn(d[
"entrance_id"], 0, 0xFE)) {
789 return absl::InvalidArgumentError(
"Invalid entrance_id");
791 entrance_id = d[
"entrance_id"].get<
int>();
793 document.authoring_.maps[index].doors.push_back(
794 {d[
"id"].get<uint32_t>(), d[
"x"].get<int>(), d[
"y"].get<
int>(),
799 if (!auth[
"exits"].is_array()) {
800 return absl::InvalidArgumentError(
"exits must be an array");
802 for (
const auto& e : auth[
"exits"]) {
803 if (!HasKeys(e, {
"id",
"room_id",
"x",
"y",
"exit_row"}) ||
804 !IntegerIn(e[
"id"], 1, 0xFFFFFFFFULL) ||
805 !IntegerIn(e[
"room_id"], 0, 0x127) || !IntegerIn(e[
"x"], 0, 31) ||
806 !IntegerIn(e[
"y"], 0, 31)) {
807 return absl::InvalidArgumentError(
"Malformed exit placement");
809 std::optional<int> exit_row;
810 if (!e[
"exit_row"].is_null()) {
811 if (!IntegerIn(e[
"exit_row"], 0, 78)) {
812 return absl::InvalidArgumentError(
"Invalid exit_row");
814 exit_row = e[
"exit_row"].get<
int>();
816 document.authoring_.maps[index].exits.push_back(
817 {e[
"id"].get<uint32_t>(), e[
"room_id"].get<int>(),
818 e[
"x"].get<
int>(), e[
"y"].get<int>(), exit_row});
822 if (!auth[
"asset_bindings"].is_array() ||
823 auth[
"asset_bindings"].size() != 3) {
824 return absl::InvalidArgumentError(
825 "asset_bindings must be an array of 3 strings");
827 for (
int i = 0; i < 3; ++i) {
828 if (!auth[
"asset_bindings"][i].is_string()) {
829 return absl::InvalidArgumentError(
"asset_bindings must be strings");
831 document.authoring_.maps[index].asset_bindings[i] =
832 auth[
"asset_bindings"][i].get<std::string>();
838 if (!
json[
"flight_points"].is_array()) {
839 return absl::InvalidArgumentError(
"flight_points must be an array");
841 for (
const auto& f :
json[
"flight_points"]) {
842 if (!HasKeys(f, {
"id",
"map_id",
"x",
"y",
"name",
"runtime_slot"}) ||
843 !IntegerIn(f[
"id"], 1, 0xFFFFFFFFULL) ||
844 !IntegerIn(f[
"map_id"], 0, 0xBF) || !IntegerIn(f[
"x"], 0, 31) ||
845 !IntegerIn(f[
"y"], 0, 31) || !f[
"name"].is_string() ||
846 !f[
"runtime_slot"].is_null()) {
847 return absl::InvalidArgumentError(
"Malformed flight point");
849 document.authoring_.flight_points.push_back(
850 {f[
"id"].get<uint32_t>(), f[
"map_id"].get<int>(), f[
"x"].get<
int>(),
851 f[
"y"].get<int>(), f[
"name"].get<std::string>(), std::nullopt});
856 const auto& edges =
json[
"edge_connections"];
857 if (!edges.is_array()) {
858 return absl::InvalidArgumentError(
"edge_connections must be an array");
861 return absl::InvalidArgumentError(
"Max 64 edge connections exceeded");
863 for (
const auto& c : edges) {
865 if (!HasKeys(c, {
"id",
"map_a",
"map_b",
"passage"}) ||
866 !IntegerIn(c[
"id"], 1, 0xFFFFFFFFULL) ||
867 !IntegerIn(c[
"map_a"], 0, 0xFF) ||
868 !IntegerIn(c[
"map_b"], 0, 0xFF)) {
869 return absl::InvalidArgumentError(
"Malformed edge connection");
871 std::optional<LyraEdgePassage> passage;
872 if (!c[
"passage"].is_null()) {
873 const auto& p = c[
"passage"];
874 if (!HasKeys(p, {
"first_tile",
"last_tile"}) ||
875 !IntegerIn(p[
"first_tile"], 0,
kMapSize - 1) ||
876 !IntegerIn(p[
"last_tile"], 0,
kMapSize - 1)) {
877 return absl::InvalidArgumentError(
"Malformed edge passage");
880 p[
"last_tile"].get<int>()};
882 document.authoring_.edge_connections.push_back(
883 {c[
"id"].get<uint32_t>(), c[
"map_a"].get<int>(),
884 c[
"map_b"].get<
int>(), passage});
889 const auto& areas =
json[
"gameplay_areas"];
890 if (!areas.is_array() || areas.empty() || areas.size() >
kMapCount) {
891 return absl::InvalidArgumentError(
892 "gameplay_areas requires 1..32 explicit areas");
894 for (
const auto& area : areas) {
895 if (!HasKeys(area, {
"parent_map_id",
"size",
"maps"}) ||
896 !IntegerIn(area[
"parent_map_id"],
kFirstMap,
898 !area[
"maps"].is_array() || area[
"maps"].empty() ||
899 area[
"maps"].size() > 4) {
900 return absl::InvalidArgumentError(
"Malformed gameplay area");
902 const auto size = ParseGameplayAreaSize(area[
"size"]);
903 if (!size.has_value())
904 return absl::InvalidArgumentError(
"Unknown gameplay area size");
906 for (
const auto& map_id : area[
"maps"]) {
908 return absl::InvalidArgumentError(
909 "Gameplay area map must be A0-BF");
910 parsed.
maps.push_back(map_id.get<
int>());
912 document.authoring_.gameplay_areas.push_back(std::move(parsed));
916 if (version == 4 && !has_background_palette)
917 return absl::InvalidArgumentError(
918 "Lyra schema 4 requires a map background palette override");
919 auto auth_status = ValidateAuthoringState(document.authoring_);
920 if (!auth_status.ok()) {
924 document.MarkSaved();
925 return std::move(document);
927 return absl::InvalidArgumentError(std::string(
"Invalid Lyra terrain: ") +