yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
lyra_terrain_canvas.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <cmath>
5#include <filesystem>
6#include <fstream>
7#include <iterator>
8#include <set>
9
10#include "absl/strings/str_format.h"
14#include "util/macro.h"
15#include "zelda3/game_data.h"
18
19namespace yaze::editor {
20namespace {
22namespace fs = std::filesystem;
23
24absl::StatusOr<std::optional<std::string>> ReadSource(const fs::path& path) {
25 std::error_code ec;
26 if (!fs::exists(path, ec) && !ec)
27 return std::nullopt;
28 if (ec || !fs::is_regular_file(path, ec) || ec)
29 return absl::FailedPreconditionError(
30 "Terrain source is not a regular file");
31 if (fs::file_size(path, ec) > 4 * 1024 * 1024 || ec)
32 return absl::InvalidArgumentError("Terrain source exceeds 4 MiB");
33 std::ifstream file(path, std::ios::binary);
34 if (!file)
35 return absl::PermissionDeniedError("Cannot read terrain source");
36 std::string text{std::istreambuf_iterator<char>(file), {}};
37 if (file.bad())
38 return absl::DataLossError("Could not finish reading terrain source");
39 return std::optional<std::string>(std::move(text));
40}
41
42absl::Status ValidateTarget(const fs::path& path,
43 const project::YazeProject& project) {
44 if (path.extension() != ".json")
45 return absl::InvalidArgumentError(
46 "Lyra terrain must use a separate .json file");
47 std::error_code ec;
48 const auto canonical_target = fs::weakly_canonical(path, ec);
49 if (ec)
50 return absl::FailedPreconditionError("Cannot resolve terrain source path");
51 const bool target_exists = fs::exists(path, ec);
52 if (ec)
53 return absl::FailedPreconditionError("Cannot inspect terrain source path");
54 for (const auto& protected_path :
55 {project.filepath, project.GetAbsolutePath(project.rom_filename)}) {
56 if (protected_path.empty())
57 continue;
58 const auto canonical_protected = fs::weakly_canonical(protected_path, ec);
59 if (ec)
60 return absl::FailedPreconditionError(
61 "Cannot resolve project or ROM path");
62 if (canonical_target == canonical_protected)
63 return absl::FailedPreconditionError(
64 "Terrain source aliases the project or ROM");
65 const bool protected_exists = fs::exists(protected_path, ec);
66 if (ec)
67 return absl::FailedPreconditionError(
68 "Cannot inspect project or ROM path");
69 if (!target_exists || !protected_exists)
70 continue;
71 const bool aliases = fs::equivalent(path, protected_path, ec);
72 if (ec)
73 return absl::FailedPreconditionError(
74 "Cannot compare terrain source identity");
75 if (aliases)
76 return absl::FailedPreconditionError(
77 "Terrain source aliases the project or ROM");
78 }
79 return absl::OkStatus();
80}
81
82void AddWord(std::string& bytes, uint16_t word) {
83 bytes.push_back(static_cast<char>(word));
84 bytes.push_back(static_cast<char>(word >> 8));
85}
86
87class TerrainStrokeAction final : public UndoAction {
88 public:
89 TerrainStrokeAction(std::shared_ptr<Document> document,
91 : document_(std::move(document)), stroke_(std::move(stroke)) {}
92 absl::Status Undo() override { return document_->ApplyStroke(stroke_, true); }
93 absl::Status Redo() override {
94 return document_->ApplyStroke(stroke_, false);
95 }
96 std::string Description() const override { return "Paint Lyra terrain"; }
97 size_t MemoryUsage() const override {
98 return sizeof(*this) +
99 stroke_.changes.size() * sizeof(core::LyraTerrainCellChange);
100 }
101
102 private:
103 std::shared_ptr<Document> document_;
105};
106class AuthoringAction final : public UndoAction {
107 public:
108 AuthoringAction(std::shared_ptr<Document> document,
111 : document_(std::move(document)),
112 before_(std::move(before)),
113 after_(std::move(after)) {}
114 absl::Status Undo() override {
115 return document_->SetAuthoring(after_, before_);
116 }
117 absl::Status Redo() override {
118 return document_->SetAuthoring(before_, after_);
119 }
120 std::string Description() const override {
121 return "Edit Lyra map properties, areas, entities or connections";
122 }
123 size_t MemoryUsage() const override { return sizeof(*this); }
124
125 private:
126 std::shared_ptr<Document> document_;
128};
129
130} // namespace
131
132absl::StatusOr<std::string> PublishLyraTerrainSource(
133 const project::YazeProject& project, const std::string& source_path,
134 const std::optional<std::string>& source_bytes,
135 const core::LyraTerrainDocument& document) {
136 if (project.GetAbsolutePath(project.lyra_terrain_file) != source_path)
137 return absl::FailedPreconditionError(
138 "Lyra project source changed; draft retained");
139 RETURN_IF_ERROR(ValidateTarget(source_path, project));
141 {fs::absolute(project.filepath), source_path},
142 {"Lyra terrain", "Lyra terrain file"}));
143 ASSIGN_OR_RETURN(auto current, ReadSource(source_path));
144 if (current != source_bytes)
145 return absl::FailedPreconditionError(
146 "Lyra terrain changed on disk; refusing stale write");
147 ASSIGN_OR_RETURN(auto project_bytes, ReadSource(project.filepath));
148 if (!project_bytes)
149 return absl::FailedPreconditionError(
150 "Save the project file before publishing Lyra terrain");
151 const std::string after = document.Serialize();
152 // The existing project is the primary CAS source, allowing first creation of
153 // the sidecar through the same atomic publisher as existing source artifacts.
155 *lock,
156 {{fs::absolute(project.filepath), project_bytes, *project_bytes},
157 {source_path, source_bytes, after}},
158 core::ComputeSourceArtifactSha256(*project_bytes), [&]() -> absl::Status {
159 ASSIGN_OR_RETURN(auto saved, ReadSource(source_path));
160 if (!saved)
161 return absl::DataLossError("Lyra terrain readback is missing");
162 return Document::Parse(*saved, document.profile()).status();
163 }));
164 return after;
165}
166
170
173 gfx::Bitmap* atlas) {
174 return zelda3::BindLyraTerrainSource(overworld, profile, atlas);
175}
176
183
184absl::StatusOr<std::unique_ptr<LyraTerrainCanvas::MapAssets>>
186 const core::LyraMapProperties& p, int state,
187 bool textures) {
188 ASSIGN_OR_RETURN(auto native,
189 zelda3::ResolveLyraMapAssets(overworld, p, state));
190 auto& source = native.source;
191 const auto& pixels = source.current_graphics();
192 auto result = std::make_unique<MapAssets>();
193 result->binding = std::move(native.binding);
194 if (textures) {
195 RETURN_IF_ERROR(source.BuildTiles16Gfx(*overworld.mutable_tiles16(),
196 overworld.tiles16().size()));
197 const int height = ((overworld.tiles16().size() + 7) / 8) * 16;
198 const auto& blockset = source.current_tile16_blockset();
199 const size_t bytes = 128 * height;
200 if (blockset.size() < bytes)
201 return absl::FailedPreconditionError("Lyra Tile16 pixels are incomplete");
202 result->atlas.Create(
203 128, height, 8,
204 std::vector<uint8_t>(blockset.begin(), blockset.begin() + bytes));
205 result->atlas.SetPalette(source.current_palette());
206 result->graphics.Create(128, 512, 8, pixels);
207 result->graphics.SetPalette(source.current_palette());
211 gfx::Arena::TextureCommandType::CREATE, &result->graphics);
212 }
213 return result;
214}
215
216absl::StatusOr<core::LyraMapProperties>
218 zelda3::Overworld& overworld, int source_map,
219 core::LyraMapProperties candidate) {
220 if (!overworld.is_loaded() || overworld.is_lyra_preview() || !overworld.rom())
221 return absl::FailedPreconditionError(
222 "Open the main project ROM to copy native graphics settings");
223 if (source_map < 0 || source_map >= 0xA0)
224 return absl::InvalidArgumentError("Choose a native source map $00-$9F");
225 const auto* selected = overworld.overworld_map(source_map);
226 if (!selected)
227 return absl::FailedPreconditionError(
228 absl::StrFormat("Native map $%02X is unavailable", source_map));
229 const int parent_id = selected->parent();
230 const auto* parent = parent_id >= 0 && parent_id < 0xA0
231 ? overworld.overworld_map(parent_id)
232 : nullptr;
233 if (!parent || parent->parent() != parent_id)
234 return absl::FailedPreconditionError(absl::StrFormat(
235 "Native map $%02X has no valid native area parent", source_map));
236 const auto* donor = overworld.overworld_map(0x3A);
237 if (!donor)
238 return absl::FailedPreconditionError("Loom map $3A is unavailable");
239 if (selected->HasGraphicsPreview() || parent->HasGraphicsPreview() ||
240 donor->HasGraphicsPreview())
241 return absl::FailedPreconditionError(
242 "Graphics preview overrides cannot be copied as native settings");
243 const auto* data = parent->game_data();
244 if (!data || data != donor->game_data() || !data->gfx_groups_loaded)
245 return absl::FailedPreconditionError(
246 "Load native graphics before copying graphics settings");
247 // OverworldMap resolves these tables with an unchecked version map lookup.
248 const auto tables = zelda3::kVersionConstantsMap.find(data->version);
249 if (tables == zelda3::kVersionConstantsMap.end() ||
250 tables->second.kOverworldGfxGroups1 == 0 ||
251 tables->second.kOverworldGfxGroups2 == 0 ||
252 tables->second.kOverworldMapPaletteGroup == 0 ||
253 tables->second.kSpriteBlocksetPointer == 0)
254 return absl::FailedPreconditionError(
255 "Native graphics tables are unknown for this ROM version");
256 if (donor->roomset_source() !=
258 return absl::FailedPreconditionError(
259 "Loom map $3A must use custom tile groups to copy graphics settings");
260
261 auto source = *parent;
262 source.ClearInheritedAreaProperties();
263 source.set_game_state(0);
264 source.LoadAreaGraphics();
265 for (int i = 0; i < 8; ++i) {
266 const uint8_t sheet = source.static_graphics(i);
267 if (sheet == 0x00 || sheet == 0xFF)
268 return absl::FailedPreconditionError(
269 absl::StrFormat("Native map $%02X resolves graphics slot %d to $%02X",
270 parent_id, i, sheet));
271 candidate.graphics_sheets[i] = sheet;
272 }
273 candidate.animated_sheet = source.animated_sheet();
274 if (candidate.animated_sheet == 0x00 || candidate.animated_sheet == 0xFF)
275 return absl::FailedPreconditionError(
276 absl::StrFormat("Native map $%02X resolves animation to $%02X",
277 parent_id, candidate.animated_sheet));
278 candidate.main_palette = source.render_main_palette();
279 candidate.palette_set = source.area_palette();
280 if (candidate.main_palette == 0xFF ||
281 candidate.main_palette >= data->palette_groups.overworld_main.size())
282 return absl::OutOfRangeError(absl::StrFormat(
283 "Native main palette $%02X is unavailable", candidate.main_palette));
284 if (candidate.palette_set > 0xA3)
285 return absl::OutOfRangeError(absl::StrFormat(
286 "Native area palette $%02X is unavailable", candidate.palette_set));
287
288 // LoadPalette replaces $FF entries using parent-1. Only Loom retains the
289 // same context when Lyra reconstructs the settings from its $3A donor.
290 const bool same_palette_context = parent_id == 0x3A;
291 const auto& rom = overworld.rom()->vector();
292 const size_t area_entry = tables->second.kOverworldMapPaletteGroup +
293 static_cast<size_t>(candidate.palette_set) * 4;
294 if (area_entry + 2 >= rom.size())
295 return absl::OutOfRangeError("Native area palette table is incomplete");
296 for (int i = 0; i < 3; ++i) {
297 if (!same_palette_context && rom[area_entry + i] == 0xFF)
298 return absl::FailedPreconditionError(absl::StrFormat(
299 "Native area palette $%02X uses a previous-map fallback",
300 candidate.palette_set));
301 }
302 for (int state = 0; state < 3; ++state) {
303 const uint8_t sprite_palette = source.sprite_palette(state);
304 const size_t sprite_entry = zelda3::kOverworldSpritePaletteGroup +
305 static_cast<size_t>(sprite_palette) * 2;
306 if (sprite_entry + 1 >= rom.size())
307 return absl::OutOfRangeError("Native sprite palette table is incomplete");
308 if (!same_palette_context &&
309 (rom[sprite_entry] == 0xFF || rom[sprite_entry + 1] == 0xFF))
310 return absl::FailedPreconditionError(absl::StrFormat(
311 "Native sprite palette $%02X (State %d) uses a previous-map fallback",
312 sprite_palette, state));
313 candidate.sprite_graphics[state] = source.sprite_graphics(state);
314 candidate.sprite_palettes[state] = sprite_palette;
315 }
316 for (int state = 0; state < 3; ++state)
318 ResolveMapAssets(overworld, candidate, state, false).status());
319 return candidate;
320}
321
323 zelda3::Overworld& overworld, const core::LyraAuthoringState& authoring) {
324 std::unordered_map<std::string, std::string> bindings;
325 for (const auto& map : authoring.maps) {
326 for (int state = 0; state < 3; ++state) {
327 const auto key = zelda3::LyraMapAssetKey(map.properties, state);
328 if (!bindings.contains(key)) {
330 auto assets,
331 ResolveMapAssets(overworld, map.properties, state, false));
332 bindings.emplace(key, assets->binding);
333 }
334 if (!map.asset_bindings[state].empty() &&
335 map.asset_bindings[state] != bindings.at(key))
336 return absl::FailedPreconditionError(
337 "Lyra map graphics or palette assets changed; draft retained");
338 }
339 }
340 return absl::OkStatus();
341}
342
344 zelda3::Overworld& overworld) {
345 const auto* donor = overworld.overworld_map(0x3A);
346 if (!document_ || !donor || !donor->game_data() || !overworld.rom())
347 return absl::FailedPreconditionError("Lyra source is unavailable");
348 const auto& data = *donor->game_data();
349 std::string metadata;
350 for (const auto& tile : overworld.tiles16())
351 for (const auto& quadrant : tile.tiles_info)
352 AddWord(metadata, gfx::TileInfoToWord(quadrant));
353 for (const auto* group :
354 {&data.palette_groups.overworld_main, &data.palette_groups.overworld_aux,
355 &data.palette_groups.overworld_animated, &data.palette_groups.hud,
356 &data.palette_groups.global_sprites, &data.palette_groups.armors,
357 &data.palette_groups.sprites_aux1, &data.palette_groups.sprites_aux2,
358 &data.palette_groups.sprites_aux3, &data.palette_groups.grass}) {
359 AddWord(metadata, group->size());
360 for (size_t i = 0; i < group->size(); ++i) {
361 AddWord(metadata, (*group)[i].size());
362 for (const auto& color : (*group)[i])
363 AddWord(metadata, color.snes());
364 }
365 }
366 for (const auto& group : data.main_blockset_ids)
367 for (auto id : group)
368 metadata.push_back(id);
369 for (const auto& group : data.room_blockset_ids)
370 for (auto id : group)
371 metadata.push_back(id);
372 for (const auto& group : data.spriteset_ids)
373 for (auto id : group)
374 metadata.push_back(id);
375 for (int state = 0; state < 3; ++state) {
376 metadata.push_back(donor->sprite_graphics(state));
377 metadata.push_back(donor->sprite_palette(state));
378 }
379 // Byte comparison avoids hashing/rebuilding native atlases every frame.
380 // Snapshot only when a source actually changes; this also catches unsaved edits.
381 if (source_rom_ == overworld.rom()->vector() &&
382 source_graphics_ == data.graphics_buffer && source_metadata_ == metadata)
383 return source_status_;
384 source_rom_ = overworld.rom()->vector();
385 source_graphics_ = data.graphics_buffer;
386 source_metadata_ = std::move(metadata);
387 validated_authoring_.reset();
388 displayed_assets_.fill(nullptr);
389 map_assets_.clear();
390 assets_valid_ = false;
392 source_status_ = BindSource(overworld, &profile);
393 if (!source_status_.ok())
394 return source_status_;
395 if (profile != document_->profile()) {
396 source_status_ = absl::FailedPreconditionError(
397 "Lyra bound native assets changed; reload matching source before "
398 "editing");
399 return source_status_;
400 }
401 return source_status_;
402}
403
405 int state) {
406 if (!document_)
407 return absl::OkStatus();
409 assets_valid_ = false;
411 *validated_authoring_ != document_->authoring()) {
413 ValidateMapAssets(overworld, document_->authoring());
414 validated_authoring_ = document_->authoring();
415 }
417 asset_state_ = std::clamp(state, 0, 2);
418 std::set<std::string> active_keys;
419 for (int i = 0; i < 32; ++i) {
420 const auto& map = document_->authoring().maps[i];
421 const auto key = zelda3::LyraMapAssetKey(map.properties, asset_state_);
422 active_keys.insert(key);
423 if (!map_assets_.contains(key)) {
424 ASSIGN_OR_RETURN(auto assets, ResolveMapAssets(overworld, map.properties,
425 asset_state_, true));
426 if (!map.asset_bindings[asset_state_].empty() &&
427 assets->binding != map.asset_bindings[asset_state_])
428 return absl::FailedPreconditionError("Lyra map asset binding changed");
429 map_assets_.emplace(key, std::move(assets));
430 }
431 if (!map.asset_bindings[asset_state_].empty() &&
432 map.asset_bindings[asset_state_] != map_assets_.at(key)->binding)
433 return absl::FailedPreconditionError("Lyra cached map binding changed");
434 displayed_assets_[i] = map_assets_.at(key).get();
435 }
436 assets_valid_ = true;
437 std::erase_if(map_assets_, [&](const auto& item) {
438 return !active_keys.contains(item.first);
439 });
440 return absl::OkStatus();
441}
442
444 if (!assets_valid_)
445 return unavailable_atlas_;
446 if (map_id >= 0xA0 && map_id < 0xC0 && displayed_assets_[map_id - 0xA0])
447 return displayed_assets_[map_id - 0xA0]->atlas;
448 return atlas_;
449}
450
452 if (!assets_valid_)
453 return nullptr;
454 if (map_id >= 0xA0 && map_id < 0xC0 && displayed_assets_[map_id - 0xA0])
455 return &displayed_assets_[map_id - 0xA0]->graphics;
456 return nullptr;
457}
458
460 const gui::CanvasRuntime& canvas, int map_id,
461 const core::LyraSpritePlacement& sprite) {
462 if (map_id < 0xA0 || map_id >= 0xC0 || !canvas.draw_list)
463 return;
464 auto* assets = displayed_assets_[map_id - 0xA0];
465 if (!assets)
466 return;
467 if (!assets->sprites.contains(sprite.sprite_id)) {
468 zelda3::Sprite preview(sprite.sprite_id, 0, 0, 0, 0);
469 preview.RenderPreviewGraphics(assets->graphics.vector());
470 const auto bounds = preview.preview_bounds();
471 if (preview.preview_graphics()->empty() || bounds.w <= 0 || bounds.h <= 0)
472 return;
473 auto bitmap = std::make_unique<gfx::Bitmap>();
474 bitmap->Create(bounds.w, bounds.h, 8, *preview.preview_graphics());
475 bitmap->SetPalette(assets->graphics.palette());
478 assets->sprites.emplace(sprite.sprite_id, std::move(bitmap));
479 assets->sprite_bounds.emplace(sprite.sprite_id, bounds);
480 }
481 const auto& bitmap = *assets->sprites.at(sprite.sprite_id);
482 if (!bitmap.texture())
483 return;
484 const auto bounds = assets->sprite_bounds.at(sprite.sprite_id);
485 const ImVec2 min(
486 canvas.canvas_p0.x + canvas.scrolling.x +
487 (map_id % 8 * 512 + sprite.x * 16 + bounds.x) * canvas.scale,
488 canvas.canvas_p0.y + canvas.scrolling.y +
489 (map_id % 64 / 8 * 512 + sprite.y * 16 + bounds.y) * canvas.scale);
490 canvas.draw_list->AddImage(
491 (ImTextureID)(intptr_t)bitmap.texture(), min,
492 ImVec2(min.x + bounds.w * canvas.scale, min.y + bounds.h * canvas.scale));
493}
494
496 zelda3::Overworld& overworld,
497 UndoManager& history) {
498 status_ = ApplyAuthoring(std::move(after), overworld, history);
499 return status_;
500}
501
503 int map_id, std::optional<core::LyraGameplayAreaSize> size,
504 zelda3::Overworld& overworld, UndoManager& history) {
505 if (!document_) {
506 return absl::FailedPreconditionError("Lyra project is unavailable");
507 }
508 if (map_id < Document::kFirstMap ||
509 map_id >= Document::kFirstMap + Document::kMapCount) {
510 return absl::OutOfRangeError("Gameplay area page must be in A0-BF");
511 }
512 const auto* current = document_->GameplayAreaForMap(map_id);
513 if (size && current && current->parent_map_id != map_id) {
514 return absl::FailedPreconditionError(absl::StrFormat(
515 "Select area parent $%02X to resize, or unassign the whole area first",
516 current->parent_map_id));
517 }
518 auto after = document_->authoring();
519 auto& areas = after.gameplay_areas;
520 const auto existing = std::find_if(
521 areas.begin(), areas.end(), [&](const core::LyraGameplayArea& area) {
522 return current && area.parent_map_id == current->parent_map_id;
523 });
524 if (!size) {
525 if (existing == areas.end()) {
526 return absl::OkStatus();
527 }
528 areas.erase(existing);
529 } else {
530 ASSIGN_OR_RETURN(auto maps, core::LyraGameplayAreaFootprint(map_id, *size));
531 core::LyraGameplayArea area{map_id, *size, std::move(maps)};
532 if (existing == areas.end()) {
533 areas.push_back(std::move(area));
534 } else {
535 *existing = std::move(area);
536 }
537 }
538 return EditAuthoring(std::move(after), overworld, history);
539}
540
542 zelda3::Overworld& overworld,
543 UndoManager& history) {
544 if (!document_)
545 return absl::FailedPreconditionError("Lyra project is unavailable");
546 FinishStroke(history);
548 const auto before = document_->authoring();
549 if (before == after)
550 return absl::OkStatus();
551 for (int i = 0; i < 32; ++i) {
552 if (before.maps[i].properties == after.maps[i].properties)
553 continue;
554 for (int state = 0; state < 3; ++state) {
556 auto assets,
557 ResolveMapAssets(overworld, after.maps[i].properties, state, false));
558 after.maps[i].asset_bindings[state] = assets->binding;
559 }
560 }
561 RETURN_IF_ERROR(document_->SetAuthoring(before, after));
562 history.Push(
563 std::make_unique<AuthoringAction>(document_, before, std::move(after)));
564 return absl::OkStatus();
565}
566
568 zelda3::Overworld& overworld) {
569 if (dirty())
570 return absl::FailedPreconditionError(
571 "Save the current Lyra terrain draft before replacing it");
572 if (!project) {
573 Reset();
574 return absl::OkStatus();
575 }
576 if (project->lyra_terrain_file.empty()) {
577 if (!project->project_opened() || !project->IsOracleCompatible()) {
578 Reset();
579 return absl::OkStatus();
580 }
581 // Oracle projects own this source automatically. It is an implementation
582 // detail of opening the project, editing Lyra, and saving the project.
583 return Create(project, overworld);
584 }
585 const auto path = project->GetAbsolutePath(project->lyra_terrain_file);
586 RETURN_IF_ERROR(ValidateTarget(path, *project));
588 RETURN_IF_ERROR(BindSource(overworld, &profile));
589 ASSIGN_OR_RETURN(auto bytes, ReadSource(path));
590 if (!bytes)
591 return absl::NotFoundError("The project's Lyra terrain file is missing");
592 ASSIGN_OR_RETURN(auto document, Document::Parse(*bytes, profile));
594 // ROM replacement can keep the sidecar path while changing its native tile
595 // profile. Validate all assets in copied maps before retiring the old view.
597 RETURN_IF_ERROR(BindSource(overworld, &profile, &atlas));
598 InstallDocument(std::make_shared<Document>(std::move(document)),
599 std::move(atlas), path, std::move(bytes), false);
600 // Keep the live reference absolute so project Save As can rebase it safely.
602 return absl::OkStatus();
603}
604
606 const std::string& path,
607 zelda3::Overworld& overworld) {
608 if (!project || !project->project_opened())
609 return absl::FailedPreconditionError(
610 "Save a Yaze project before opening Lyra terrain");
611 if (path.empty())
612 return absl::InvalidArgumentError("Select a Lyra terrain .json file");
613 auto candidate = *project;
614 candidate.lyra_terrain_file = path;
615 RETURN_IF_ERROR(Load(&candidate, overworld));
616 const bool reference_changed =
617 project->GetAbsolutePath(project->lyra_terrain_file) !=
618 candidate.lyra_terrain_file;
619 project->lyra_terrain_file = candidate.lyra_terrain_file;
620 pending_project_commit_ = reference_changed;
621 return absl::OkStatus();
622}
623
625 zelda3::Overworld& overworld) {
626 if (!project || !project->project_opened())
627 return absl::FailedPreconditionError(
628 "Save a Yaze project before creating Lyra terrain");
629 if (document_ && !dirty() &&
630 project->GetAbsolutePath(project->lyra_terrain_file) == source_path_)
631 return absl::OkStatus();
632 if (!project->lyra_terrain_file.empty())
633 return Load(project, overworld);
634 if (document_)
635 return absl::FailedPreconditionError("Lyra terrain is already loaded");
637 RETURN_IF_ERROR(BindSource(overworld, &profile));
638 fs::path path(project->filepath);
639 path.replace_extension(".lyra-terrain.json");
640 RETURN_IF_ERROR(ValidateTarget(path, *project));
641 ASSIGN_OR_RETURN(auto bytes, ReadSource(path));
642 if (bytes) {
643 auto candidate = *project;
644 candidate.lyra_terrain_file =
645 fs::absolute(path).lexically_normal().string();
646 RETURN_IF_ERROR(Load(&candidate, overworld));
647 project->lyra_terrain_file = candidate.lyra_terrain_file;
649 return absl::OkStatus();
650 }
651 ASSIGN_OR_RETURN(auto document, Document::Create(profile));
654 RETURN_IF_ERROR(BindSource(overworld, &profile, &atlas));
656 std::make_shared<Document>(std::move(document)), std::move(atlas),
657 fs::absolute(path).lexically_normal().string(), std::nullopt, true);
659 return absl::OkStatus();
660}
661
662void LyraTerrainCanvas::InstallDocument(std::shared_ptr<Document> document,
664 std::string source_path,
665 std::optional<std::string> source_bytes,
666 bool unpublished) {
667 Reset();
668 document_ = std::move(document);
669 atlas_ = std::move(atlas);
670 source_path_ = std::move(source_path);
671 source_bytes_ = std::move(source_bytes);
672 unpublished_ = unpublished;
674 &atlas_);
675}
676
679 document_->dirty() || !PendingPropertyMaps().empty());
680}
681
683 const auto& live = document_->authoring().maps[map_index].properties;
684 auto& entry = property_drafts_[map_index];
685 if (!entry || entry->draft == entry->baseline || entry->draft == live)
686 entry = PropertyDraft{live, live};
687 return entry->draft;
688}
689
690bool LyraTerrainCanvas::PropertyPending(int map_index) const {
691 const auto& entry = property_drafts_[map_index];
692 return document_ && entry && entry->draft != entry->baseline &&
693 entry->draft != document_->authoring().maps[map_index].properties;
694}
695
696bool LyraTerrainCanvas::PropertyConflict(int map_index) const {
697 return PropertyPending(map_index) &&
698 property_drafts_[map_index]->baseline !=
699 document_->authoring().maps[map_index].properties;
700}
701
703 std::vector<int> map_ids;
704 for (int i = 0; i < Document::kMapCount; ++i) {
705 if (PropertyPending(i))
706 map_ids.push_back(Document::kFirstMap + i);
707 }
708 return map_ids;
709}
710
711std::string LyraTerrainCanvas::FormatMapIds(const std::vector<int>& map_ids) {
712 std::string text;
713 for (const int map_id : map_ids)
714 absl::StrAppendFormat(&text, "%s$%02X", text.empty() ? "" : ", ", map_id);
715 return text;
716}
717
718absl::Status LyraTerrainCanvas::ApplyPropertyDraft(int map_index,
719 zelda3::Overworld& overworld,
720 UndoManager& history) {
721 if (!document_)
722 return absl::FailedPreconditionError("Lyra project is unavailable");
723 // A whole-map draft would silently replace the newer live fields.
724 if (PropertyConflict(map_index))
725 return absl::FailedPreconditionError(absl::StrFormat(
726 "Map $%02X properties changed after these edits began; Revert "
727 "Properties to load the current values, then edit again. Edits "
728 "retained.",
729 Document::kFirstMap + map_index));
730 core::LyraAuthoringState next_state = document_->authoring();
731 next_state.maps[map_index].properties = PropertyDraftFor(map_index);
732 RETURN_IF_ERROR(EditAuthoring(std::move(next_state), overworld, history));
733 RevertPropertyDraft(map_index);
734 return absl::OkStatus();
735}
736
738 const auto& live = document_->authoring().maps[map_index].properties;
739 property_drafts_[map_index] = PropertyDraft{live, live};
740}
741
743 zelda3::Overworld& overworld) {
744 if (const auto pending = PendingPropertyMaps(); !pending.empty())
745 return absl::FailedPreconditionError(absl::StrFormat(
746 "Apply or Revert Lyra properties for %s %s before saving the project.",
747 pending.size() == 1 ? "map" : "maps", FormatMapIds(pending)));
748 if (!project || !document_ ||
749 project->GetAbsolutePath(project->lyra_terrain_file) != source_path_)
750 return absl::FailedPreconditionError(
751 "Lyra project source changed; draft retained");
752 RETURN_IF_ERROR(ValidateTarget(source_path_, *project));
754 RETURN_IF_ERROR(BindSource(overworld, &profile));
755 if (profile != document_->profile())
756 return absl::FailedPreconditionError(
757 "Lyra tile definitions or graphics profile changed; draft retained");
758 return ValidateMapAssets(overworld, document_->authoring());
759}
760
762 zelda3::Overworld& overworld) {
763 if (!dirty())
764 return absl::OkStatus();
765 RETURN_IF_ERROR(ValidateSave(project, overworld));
766 ASSIGN_OR_RETURN(auto after,
769 source_bytes_ = after;
770 unpublished_ = false;
772 document_->MarkSaved();
773 return absl::OkStatus();
774}
775
777 const std::string& project_path,
778 zelda3::Overworld& overworld) {
779 RETURN_IF_ERROR(ValidateSave(project, overworld));
781 project->SaveAsWithLyraTerrain(project_path, *document_, source_bytes_));
782 // Retain the document shared by undo actions; only its backing owner moves.
784 source_bytes_ = document_->Serialize();
785 unpublished_ = false;
787 document_->MarkSaved();
788 return absl::OkStatus();
789}
790
792 if (!stroke_active_ || !document_)
793 return;
794 auto stroke = document_->EndStroke();
795 stroke_active_ = false;
796 last_cell_.reset();
797 if (!stroke.ok()) {
798 status_ = stroke.status();
799 return;
800 }
801 if (!stroke->empty())
802 history.Push(
803 std::make_unique<TerrainStrokeAction>(document_, std::move(*stroke)));
804}
805
806absl::Status LyraTerrainCanvas::PaintPattern(const TileBrush& brush, int x,
807 int y, int width, int height,
808 bool finalize,
809 UndoManager& history) {
810 if (!assets_valid_)
811 return absl::FailedPreconditionError(
812 "Lyra map assets are unavailable; terrain retained");
813 if (!document_)
814 return absl::FailedPreconditionError("Lyra terrain is unavailable");
815 if (!brush.valid() || width <= 0 || width > Document::kWidth || height <= 0 ||
816 height > 256 || x < 0 || x >= Document::kWidth || y < 0 ||
817 y >= Document::kHeight)
818 return absl::InvalidArgumentError("Invalid Lyra terrain brush footprint");
819 for (const int tile : brush.tile_ids) {
820 if (tile >= document_->profile().tile_count)
821 return absl::InvalidArgumentError(
822 "Brush contains a Tile16 outside the Lyra graphics profile");
823 }
824 // Discrete paste/fill operations and changed brushes each own an undo step.
825 if (stroke_active_ &&
826 (finalize || brush.width != brush_.width ||
827 brush.height != brush_.height || brush.tile_ids != brush_.tile_ids ||
828 width != pattern_width_ || height != pattern_height_))
829 FinishStroke(history);
830 if (!stroke_active_) {
831 RETURN_IF_ERROR(document_->BeginStroke());
832 stroke_active_ = true;
833 brush_ = brush;
834 pattern_width_ = width;
835 pattern_height_ = height;
836 last_cell_.reset();
837 }
838 status_ = absl::OkStatus();
839 PaintLine(x, y);
840 if (!status_.ok()) {
841 document_->CancelStroke();
842 stroke_active_ = false;
843 last_cell_.reset();
844 return status_;
845 }
846 if (finalize)
847 FinishStroke(history);
848 return status_;
849}
850
852 for (int dy = 0; dy < pattern_height_ && y + dy < Document::kHeight; ++dy) {
853 for (int dx = 0; dx < pattern_width_ && x + dx < Document::kWidth; ++dx) {
854 status_ = document_->Paint(
855 x + dx, y + dy, brush_.at(dx % brush_.width, dy % brush_.height));
856 if (!status_.ok())
857 return;
858 }
859 }
860}
861
863 int x0 = last_cell_ ? static_cast<int>(last_cell_->x) : x;
864 int y0 = last_cell_ ? static_cast<int>(last_cell_->y) : y;
865 const int dx = std::abs(x - x0), sx = x0 < x ? 1 : -1;
866 const int dy = -std::abs(y - y0), sy = y0 < y ? 1 : -1;
867 int error = dx + dy;
868 for (;;) {
869 PaintStamp(x0, y0);
870 if (!status_.ok() || (x0 == x && y0 == y))
871 break;
872 const int twice = 2 * error;
873 if (twice >= dy) {
874 error += dy;
875 x0 += sx;
876 }
877 if (twice <= dx) {
878 error += dx;
879 y0 += sy;
880 }
881 }
882 last_cell_ = ImVec2(x, y);
883}
884
885void LyraTerrainCanvas::DrawTile(ImDrawList* draw, const gfx::Bitmap& atlas,
886 int tile, ImVec2 min, float size) const {
887 const float height = atlas.height();
888 const ImVec2 uv0((tile % 8) * 16.0f / 128.0f, (tile / 8) * 16.0f / height);
889 const ImVec2 uv1(uv0.x + 16.0f / 128.0f, uv0.y + 16.0f / height);
890 draw->AddImage((ImTextureID)(intptr_t)atlas.texture(), min,
891 ImVec2(min.x + size, min.y + size), uv0, uv1);
892}
893
895 if (!document_ || !canvas.draw_list || !atlas_.texture() ||
896 !std::isfinite(canvas.scale) || canvas.scale <= 0) {
897 return;
898 }
899
900 // The existing special maps occupy world pixels Y=0..2047. Sidecar map A0
901 // begins directly below them; navigation and scrolling remain canvas-owned.
902 constexpr float kWorldYOffset =
903 4 * Document::kMapSize * Document::kTilePixels;
904 const float cell = Document::kTilePixels * canvas.scale;
905 const ImVec2 start(
906 canvas.canvas_p0.x + canvas.scrolling.x,
907 canvas.canvas_p0.y + canvas.scrolling.y + kWorldYOffset * canvas.scale);
908 if (!std::isfinite(start.x) || !std::isfinite(start.y)) {
909 return;
910 }
911 auto* draw = canvas.draw_list;
912 const ImVec2 clip_min = draw->GetClipRectMin();
913 const ImVec2 clip_max = draw->GetClipRectMax();
914 // Clamp in floating point before converting: the canvas can be far outside
915 // the viewport while panning, and focus loss can supply extreme coordinates.
916 const auto first_cell = [cell](float clip, float origin, int count) {
917 return static_cast<int>(std::clamp(std::floor((clip - origin) / cell), 0.0f,
918 static_cast<float>(count)));
919 };
920 const auto last_cell = [cell](float clip, float origin, int count) {
921 return static_cast<int>(std::clamp(std::ceil((clip - origin) / cell), 0.0f,
922 static_cast<float>(count)));
923 };
924 const int x0 = first_cell(clip_min.x, start.x, Document::kWidth);
925 const int y0 = first_cell(clip_min.y, start.y, Document::kHeight);
926 const int x1 = last_cell(clip_max.x, start.x, Document::kWidth);
927 const int y1 = last_cell(clip_max.y, start.y, Document::kHeight);
928 for (int y = y0; y < y1; ++y) {
929 for (int x = x0; x < x1; ++x) {
930 auto& atlas = MapAtlas(0xA0 + (y / 32) * 8 + x / 32);
931 if (!atlas.texture())
932 continue;
933 DrawTile(draw, atlas, *document_->tile(x, y),
934 ImVec2(start.x + x * cell, start.y + y * cell), cell);
935 }
936 }
937 // One band covers the facing edge row/column of both maps. map_b is always
938 // $A0+, south or east of map_a. Unassigned passages draw no opening.
939 for (const auto& connection : document_->authoring().edge_connections) {
940 if (!connection.passage)
941 continue;
942 const int col = connection.map_b % Document::kColumns;
943 const int row = connection.map_b / Document::kColumns -
944 Document::kFirstMap / Document::kColumns;
945 const float first = connection.passage->first_tile;
946 const float last = connection.passage->last_tile + 1;
947 const bool vertical =
948 connection.map_b - connection.map_a == Document::kColumns;
949 const float edge = (vertical ? row : col) * Document::kMapSize;
950 const float along = (vertical ? col : row) * Document::kMapSize;
951 const ImVec2 min_tiles = vertical ? ImVec2(along + first, edge - 1)
952 : ImVec2(edge - 1, along + first);
953 const ImVec2 max_tiles = vertical ? ImVec2(along + last, edge + 1)
954 : ImVec2(edge + 1, along + last);
955 const ImVec2 p_min(start.x + min_tiles.x * cell,
956 start.y + min_tiles.y * cell);
957 const ImVec2 p_max(start.x + max_tiles.x * cell,
958 start.y + max_tiles.y * cell);
959 draw->AddRectFilled(p_min, p_max, IM_COL32(255, 140, 40, 110));
960 draw->AddRect(p_min, p_max, IM_COL32(255, 200, 120, 230));
961 }
962 // The shared Overworld canvas owns the grid and selected-map outline.
963 // Keep project terrain free of an additional always-on map-ID overlay.
964}
965
967 source_rom_.clear();
968 source_graphics_.clear();
969 source_metadata_.clear();
970 validated_authoring_.reset();
971 authoring_assets_status_ = absl::OkStatus();
972 source_status_ = absl::OkStatus();
973 assets_valid_ = false;
974 displayed_assets_.fill(nullptr);
975 map_assets_.clear();
976 property_drafts_.fill(std::nullopt);
977 copy_source_map_ = 0x3A;
978 copy_message_.clear();
979 copy_failed_ = false;
982 sprite_filter_ = {};
984 drag_map_ = -1;
985 property_map_ = -1;
986 drag_before_.reset();
988 brush_ = {};
990 document_->CancelStroke();
991 document_.reset();
992 source_path_.clear();
993 source_bytes_.reset();
994 unpublished_ = false;
995 stroke_active_ = false;
996 last_cell_.reset();
998 status_ = absl::OkStatus();
999}
1000} // namespace yaze::editor
auto size() const
Definition rom.h:212
const LyraAuthoringState & authoring() const
const LyraTerrainProfile & profile() const
bool PropertyConflict(int map_index) const
absl::Status PaintPattern(const TileBrush &brush, int x, int y, int width, int height, bool finalize, UndoManager &history)
bool PropertyPending(int map_index) const
absl::Status PrepareMapAssets(zelda3::Overworld &overworld, int state)
absl::Status SaveAs(project::YazeProject *project, const std::string &project_path, zelda3::Overworld &overworld)
std::optional< std::string > source_bytes_
absl::Status SetGameplayArea(int map_id, std::optional< core::LyraGameplayAreaSize > size, zelda3::Overworld &overworld, UndoManager &history)
void DrawSpecialWorld(const gui::CanvasRuntime &canvas)
std::shared_ptr< core::LyraTerrainDocument > document_
std::optional< core::LyraAuthoringState > validated_authoring_
std::vector< int > PendingPropertyMaps() const
absl::Status ValidateMapAssets(zelda3::Overworld &overworld, const core::LyraAuthoringState &authoring)
gfx::Bitmap & MapAtlas(int map_id)
absl::Status Create(project::YazeProject *project, zelda3::Overworld &overworld)
std::array< std::optional< PropertyDraft >, core::LyraTerrainDocument::kMapCount > property_drafts_
absl::StatusOr< std::unique_ptr< MapAssets > > ResolveMapAssets(zelda3::Overworld &overworld, const core::LyraMapProperties &properties, int state, bool textures)
void InstallDocument(std::shared_ptr< core::LyraTerrainDocument > document, gfx::Bitmap atlas, std::string source_path, std::optional< std::string > source_bytes, bool unpublished)
absl::Status EditAuthoring(core::LyraAuthoringState after, zelda3::Overworld &overworld, UndoManager &history)
const core::LyraTerrainDocument * document() const
const gfx::Bitmap & atlas() const
static std::string FormatMapIds(const std::vector< int > &map_ids)
std::array< MapAssets *, 32 > displayed_assets_
std::array< char, 64 > sprite_filter_
std::vector< uint8_t > source_graphics_
gfx::Bitmap * MapGraphics(int map_id)
std::unordered_map< std::string, std::unique_ptr< MapAssets > > map_assets_
void DrawSpritePreview(const gui::CanvasRuntime &canvas, int map_id, const core::LyraSpritePlacement &sprite)
void DrawTile(ImDrawList *draw, const gfx::Bitmap &atlas, int tile, ImVec2 min, float size) const
std::optional< core::LyraAuthoringState > drag_before_
absl::Status BindSource(zelda3::Overworld &overworld, core::LyraTerrainProfile *profile, gfx::Bitmap *atlas=nullptr)
absl::StatusOr< core::LyraMapProperties > CopyNativeGraphicsSettings(zelda3::Overworld &overworld, int source_map, core::LyraMapProperties candidate)
absl::Status CheckAssetSources(zelda3::Overworld &overworld)
absl::Status ApplyPropertyDraft(int map_index, zelda3::Overworld &overworld, UndoManager &history)
absl::Status Load(project::YazeProject *project, zelda3::Overworld &overworld)
absl::Status Save(project::YazeProject *project, zelda3::Overworld &overworld)
void FinishStroke(UndoManager &history)
absl::Status ApplyAuthoring(core::LyraAuthoringState after, zelda3::Overworld &overworld, UndoManager &history)
core::LyraMapProperties & PropertyDraftFor(int map_index)
absl::Status ValidateSave(project::YazeProject *project, zelda3::Overworld &overworld)
absl::Status Open(project::YazeProject *project, const std::string &path, zelda3::Overworld &overworld)
Abstract base for all undoable actions (Command pattern)
Definition undo_action.h:20
Manages undo/redo stacks for a single editor.
void Push(std::unique_ptr< UndoAction > action)
size_t MemoryUsage() const override
Approximate memory footprint for budget enforcement.
std::string Description() const override
Human-readable description (e.g., "Paint 12 tiles on map 5")
AuthoringAction(std::shared_ptr< Document > document, core::LyraAuthoringState before, core::LyraAuthoringState after)
TerrainStrokeAction(std::shared_ptr< Document > document, core::LyraTerrainStroke stroke)
size_t MemoryUsage() const override
Approximate memory footprint for budget enforcement.
std::string Description() const override
Human-readable description (e.g., "Paint 12 tiles on map 5")
void QueueTextureCommand(TextureCommandType type, Bitmap *bitmap)
Definition arena.cc:39
void RetireBitmap(Bitmap &bitmap)
Explicitly retire resources owned by a bitmap that is being erased.
Definition arena.cc:63
static Arena & Get()
Definition arena.cc:24
Represents a bitmap image optimized for SNES ROM hacking.
Definition bitmap.h:70
TextureHandle texture() const
Definition bitmap.h:410
int height() const
Definition bitmap.h:399
const std::vector< gfx::Tile16 > & tiles16() const
Definition overworld.h:784
bool is_lyra_preview() const
Definition overworld.h:444
auto is_loaded() const
Definition overworld.h:836
auto overworld_map(int i) const
Definition overworld.h:770
A class for managing sprites in the overworld and underworld.
Definition sprite.h:39
void RenderPreviewGraphics(std::span< const uint8_t > graphics, const SpriteOamLayout *layout_override=nullptr, std::span< const uint8_t > graphics_resource={})
Definition sprite.cc:1194
auto preview_graphics() const
Definition sprite.h:103
SDL_Rect preview_bounds() const
Definition sprite.h:106
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
absl::Status PublishSourceArtifacts(const SourceArtifactPublicationLock &lock, std::vector< SourceArtifactUpdate > updates, std::string_view expected_primary_sha256, SourceArtifactReadbackValidator readback_validator)
absl::StatusOr< std::vector< int > > LyraGameplayAreaFootprint(int parent_map_id, LyraGameplayAreaSize size)
std::string ComputeSourceArtifactSha256(std::string_view content)
absl::StatusOr< std::unique_ptr< SourceArtifactPublicationLock > > AcquireSourceArtifactPublicationLock(const std::vector< fs::path > &targets, const SourceArtifactPublisherLabels &labels, const std::vector< fs::path > &read_only_targets)
absl::Status ValidateTarget(const fs::path &path, const project::YazeProject &project)
absl::StatusOr< std::optional< std::string > > ReadSource(const fs::path &path)
Editors are the view controllers for the application.
absl::StatusOr< std::string > PublishLyraTerrainSource(const project::YazeProject &project, const std::string &source_path, const std::optional< std::string > &source_bytes, const core::LyraTerrainDocument &document)
uint16_t TileInfoToWord(TileInfo tile_info)
Definition snes_tile.cc:362
constexpr int kOverworldSpritePaletteGroup
Definition overworld.h:117
std::string LyraMapAssetKey(const core::LyraMapProperties &p, int state)
absl::StatusOr< LyraNativeMapAssets > ResolveLyraMapAssets(Overworld &overworld, const core::LyraMapProperties &p, int state)
absl::Status BindLyraTerrainSource(Overworld &overworld, core::LyraTerrainProfile *profile, gfx::Bitmap *atlas)
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22
std::array< LyraMapAuthoring, 32 > maps
std::array< uint8_t, 8 > graphics_sheets
std::unordered_map< int, std::unique_ptr< gfx::Bitmap > > sprites
int at(int x, int y) const
Definition tile_brush.h:24
std::vector< int > tile_ids
Definition tile_brush.h:15
Modern project structure with comprehensive settings consolidation.
Definition project.h:290
bool project_opened() const
Definition project.h:538
std::string lyra_terrain_file
Definition project.h:342
bool IsOracleCompatible() const
Definition project.cc:1207
absl::Status SaveAsWithLyraTerrain(const std::string &new_path, const core::LyraTerrainDocument &terrain, const std::optional< std::string > &expected_source_bytes)
Definition project.cc:1214
std::string GetAbsolutePath(const std::string &relative_path) const
Definition project.cc:2675