yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
lyra_terrain_document.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <cstdlib>
5#include <memory>
6#include <set>
7#include <unordered_set>
8#include <utility>
9
10#include "nlohmann/json.hpp"
11
12namespace yaze::core {
13namespace {
14using Json = nlohmann::json;
15
16bool HasKeys(const Json& value, std::initializer_list<const char*> keys) {
17 if (!value.is_object() || value.size() != keys.size())
18 return false;
19 for (const auto* key : keys) {
20 if (!value.contains(key))
21 return false;
22 }
23 return true;
24}
25
26bool IntegerIn(const Json& value, int64_t low, int64_t high) {
27 if (value.is_number_unsigned()) {
28 const auto number = value.get<uint64_t>();
29 return high >= 0 && number <= static_cast<uint64_t>(high) &&
30 (low <= 0 || number >= static_cast<uint64_t>(low));
31 }
32 if (!value.is_number_integer())
33 return false;
34 const auto number = value.get<int64_t>();
35 return number >= low && number <= high;
36}
37
38bool Fingerprint(const std::string& value) {
39 return !value.empty() && value.size() <= 256 &&
40 std::none_of(value.begin(), value.end(),
41 [](unsigned char c) { return c <= 0x20 || c == 0x7F; });
42}
43
44bool ValidFlightName(const std::string& value) {
45 return !value.empty() && value.size() <= 80 &&
46 std::none_of(value.begin(), value.end(),
47 [](unsigned char c) { return c < 0x20 || c == 0x7F; });
48}
49
50bool Coordinates(int map_id, int x, int y) {
51 return map_id >= LyraTerrainDocument::kFirstMap &&
52 map_id <
54 x >= 0 && x < LyraTerrainDocument::kMapSize && y >= 0 &&
56}
57
58size_t CellKey(int map_id, int x, int y) {
59 return (map_id - LyraTerrainDocument::kFirstMap) *
62}
63
65 return {
66 {"graphics_sheets", profile.graphics_sheets},
67 {"main_palette", profile.main_palette},
68 {"palette_set", profile.palette_set},
69 {"animated_sheet", profile.animated_sheet},
70 {"tile_count", profile.tile_count},
71 {"tile16_definition_fingerprint", profile.tile16_definition_fingerprint},
72 {"graphics_fingerprint", profile.graphics_fingerprint},
73 {"palette_fingerprint", profile.palette_fingerprint},
74 {"animation_fingerprint", profile.animation_fingerprint}};
75}
76
78 Json result = {{"graphics_sheets", p.graphics_sheets},
79 {"main_palette", p.main_palette},
80 {"palette_set", p.palette_set},
81 {"animated_sheet", p.animated_sheet},
82 {"sprite_graphics", p.sprite_graphics},
83 {"sprite_palettes", p.sprite_palettes},
84 {"music", p.music},
85 {"message_id", p.message_id}};
87 result["background_palette"] = *p.background_palette;
88 return result;
89}
90
92 return {{"id", s.id},
93 {"sprite_id", s.sprite_id},
94 {"x", s.x},
95 {"y", s.y},
96 {"state", s.state}};
97}
98
100 Json j = {{"id", d.id}, {"x", d.x}, {"y", d.y}, {"entrance_id", nullptr}};
101 if (d.entrance_id.has_value()) {
102 j["entrance_id"] = *d.entrance_id;
103 }
104 return j;
105}
106
108 Json j = {{"id", e.id},
109 {"room_id", e.room_id},
110 {"x", e.x},
111 {"y", e.y},
112 {"exit_row", nullptr}};
113 if (e.exit_row.has_value()) {
114 j["exit_row"] = *e.exit_row;
115 }
116 return j;
117}
118
120 Json sprites = Json::array();
121 for (const auto& s : a.sprites)
122 sprites.push_back(SpriteJson(s));
123 Json doors = Json::array();
124 for (const auto& d : a.doors)
125 doors.push_back(DoorJson(d));
126 Json exits = Json::array();
127 for (const auto& e : a.exits)
128 exits.push_back(ExitJson(e));
129 Json bindings = Json::array();
130 for (const auto& b : a.asset_bindings)
131 bindings.push_back(b);
132
133 return {{"properties", PropertiesJson(a.properties)},
134 {"sprites", std::move(sprites)},
135 {"doors", std::move(doors)},
136 {"exits", std::move(exits)},
137 {"asset_bindings", std::move(bindings)}};
138}
139
141 Json j = {{"id", f.id}, {"map_id", f.map_id}, {"x", f.x},
142 {"y", f.y}, {"name", f.name}, {"runtime_slot", nullptr}};
143 if (f.runtime_slot.has_value()) {
144 j["runtime_slot"] = *f.runtime_slot;
145 }
146 return j;
147}
148
150 Json j = {{"id", c.id},
151 {"map_a", c.map_a},
152 {"map_b", c.map_b},
153 {"passage", nullptr}};
154 if (c.passage.has_value()) {
155 j["passage"] = {{"first_tile", c.passage->first_tile},
156 {"last_tile", c.passage->last_tile}};
157 }
158 return j;
159}
160
162 switch (size) {
164 return "small";
166 return "wide";
168 return "tall";
170 return "large";
171 }
172 return "";
173}
174
175std::optional<LyraGameplayAreaSize> ParseGameplayAreaSize(const Json& value) {
176 if (!value.is_string())
177 return std::nullopt;
178 if (value == "small")
180 if (value == "wide")
182 if (value == "tall")
184 if (value == "large")
186 return std::nullopt;
187}
188
189bool EdgeEndpoint(int map_id) {
190 return (map_id >= 0x98 && map_id <= 0x9B) ||
192 map_id <
194}
195
196absl::Status ValidateAuthoringState(const LyraAuthoringState& state) {
197 std::unordered_set<uint32_t> sprite_ids;
198 std::unordered_set<uint32_t> door_ids;
199 std::unordered_set<uint32_t> exit_ids;
200 std::unordered_set<uint32_t> flight_ids;
201 std::unordered_set<uint32_t> edge_ids;
202 std::set<std::pair<int, int>> edge_pairs;
203
204 std::unordered_set<int> owned_maps;
205 for (const auto& area : state.gameplay_areas) {
206 const auto footprint =
207 LyraGameplayAreaFootprint(area.parent_map_id, area.size);
208 if (!footprint.ok())
209 return footprint.status();
210 if (area.maps != *footprint) {
211 return absl::InvalidArgumentError(
212 "Gameplay area maps must match its exact ordered footprint");
213 }
214 for (const int map_id : area.maps) {
215 if (!owned_maps.insert(map_id).second) {
216 return absl::InvalidArgumentError(
217 "Gameplay areas cannot share ownership of a map");
218 }
219 }
220 }
221
222 const LyraMapProperties default_props;
223
224 for (size_t map_idx = 0; map_idx < state.maps.size(); ++map_idx) {
225 const auto& map_auth = state.maps[map_idx];
226 const auto& props = map_auth.properties;
227
228 // Validate properties
229 if (props.background_palette) {
230 for (const auto color : *props.background_palette) {
231 if (color > 0x7FFF)
232 return absl::InvalidArgumentError(
233 "Lyra background palette colors require BGR555 values 0..32767");
234 }
235 }
236 for (uint8_t sheet : props.graphics_sheets) {
237 (void)sheet; // uint8_t is 0..255
238 }
239 for (int g : props.sprite_graphics) {
240 if (g < -1 || g > 255) {
241 return absl::InvalidArgumentError(
242 "Sprite graphics out of range -1..255");
243 }
244 }
245 for (int p : props.sprite_palettes) {
246 if (p < -1 || p > 255) {
247 return absl::InvalidArgumentError(
248 "Sprite palettes out of range -1..255");
249 }
250 }
251
252 // asset_bindings: either all nonempty valid fingerprints OR all empty AND properties exactly default
253 bool all_empty = true;
254 bool all_valid = true;
255 for (const auto& b : map_auth.asset_bindings) {
256 if (!b.empty())
257 all_empty = false;
258 if (!Fingerprint(b))
259 all_valid = false;
260 }
261
262 if (all_empty) {
263 if (props != default_props) {
264 return absl::InvalidArgumentError(
265 "Empty asset bindings require exactly default map properties");
266 }
267 } else if (!all_valid) {
268 return absl::InvalidArgumentError(
269 "Asset bindings must be all valid fingerprints or all empty");
270 }
271
272 // sprites
273 if (map_auth.sprites.size() > 128) {
274 return absl::InvalidArgumentError("Max 128 sprites per map exceeded");
275 }
276 for (const auto& s : map_auth.sprites) {
277 if (s.id == 0) {
278 return absl::InvalidArgumentError("Sprite ID must be nonzero");
279 }
280 if (!sprite_ids.insert(s.id).second) {
281 return absl::InvalidArgumentError(
282 "Duplicate sprite ID across document");
283 }
284 if (s.x < 0 || s.x > 31 || s.y < 0 || s.y > 31) {
285 return absl::InvalidArgumentError(
286 "Sprite coordinates out of range 0..31");
287 }
288 if (s.state < 0 || s.state > 2) {
289 return absl::InvalidArgumentError("Sprite state out of range 0..2");
290 }
291 }
292
293 // doors
294 if (map_auth.doors.size() > 64) {
295 return absl::InvalidArgumentError("Max 64 doors per map exceeded");
296 }
297 std::set<std::pair<int, int>> door_positions;
298 for (const auto& d : map_auth.doors) {
299 if (d.id == 0) {
300 return absl::InvalidArgumentError("Door ID must be nonzero");
301 }
302 if (!door_ids.insert(d.id).second) {
303 return absl::InvalidArgumentError("Duplicate door ID across document");
304 }
305 if (d.x < 0 || d.x > 31 || d.y < 0 || d.y > 31) {
306 return absl::InvalidArgumentError(
307 "Door coordinates out of range 0..31");
308 }
309 if (!door_positions.insert({d.x, d.y}).second) {
310 return absl::InvalidArgumentError(
311 "Duplicate door position at same (map, x, y)");
312 }
313 if (d.entrance_id.has_value()) {
314 if (*d.entrance_id < 0 || *d.entrance_id > 0xFE) {
315 return absl::InvalidArgumentError(
316 "Door entrance_id out of range 0..0xFE");
317 }
318 }
319 }
320
321 // exits
322 if (map_auth.exits.size() > 64) {
323 return absl::InvalidArgumentError("Max 64 exits per map exceeded");
324 }
325 for (const auto& e : map_auth.exits) {
326 if (e.id == 0) {
327 return absl::InvalidArgumentError("Exit ID must be nonzero");
328 }
329 if (!exit_ids.insert(e.id).second) {
330 return absl::InvalidArgumentError("Duplicate exit ID across document");
331 }
332 if (e.room_id < 0 || e.room_id > 0x127) {
333 return absl::InvalidArgumentError(
334 "Exit room_id out of range 0..0x127 (295)");
335 }
336 if (e.x < 0 || e.x > 31 || e.y < 0 || e.y > 31) {
337 return absl::InvalidArgumentError(
338 "Exit coordinates out of range 0..31");
339 }
340 if (e.exit_row.has_value()) {
341 if (*e.exit_row < 0 || *e.exit_row > 78) {
342 return absl::InvalidArgumentError("Exit exit_row out of range 0..78");
343 }
344 }
345 }
346 }
347
348 // flight points
349 if (state.flight_points.size() > 64) {
350 return absl::InvalidArgumentError("Max 64 flight points exceeded");
351 }
352 for (const auto& f : state.flight_points) {
353 if (f.id == 0) {
354 return absl::InvalidArgumentError("Flight point ID must be nonzero");
355 }
356 if (!flight_ids.insert(f.id).second) {
357 return absl::InvalidArgumentError(
358 "Duplicate flight point ID across document");
359 }
360 if (f.map_id < 0 || f.map_id > 0xBF) {
361 return absl::InvalidArgumentError(
362 "Flight point map_id out of range 0..0xBF");
363 }
364 if (f.x < 0 || f.x > 31 || f.y < 0 || f.y > 31) {
365 return absl::InvalidArgumentError(
366 "Flight point coordinates out of range 0..31");
367 }
368 if (!ValidFlightName(f.name)) {
369 return absl::InvalidArgumentError("Flight point name invalid");
370 }
371 if (f.runtime_slot.has_value()) {
372 return absl::InvalidArgumentError(
373 "Flight point runtime_slot must be null");
374 }
375 }
376
377 // edge connections
378 if (state.edge_connections.size() >
380 return absl::InvalidArgumentError("Max 64 edge connections exceeded");
381 }
382 for (const auto& c : state.edge_connections) {
383 if (c.id == 0) {
384 return absl::InvalidArgumentError("Edge connection ID must be nonzero");
385 }
386 if (!edge_ids.insert(c.id).second) {
387 return absl::InvalidArgumentError("Duplicate edge connection ID");
388 }
389 if (!LyraEdgeMapsAdjacent(c.map_a, c.map_b)) {
390 return absl::InvalidArgumentError(
391 "Edge connection maps are not adjacent Lyra edge maps");
392 }
393 if (c.map_a > c.map_b) {
394 return absl::InvalidArgumentError(
395 "Edge connection must store map_a < map_b");
396 }
397 if (!edge_pairs.insert({c.map_a, c.map_b}).second) {
398 return absl::InvalidArgumentError("Duplicate edge connection map pair");
399 }
400 if (c.passage.has_value() &&
401 (c.passage->first_tile < 0 ||
402 c.passage->last_tile >= LyraTerrainDocument::kMapSize ||
403 c.passage->first_tile > c.passage->last_tile)) {
404 return absl::InvalidArgumentError(
405 "Edge passage requires 0 <= first_tile <= last_tile <= 31");
406 }
407 }
408
409 return absl::OkStatus();
410}
411
412} // namespace
413
414bool LyraEdgeMapsAdjacent(int map_a, int map_b) {
415 if (!EdgeEndpoint(map_a) || !EdgeEndpoint(map_b) ||
416 std::max(map_a, map_b) < LyraTerrainDocument::kFirstMap) {
417 return false;
418 }
419 const int difference = std::abs(map_a - map_b);
420 return difference == LyraTerrainDocument::kColumns ||
421 (difference == 1 && map_a / LyraTerrainDocument::kColumns ==
423}
424
425absl::StatusOr<std::vector<int>> LyraGameplayAreaFootprint(
426 int parent_map_id, LyraGameplayAreaSize size) {
427 if (parent_map_id < LyraTerrainDocument::kFirstMap ||
428 parent_map_id >=
430 return absl::InvalidArgumentError("Gameplay area parent must be A0-BF");
431 }
432 int width = 1;
433 int height = 1;
434 switch (size) {
436 break;
438 width = 2;
439 break;
441 height = 2;
442 break;
444 width = 2;
445 height = 2;
446 break;
447 default:
448 return absl::InvalidArgumentError("Unknown gameplay area size");
449 }
450 const int offset = parent_map_id - LyraTerrainDocument::kFirstMap;
451 if (offset % LyraTerrainDocument::kColumns + width >
453 offset / LyraTerrainDocument::kColumns + height >
455 return absl::InvalidArgumentError(
456 "Gameplay area footprint leaves A0-BF grid");
457 }
458 std::vector<int> maps;
459 for (int row = 0; row < height; ++row) {
460 for (int column = 0; column < width; ++column) {
461 maps.push_back(parent_map_id + row * LyraTerrainDocument::kColumns +
462 column);
463 }
464 }
465 return maps;
466}
467
469 int map_id) const {
470 for (const auto& area : authoring_.gameplay_areas) {
471 if (std::find(area.maps.begin(), area.maps.end(), map_id) !=
472 area.maps.end())
473 return &area;
474 }
475 return nullptr;
476}
477
478absl::Status LyraTerrainProfile::Validate() const {
479 if (tile_count == 0 || tile_count > 65536 ||
480 !Fingerprint(tile16_definition_fingerprint) ||
481 !Fingerprint(graphics_fingerprint) || !Fingerprint(palette_fingerprint) ||
482 !Fingerprint(animation_fingerprint) || animated_sheet == 0xFF ||
483 std::find(graphics_sheets.begin(), graphics_sheets.end(), 0xFF) !=
484 graphics_sheets.end()) {
485 return absl::InvalidArgumentError(
486 "Lyra terrain requires bound Tile16, graphics, palette and animation "
487 "identities, a valid tile count and explicit graphics sheets");
488 }
489 return absl::OkStatus();
490}
491
492absl::StatusOr<LyraTerrainDocument> LyraTerrainDocument::Create(
493 const LyraTerrainProfile& profile, uint16_t fill_tile) {
494 auto status = profile.Validate();
495 if (!status.ok())
496 return status;
497 if (fill_tile >= profile.tile_count) {
498 return absl::InvalidArgumentError(
499 "Lyra fill tile is outside the bound atlas");
500 }
501 auto document = std::unique_ptr<LyraTerrainDocument>(new LyraTerrainDocument);
502 document->profile_ = profile;
503 for (auto& map : document->maps_)
504 map.fill(fill_tile);
505 document->MarkSaved();
506 return std::move(*document);
507}
508
510 const LyraAuthoringState& before, const LyraAuthoringState& after) {
511 if (stroke_active_) {
512 return absl::FailedPreconditionError(
513 "Cannot update Lyra authoring while a terrain stroke is active");
514 }
515 if (authoring_ != before) {
516 return absl::FailedPreconditionError(
517 "Lyra authoring state is stale or does not match before");
518 }
519 if (before == after) {
520 return absl::OkStatus();
521 }
522 auto status = ValidateAuthoringState(after);
523 if (!status.ok()) {
524 return status;
525 }
526 authoring_ = after;
527 return absl::OkStatus();
528}
529
530absl::StatusOr<LyraTerrainDocument> LyraTerrainDocument::Parse(
531 std::string_view text, const LyraTerrainProfile& expected_profile) {
532 auto created = Create(expected_profile);
533 if (!created.ok())
534 return created.status();
535 if (text.size() > 4 * 1024 * 1024) {
536 return absl::InvalidArgumentError("Lyra terrain document exceeds 4 MiB");
537 }
538 try {
539 // Reject repeated object keys instead of silently accepting the last value.
540 bool duplicate_key = false;
541 std::vector<std::set<std::string>> object_keys;
542 auto json =
543 Json::parse(text, [&](int, Json::parse_event_t event, Json& value) {
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;
549 }
550 if (event == Json::parse_event_t::object_end)
551 object_keys.pop_back();
552 return true;
553 });
554 if (duplicate_key) {
555 return absl::InvalidArgumentError("Duplicate JSON keys");
556 }
557
558 if (!json.is_object() || !json.contains("schema") ||
559 json["schema"] != "yaze.lyra-terrain" ||
560 !json.contains("schema_version") ||
561 !IntegerIn(json["schema_version"], 1, kSchemaVersion)) {
562 return absl::InvalidArgumentError("Invalid Lyra terrain schema header");
563 }
564
565 int version = json["schema_version"].get<int>();
566 if (version == 1) {
567 if (!HasKeys(json,
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");
572 }
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");
579 }
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");
587 }
588 } else if (version == 5) {
589 if (!HasKeys(json,
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");
595 }
596 } else {
597 return absl::InvalidArgumentError("Unsupported Lyra schema version");
598 }
599
600 if (!IntegerIn(json["columns"], kColumns, kColumns) ||
601 !IntegerIn(json["rows"], kRows, kRows) ||
602 !IntegerIn(json["map_width"], kMapSize, kMapSize) ||
603 !IntegerIn(json["map_height"], kMapSize, kMapSize) ||
604 !IntegerIn(json["tile_pixels"], kTilePixels, kTilePixels)) {
605 return absl::InvalidArgumentError(
606 "Lyra terrain requires an 8x4 map grid and 32x32 Tile16 maps");
607 }
608 // Exact JSON equality alone treats 1.0 and 1 as equivalent, so validate
609 // numeric types before comparing the complete profile binding.
610 const auto& profile = json["profile"];
611 if (!HasKeys(profile,
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");
623 }
624 for (const auto& sheet : profile["graphics_sheets"]) {
625 if (!IntegerIn(sheet, 0, 254))
626 return absl::InvalidArgumentError("Malformed Lyra graphics sheet ID");
627 }
628 if (profile != ProfileJson(expected_profile)) {
629 return absl::FailedPreconditionError(
630 "Lyra terrain profile does not match the current Tile16 atlas");
631 }
632 if (!json["maps"].is_array() || json["maps"].size() != kMapCount) {
633 return absl::InvalidArgumentError("Lyra terrain requires all 32 maps");
634 }
635 auto& document = *created;
636 bool has_background_palette = false;
637 std::array<bool, kMapCount> seen{};
638 for (const auto& map : json["maps"]) {
639 if (version == 1) {
640 if (!HasKeys(map, {"map_id", "tiles16"})) {
641 return absl::InvalidArgumentError(
642 "Lyra schema 1 map requires map_id and tiles16");
643 }
644 } else {
645 if (!HasKeys(map, {"map_id", "tiles16", "authoring"})) {
646 return absl::InvalidArgumentError(
647 "Lyra schema 2+ map requires map_id, tiles16, and authoring");
648 }
649 }
650 if (!IntegerIn(map["map_id"], kFirstMap, kFirstMap + kMapCount - 1) ||
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");
654 }
655 const int index = map["map_id"].get<int>() - kFirstMap;
656 if (seen[index])
657 return absl::InvalidArgumentError("Duplicate Lyra map ID");
658 seen[index] = true;
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");
663 }
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");
668 }
669 document.maps_[index][y * kMapSize + x] = row[x].get<uint16_t>();
670 }
671 }
672
673 if (version >= 2) {
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");
678 }
679 // properties
680 const auto& props = auth["properties"];
681 const bool has_override = props.contains("background_palette");
682 if ((has_override && version < 4) ||
683 !(has_override
684 ? HasKeys(
685 props,
686 {"graphics_sheets", "main_palette", "palette_set",
687 "animated_sheet", "sprite_graphics", "sprite_palettes",
688 "music", "message_id", "background_palette"})
689 : HasKeys(props,
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");
694 }
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");
707 }
708 LyraMapProperties map_properties;
709 if (has_override) {
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>();
720 }
721 map_properties.background_palette = palette;
722 has_background_palette = true;
723 }
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");
728 }
729 map_properties.graphics_sheets[i] =
730 props["graphics_sheets"][i].get<uint8_t>();
731 }
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");
739 }
740 map_properties.sprite_graphics[i] =
741 props["sprite_graphics"][i].get<int>();
742 }
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");
747 }
748 map_properties.sprite_palettes[i] =
749 props["sprite_palettes"][i].get<int>();
750 }
751 for (int i = 0; i < 4; ++i) {
752 if (!IntegerIn(props["music"][i], 0, 255)) {
753 return absl::InvalidArgumentError("Invalid music in properties");
754 }
755 map_properties.music[i] = props["music"][i].get<uint8_t>();
756 }
757 map_properties.message_id = props["message_id"].get<uint16_t>();
758 document.authoring_.maps[index].properties = map_properties;
759
760 // sprites
761 if (!auth["sprites"].is_array()) {
762 return absl::InvalidArgumentError("sprites must be an array");
763 }
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");
770 }
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>()});
774 }
775
776 // doors
777 if (!auth["doors"].is_array()) {
778 return absl::InvalidArgumentError("doors must be an array");
779 }
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");
785 }
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");
790 }
791 entrance_id = d["entrance_id"].get<int>();
792 }
793 document.authoring_.maps[index].doors.push_back(
794 {d["id"].get<uint32_t>(), d["x"].get<int>(), d["y"].get<int>(),
795 entrance_id});
796 }
797
798 // exits
799 if (!auth["exits"].is_array()) {
800 return absl::InvalidArgumentError("exits must be an array");
801 }
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");
808 }
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");
813 }
814 exit_row = e["exit_row"].get<int>();
815 }
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});
819 }
820
821 // asset_bindings
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");
826 }
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");
830 }
831 document.authoring_.maps[index].asset_bindings[i] =
832 auth["asset_bindings"][i].get<std::string>();
833 }
834 }
835 }
836
837 if (version >= 2) {
838 if (!json["flight_points"].is_array()) {
839 return absl::InvalidArgumentError("flight_points must be an array");
840 }
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");
848 }
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});
852 }
853 }
854
855 if (version >= 3) {
856 const auto& edges = json["edge_connections"];
857 if (!edges.is_array()) {
858 return absl::InvalidArgumentError("edge_connections must be an array");
859 }
860 if (edges.size() > kMaxEdgeConnections) {
861 return absl::InvalidArgumentError("Max 64 edge connections exceeded");
862 }
863 for (const auto& c : edges) {
864 // Adjacency and order are checked by ValidateAuthoringState below.
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");
870 }
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");
878 }
879 passage = LyraEdgePassage{p["first_tile"].get<int>(),
880 p["last_tile"].get<int>()};
881 }
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});
885 }
886 }
887
888 if (version == 5) {
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");
893 }
894 for (const auto& area : areas) {
895 if (!HasKeys(area, {"parent_map_id", "size", "maps"}) ||
896 !IntegerIn(area["parent_map_id"], kFirstMap,
897 kFirstMap + kMapCount - 1) ||
898 !area["maps"].is_array() || area["maps"].empty() ||
899 area["maps"].size() > 4) {
900 return absl::InvalidArgumentError("Malformed gameplay area");
901 }
902 const auto size = ParseGameplayAreaSize(area["size"]);
903 if (!size.has_value())
904 return absl::InvalidArgumentError("Unknown gameplay area size");
905 LyraGameplayArea parsed{area["parent_map_id"].get<int>(), *size, {}};
906 for (const auto& map_id : area["maps"]) {
907 if (!IntegerIn(map_id, kFirstMap, kFirstMap + kMapCount - 1))
908 return absl::InvalidArgumentError(
909 "Gameplay area map must be A0-BF");
910 parsed.maps.push_back(map_id.get<int>());
911 }
912 document.authoring_.gameplay_areas.push_back(std::move(parsed));
913 }
914 }
915
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()) {
921 return auth_status;
922 }
923
924 document.MarkSaved();
925 return std::move(document);
926 } catch (const Json::exception& error) {
927 return absl::InvalidArgumentError(std::string("Invalid Lyra terrain: ") +
928 error.what());
929 }
930}
931
933 bool has_background_palette = false;
934 for (const auto& map : authoring_.maps)
935 has_background_palette |= map.properties.background_palette.has_value();
936 Json json = {{"schema", "yaze.lyra-terrain"},
937 {"schema_version", !authoring_.gameplay_areas.empty()
938 ? 5
939 : (has_background_palette ? 4 : 3)},
940 {"columns", kColumns},
941 {"rows", kRows},
942 {"map_width", kMapSize},
943 {"map_height", kMapSize},
944 {"tile_pixels", kTilePixels},
945 {"profile", ProfileJson(profile_)},
946 {"maps", Json::array()},
947 {"flight_points", Json::array()},
948 {"edge_connections", Json::array()}};
949 for (int index = 0; index < kMapCount; ++index) {
950 Json rows = Json::array();
951 for (int y = 0; y < kMapSize; ++y) {
952 const auto first = maps_[index].begin() + y * kMapSize;
953 rows.push_back(std::vector<uint16_t>(first, first + kMapSize));
954 }
955 json["maps"].push_back(
956 {{"map_id", kFirstMap + index},
957 {"tiles16", std::move(rows)},
958 {"authoring", AuthoringJson(authoring_.maps[index])}});
959 }
960 for (const auto& f : authoring_.flight_points) {
961 json["flight_points"].push_back(FlightPointJson(f));
962 }
963 for (const auto& c : authoring_.edge_connections) {
964 json["edge_connections"].push_back(EdgeConnectionJson(c));
965 }
966 if (!authoring_.gameplay_areas.empty()) {
967 json["gameplay_areas"] = Json::array();
968 for (const auto& area : authoring_.gameplay_areas) {
969 json["gameplay_areas"].push_back(
970 {{"parent_map_id", area.parent_map_id},
971 {"size", GameplayAreaSizeName(area.size)},
972 {"maps", area.maps}});
973 }
974 }
975 return json.dump(2) + "\n";
976}
977
978absl::StatusOr<uint16_t> LyraTerrainDocument::tile(int global_x,
979 int global_y) const {
980 if (global_x < 0 || global_x >= kWidth || global_y < 0 ||
981 global_y >= kHeight) {
982 return absl::OutOfRangeError("Lyra canvas coordinate is outside 256x128");
983 }
984 return MapTile(
985 kFirstMap + (global_y / kMapSize) * kColumns + global_x / kMapSize,
986 global_x % kMapSize, global_y % kMapSize);
987}
988
989absl::StatusOr<uint16_t> LyraTerrainDocument::MapTile(int map_id, int x,
990 int y) const {
991 if (!Coordinates(map_id, x, y))
992 return absl::OutOfRangeError("Lyra map coordinate is outside A0-BF/32x32");
993 return maps_[map_id - kFirstMap][y * kMapSize + x];
994}
995
997 if (stroke_active_)
998 return absl::FailedPreconditionError("A Lyra stroke is already active");
999 stroke_active_ = true;
1000 active_stroke_.changes.clear();
1001 active_cells_.clear();
1002 return absl::OkStatus();
1003}
1004
1005absl::Status LyraTerrainDocument::Paint(int global_x, int global_y,
1006 uint16_t tile_id) {
1007 if (!stroke_active_)
1008 return absl::FailedPreconditionError("Begin a Lyra stroke before painting");
1009 auto current = tile(global_x, global_y);
1010 if (!current.ok())
1011 return current.status();
1012 if (tile_id >= profile_.tile_count)
1013 return absl::OutOfRangeError("Lyra tile ID is outside the bound atlas");
1014 if (*current == tile_id)
1015 return absl::OkStatus();
1016 const int map_id =
1017 kFirstMap + (global_y / kMapSize) * kColumns + global_x / kMapSize;
1018 const int x = global_x % kMapSize;
1019 const int y = global_y % kMapSize;
1020 const auto [entry, inserted] = active_cells_.emplace(
1021 CellKey(map_id, x, y), active_stroke_.changes.size());
1022 if (inserted) {
1023 active_stroke_.changes.push_back({map_id, x, y, *current, tile_id});
1024 } else {
1025 active_stroke_.changes[entry->second].after = tile_id;
1026 }
1027 maps_[map_id - kFirstMap][y * kMapSize + x] = tile_id;
1028 return absl::OkStatus();
1029}
1030
1031absl::StatusOr<LyraTerrainStroke> LyraTerrainDocument::EndStroke() {
1032 if (!stroke_active_)
1033 return absl::FailedPreconditionError("No Lyra stroke is active");
1034 stroke_active_ = false;
1035 active_cells_.clear();
1036 auto result = std::move(active_stroke_);
1037 std::erase_if(result.changes,
1038 [](const auto& cell) { return cell.before == cell.after; });
1039 return result;
1040}
1041
1043 if (!stroke_active_)
1044 return;
1045 for (const auto& cell : active_stroke_.changes) {
1046 maps_[cell.map_id - kFirstMap][cell.y * kMapSize + cell.x] = cell.before;
1047 }
1048 active_stroke_.changes.clear();
1049 active_cells_.clear();
1050 stroke_active_ = false;
1051}
1052
1054 bool undo) {
1055 if (stroke_active_)
1056 return absl::FailedPreconditionError("Finish the active Lyra stroke first");
1057 std::set<size_t> seen;
1058 for (const auto& cell : stroke.changes) {
1059 if (!Coordinates(cell.map_id, cell.x, cell.y) ||
1060 cell.before >= profile_.tile_count ||
1061 cell.after >= profile_.tile_count ||
1062 !seen.insert(CellKey(cell.map_id, cell.x, cell.y)).second) {
1063 return absl::InvalidArgumentError("Malformed Lyra stroke history");
1064 }
1065 const auto current =
1066 maps_[cell.map_id - kFirstMap][cell.y * kMapSize + cell.x];
1067 if (current != (undo ? cell.after : cell.before)) {
1068 return absl::FailedPreconditionError("Lyra stroke history is stale");
1069 }
1070 }
1071 for (const auto& cell : stroke.changes) {
1072 maps_[cell.map_id - kFirstMap][cell.y * kMapSize + cell.x] =
1073 undo ? cell.before : cell.after;
1074 }
1075 return absl::OkStatus();
1076}
1077
1078} // namespace yaze::core
const char * what() const noexcept override
Definition json.h:96
static Json parse(const std::string &)
Definition json.h:36
static Json array()
Definition json.h:35
T get() const
Definition json.h:49
absl::StatusOr< uint16_t > tile(int global_x, int global_y) const
const LyraTerrainProfile & profile() const
absl::StatusOr< LyraTerrainStroke > EndStroke()
static absl::StatusOr< LyraTerrainDocument > Parse(std::string_view text, const LyraTerrainProfile &expected_profile)
absl::Status ApplyStroke(const LyraTerrainStroke &stroke, bool undo)
absl::Status Paint(int global_x, int global_y, uint16_t tile_id)
std::unordered_map< size_t, size_t > active_cells_
absl::StatusOr< uint16_t > MapTile(int map_id, int x, int y) const
const LyraGameplayArea * GameplayAreaForMap(int map_id) const
static absl::StatusOr< LyraTerrainDocument > Create(const LyraTerrainProfile &profile, uint16_t fill_tile=0)
absl::Status SetAuthoring(const LyraAuthoringState &before, const LyraAuthoringState &after)
std::optional< LyraGameplayAreaSize > ParseGameplayAreaSize(const Json &value)
absl::Status ValidateAuthoringState(const LyraAuthoringState &state)
bool HasKeys(const Json &value, std::initializer_list< const char * > keys)
bool IntegerIn(const Json &value, int64_t low, int64_t high)
absl::StatusOr< std::vector< int > > LyraGameplayAreaFootprint(int parent_map_id, LyraGameplayAreaSize size)
bool LyraEdgeMapsAdjacent(int map_a, int map_b)
nlohmann::json json
std::vector< LyraGameplayArea > gameplay_areas
std::array< LyraMapAuthoring, 32 > maps
std::vector< LyraFlightPoint > flight_points
std::vector< LyraEdgeConnection > edge_connections
std::optional< LyraEdgePassage > passage
std::array< std::string, 3 > asset_bindings
std::vector< LyraExitPlacement > exits
std::vector< LyraSpritePlacement > sprites
std::vector< LyraDoorPlacement > doors
std::optional< std::array< uint16_t, 128 > > background_palette
std::array< uint8_t, 8 > graphics_sheets
std::array< uint8_t, 8 > graphics_sheets
std::vector< LyraTerrainCellChange > changes