yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
project_graphics_source.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <atomic>
5#include <cctype>
6#include <cerrno>
7#include <chrono>
8#include <cstring>
9#include <filesystem>
10#include <fstream>
11#include <iterator>
12#include <set>
13#include <span>
14#include <utility>
15
16#if !defined(_WIN32) && !defined(__EMSCRIPTEN__)
17#include <fcntl.h>
18#include <spawn.h>
19#include <sys/wait.h>
20#include <unistd.h>
21extern char** environ;
22#endif
23
26
27namespace yaze::editor {
28namespace {
29namespace fs = std::filesystem;
30using Json = nlohmann::json;
31
32std::string Hash(const std::vector<uint8_t>& bytes) {
34 {reinterpret_cast<const char*>(bytes.data()), bytes.size()});
35}
36
37absl::StatusOr<std::string> Read(const fs::path& path) {
38 std::error_code error;
39 const auto size = fs::file_size(path, error);
40 if (error)
41 return absl::NotFoundError("Cannot read graphics source file: " +
42 path.string());
43 if (size > 8 * 1024 * 1024)
44 return absl::ResourceExhaustedError("Graphics source file exceeds 8 MiB: " +
45 path.string());
46 std::ifstream input(path, std::ios::binary);
47 std::string bytes(size, '\0');
48 if (!input || !input.read(bytes.data(), bytes.size()))
49 return absl::DataLossError("Cannot read graphics source file: " +
50 path.string());
51 return bytes;
52}
53
54absl::StatusOr<fs::path> Resolve(const fs::path& path) {
55 std::error_code error;
56 const auto resolved = fs::canonical(path, error);
57 if (error)
58 return absl::NotFoundError("Cannot resolve graphics source path: " +
59 path.string());
60 return resolved;
61}
62
63absl::StatusOr<Json> Parse(const std::string& text) {
64 try {
65 return Json::parse(text);
66 } catch (const Json::exception& error) {
67 return absl::InvalidArgumentError(
68 std::string("Invalid graphics source JSON: ") + error.what());
69 }
70}
71
72Json Semantics(const Json& document) {
73 Json result = {{"schema_version", document.at("schema_version")},
74 {"resources", document.at("resources")},
75 {"profiles", document.at("profiles")},
76 {"bindings", document.at("bindings")}};
77 for (auto& resource : result["resources"])
78 resource.erase("path");
79 // Keep array order aligned with the pack builder and source publisher's
80 // admission rule. A separately regenerated pack should be rebound first.
81 return result;
82}
83
84// Use the existing complete V2 validator, including duplicate-key rejection.
85absl::StatusOr<Json> LoadCatalog(const fs::path& path,
86 std::map<std::string, std::string>* hashes) {
87 auto bytes = Read(path);
88 if (!bytes.ok())
89 return bytes.status();
90 auto preview = core::LoadOverworldGraphicsPreview(path.string());
91 if (!preview.ok())
92 return preview.status();
93 auto reread = Read(path);
94 if (!reread.ok())
95 return reread.status();
96 if (*bytes != *reread)
97 return absl::FailedPreconditionError(
98 "Graphics catalog changed while loading; reload the source document");
99 auto document = Parse(*bytes);
100 if (!document.ok())
101 return document.status();
102 if (document->value("schema_version", 0) != 2)
103 return absl::FailedPreconditionError(
104 "Graphics source editing requires a version 2 catalog");
105 (*hashes)[path.string()] = core::ComputeSourceArtifactSha256(*bytes);
106 for (const auto& resource : document->at("resources")) {
107 auto asset =
108 Resolve(path.parent_path() / resource.at("path").get<std::string>());
109 if (!asset.ok())
110 return asset.status();
111 auto content = Read(*asset);
112 if (!content.ok())
113 return content.status();
114 const auto hash = core::ComputeSourceArtifactSha256(*content);
115 auto expected = resource.at("sha256").get<std::string>();
116 std::transform(expected.begin(), expected.end(), expected.begin(),
117 [](unsigned char c) { return std::tolower(c); });
118 if (hash != expected ||
119 content->size() != resource.at("byte_count").get<size_t>())
120 return absl::FailedPreconditionError(
121 "Graphics resource changed while loading; reload the source "
122 "document");
123 (*hashes)[asset->string()] = hash;
124 }
125 return document;
126}
127
128absl::Status Write(const fs::path& path, const std::vector<uint8_t>& bytes) {
129 std::ofstream output(path, std::ios::binary);
130 output.write(reinterpret_cast<const char*>(bytes.data()), bytes.size());
131 output.close();
132 if (!output)
133 return absl::InternalError("Cannot write private graphics replacement: " +
134 path.string());
135 return absl::OkStatus();
136}
137
138absl::StatusOr<Json> RunPublisher(const std::vector<std::string>& arguments,
139 const fs::path& directory) {
140#if defined(_WIN32) || defined(__EMSCRIPTEN__)
141 return absl::UnimplementedError(
142 "Graphics source publication requires a native POSIX Python process");
143#else
144 // Pass each argument directly. Project paths and IDs never enter a shell.
145 const fs::path out_path = directory / "stdout.json";
146 const fs::path err_path = directory / "stderr.txt";
147 posix_spawn_file_actions_t actions;
148 int code = posix_spawn_file_actions_init(&actions);
149 if (code != 0)
150 return absl::InternalError("Cannot initialize graphics publisher process");
151 code = posix_spawn_file_actions_addopen(&actions, STDIN_FILENO, "/dev/null",
152 O_RDONLY, 0);
153 if (code == 0)
154 code = posix_spawn_file_actions_addopen(&actions, STDOUT_FILENO,
155 out_path.c_str(),
156 O_WRONLY | O_CREAT | O_TRUNC, 0600);
157 if (code == 0)
158 code = posix_spawn_file_actions_addopen(&actions, STDERR_FILENO,
159 err_path.c_str(),
160 O_WRONLY | O_CREAT | O_TRUNC, 0600);
161 std::vector<char*> argv;
162 for (const auto& argument : arguments)
163 argv.push_back(const_cast<char*>(argument.c_str()));
164 argv.push_back(nullptr);
165 pid_t pid = -1;
166 if (code == 0)
167 code = posix_spawnp(&pid, argv.front(), &actions, nullptr, argv.data(),
168 environ);
169 posix_spawn_file_actions_destroy(&actions);
170 if (code != 0)
171 return absl::UnavailableError("Cannot launch graphics publisher: " +
172 std::string(std::strerror(code)));
173 int status = 0;
174 // Publication spans several files with rollback. Never terminate its worker
175 // while it may own rollback copies or be between source and pack writes.
176 pid_t waited;
177 do {
178 waited = waitpid(pid, &status, 0);
179 } while (waited < 0 && errno == EINTR);
180 if (waited < 0)
181 return absl::InternalError("Cannot wait for graphics publisher");
182 auto output = Read(out_path);
183 if (!output.ok())
184 return output.status();
185 auto result = Parse(*output);
186 if (!result.ok()) {
187 auto error = Read(err_path);
188 return absl::InternalError(
189 "Graphics publisher returned invalid JSON" +
190 (error.ok() && !error->empty() ? ": " + error->substr(0, 4096) : ""));
191 }
192 if (!result->is_object() || !result->contains("result") ||
193 !result->at("result").is_string())
194 return absl::DataLossError(
195 "Graphics publisher returned an invalid result object");
196 if (!WIFEXITED(status) || WEXITSTATUS(status) != 0 ||
197 result->at("result") == "error") {
198 return absl::FailedPreconditionError(
199 result->contains("error") && result->at("error").is_string()
200 ? result->at("error").get<std::string>()
201 : "Graphics publisher failed");
202 }
203 return result;
204#endif
205}
206} // namespace
207
209 fs::path directory;
210 fs::path replacement;
211 fs::path plan;
213 std::string plan_sha256;
215 if (!directory.empty()) {
216 std::error_code ignored;
217 fs::remove_all(directory, ignored);
218 }
219 }
220};
221
225 default;
227 ProjectGraphicsSource&&) noexcept = default;
228
229absl::Status ProjectGraphicsSource::Load(const std::string& source_root,
230 const std::string& portable_catalog,
231 int parent_map) {
232 *this = ProjectGraphicsSource();
233 if (source_root.empty() || portable_catalog.empty())
234 return absl::FailedPreconditionError(
235 "Bind an Oracle source repository and project graphics catalog first");
236 auto root = Resolve(source_root);
237 if (!root.ok())
238 return root.status();
239 auto portable_path = Resolve(portable_catalog);
240 if (!portable_path.ok())
241 return portable_path.status();
242 auto canonical_path = Resolve(*root / "Overworld/graphics_catalog.json");
243 if (!canonical_path.ok())
244 return canonical_path.status();
245 std::map<std::string, std::string> hashes;
246 try {
247 auto canonical = LoadCatalog(*canonical_path, &hashes);
248 if (!canonical.ok())
249 return canonical.status();
250 auto portable = LoadCatalog(*portable_path, &hashes);
251 if (!portable.ok())
252 return portable.status();
253 if (Semantics(*canonical) != Semantics(*portable))
254 return absl::FailedPreconditionError(
255 "Source and portable graphics catalogs differ; rebuild or rebind the "
256 "project pack");
257 std::string selected_profile;
258 std::map<std::string, std::vector<int>> parents_by_profile;
259 for (const auto& binding : canonical->at("bindings")) {
260 const auto profile = binding.at("profile_id").get<std::string>();
261 for (int parent : binding.at("parent_maps")) {
262 parents_by_profile[profile].push_back(parent);
263 if (parent == parent_map)
264 selected_profile = profile;
265 }
266 }
267 if (selected_profile.empty())
268 return absl::NotFoundError(
269 "This parent map has no project graphics source profile");
270 std::set<std::string> bound_resources;
271 for (const auto& transfer :
272 canonical->at("profiles").at(selected_profile).at("transfers"))
273 bound_resources.insert(transfer.at("resource_id").get<std::string>());
274 std::vector<Resource> resources;
275 for (const auto& id : bound_resources) {
276 const auto& entry = canonical->at("resources").at(id);
277 Resource resource;
278 resource.id = id;
279 auto source_path = Resolve(canonical_path->parent_path() /
280 entry.at("path").get<std::string>());
281 auto pack_path = Resolve(
282 portable_path->parent_path() /
283 portable->at("resources").at(id).at("path").get<std::string>());
284 if (!source_path.ok())
285 return source_path.status();
286 if (!pack_path.ok())
287 return pack_path.status();
288 resource.source_path = source_path->string();
289 resource.portable_path = pack_path->string();
290 const auto identity = hashes.find(resource.source_path);
291 if (identity == hashes.end())
292 return absl::FailedPreconditionError(
293 "Graphics resource path changed while loading; rebind the source "
294 "document");
295 resource.sha256 = identity->second;
296 auto bytes = Read(*source_path);
297 if (!bytes.ok())
298 return bytes.status();
299 if (core::ComputeSourceArtifactSha256(*bytes) != resource.sha256)
300 return absl::FailedPreconditionError(
301 "Graphics source changed while loading; reload the source "
302 "document");
303 resource.original_bytes.assign(bytes->begin(), bytes->end());
304 resource.pending_bytes = resource.original_bytes;
305 auto decoded =
307 if (!decoded.ok())
308 return decoded.status();
309 for (const auto& [tile, pixels] : *decoded)
310 resource.tiles.push_back(pixels);
311 std::set<int> affected_parents;
312 for (const auto& [profile_id, profile] :
313 canonical->at("profiles").items()) {
314 bool used = false;
315 for (const auto& transfer : profile.at("transfers")) {
316 if (transfer.at("resource_id") != id)
317 continue;
318 used = true;
319 Slice slice;
320 slice.profile_id = profile_id;
321 slice.parent_maps = parents_by_profile[profile_id];
322 slice.source_offset = transfer.value("source_offset", size_t{0});
323 slice.byte_count = transfer.at("byte_count").get<size_t>();
324 slice.vram_word_address = transfer.at("vram_word_address").get<int>();
326 profile.at("bg_character_base_word").get<int>();
327 resource.slices.push_back(std::move(slice));
328 }
329 if (used) {
330 resource.profile_ids.push_back(profile_id);
331 const auto& parents = parents_by_profile[profile_id];
332 affected_parents.insert(parents.begin(), parents.end());
333 }
334 }
335 resource.parent_maps.assign(affected_parents.begin(),
336 affected_parents.end());
337 resources.push_back(std::move(resource));
338 }
339 source_root_ = root->string();
340 portable_catalog_ = portable_path->string();
341 parent_map_ = parent_map;
342 profile_id_ = selected_profile;
343 resources_ = std::move(resources);
344 histories_.resize(resources_.size());
345 loaded_hashes_ = std::move(hashes);
346 auto status = CheckUnchanged();
347 if (!status.ok())
348 *this = ProjectGraphicsSource();
349 return status;
350 } catch (const Json::exception& error) {
351 *this = ProjectGraphicsSource();
352 return absl::InvalidArgumentError(
353 std::string("Invalid graphics source catalog: ") + error.what());
354 }
355}
356
359 return selected_ < resources_.size() ? &resources_[selected_] : nullptr;
360}
361
363#if defined(_WIN32) || defined(__EMSCRIPTEN__)
364 return false;
365#else
366 std::error_code error;
367 return !source_root_.empty() &&
368 fs::is_regular_file(fs::path(source_root_) /
369 "Scripts/Graphics/edit_graphics_source.py",
370 error);
371#endif
372}
373
374absl::Status ProjectGraphicsSource::SelectResource(const std::string& id) {
375 for (size_t index = 0; index < resources_.size(); ++index) {
376 if (resources_[index].id == id) {
377 if (selected_ != index)
379 selected_ = index;
380 return absl::OkStatus();
381 }
382 }
383 return absl::NotFoundError(
384 "Resource is not bound to this graphics profile: " + id);
385}
386
387absl::Status ProjectGraphicsSource::CheckPixel(size_t tile, int x,
388 int y) const {
389 const auto* resource = selected_resource();
390 if (!resource)
391 return absl::FailedPreconditionError(
392 "Load a project graphics resource first");
393 if (tile >= resource->tiles.size() || x < 0 || x >= 8 || y < 0 || y >= 8)
394 return absl::OutOfRangeError(
395 "Graphics source pixel is outside its resource");
396 return absl::OkStatus();
397}
398
399absl::StatusOr<uint8_t> ProjectGraphicsSource::GetPixel(size_t tile, int x,
400 int y) const {
401 auto status = CheckPixel(tile, x, y);
402 if (!status.ok())
403 return status;
404 return resources_[selected_].tiles[tile][y * 8 + x];
405}
406
407void ProjectGraphicsSource::WritePixel(size_t tile, int x, int y,
408 uint8_t index) {
409 auto& resource = resources_[selected_];
410 resource.tiles[tile][y * 8 + x] = index;
411 for (int plane = 0; plane < 4; ++plane) {
412 const size_t offset = tile * 32 + (plane / 2) * 16 + y * 2 + plane % 2;
413 const uint8_t mask = 1 << (7 - x);
414 auto& byte = resource.pending_bytes[offset];
415 byte = (byte & ~mask) | ((index & (1 << plane)) ? mask : 0);
416 }
418}
419
420absl::Status ProjectGraphicsSource::SetPixel(size_t tile, int x, int y,
421 uint8_t index) {
422 auto before = GetPixel(tile, x, y);
423 if (!before.ok())
424 return before.status();
425 if (index > 15)
426 return absl::InvalidArgumentError(
427 "SNES 4bpp color index must be between 0 and 15");
428 if (*before == index)
429 return absl::OkStatus();
430 histories_[selected_].undo.push_back({tile, x, y, *before, index});
431 histories_[selected_].redo.clear();
432 WritePixel(tile, x, y, index);
433 return absl::OkStatus();
434}
435
437 if (!can_undo())
438 return absl::FailedPreconditionError("No graphics source edit to undo");
439 auto& history = histories_[selected_];
440 const auto action = history.undo.back();
441 history.undo.pop_back();
442 history.redo.push_back(action);
443 WritePixel(action.tile, action.x, action.y, action.before);
444 return absl::OkStatus();
445}
446
448 if (!can_redo())
449 return absl::FailedPreconditionError("No graphics source edit to redo");
450 auto& history = histories_[selected_];
451 const auto action = history.redo.back();
452 history.redo.pop_back();
453 history.undo.push_back(action);
454 WritePixel(action.tile, action.x, action.y, action.after);
455 return absl::OkStatus();
456}
457
459 return selected_ < histories_.size() && !histories_[selected_].undo.empty();
460}
462 return selected_ < histories_.size() && !histories_[selected_].redo.empty();
463}
465 const auto* resource = selected_resource();
466 return resource && resource->dirty();
467}
469 return std::any_of(resources_.begin(), resources_.end(),
470 [](const Resource& resource) { return resource.dirty(); });
471}
472
474 const std::vector<uint8_t>& bytes) {
475 return ReplaceResourceBytesAt(selected_, bytes);
476}
477
479 const std::string& source_path, const std::vector<uint8_t>& bytes) {
480 if (!loaded())
481 return absl::FailedPreconditionError(
482 "Load a project graphics resource first");
483 for (size_t index = 0; index < resources_.size(); ++index) {
484 if (resources_[index].source_path == source_path)
485 return ReplaceResourceBytesAt(index, bytes);
486 }
487 return absl::NotFoundError(
488 "Source path is not bound to this graphics profile: " + source_path);
489}
490
492 size_t index, const std::vector<uint8_t>& bytes) {
493 if (index >= resources_.size())
494 return absl::FailedPreconditionError(
495 "Load a project graphics resource first");
496 auto& resource = resources_[index];
497 if (bytes.empty() || bytes.size() % 32 != 0 ||
498 bytes.size() != resource.original_bytes.size())
499 return absl::InvalidArgumentError(
500 "Replacement must preserve the resource's aligned 4bpp byte count");
501 auto status = CheckUnchanged();
502 if (!status.ok())
503 return status;
504 auto decoded = zelda3::DecodeGraphicsPreviewTransfer(bytes, 0, 0);
505 if (!decoded.ok())
506 return decoded.status();
507 if (bytes == resource.pending_bytes)
508 return absl::OkStatus();
509
510 // Prepare both representations before changing the document. Swaps commit
511 // the byte snapshot and decoded pixels together after validation succeeds.
512 std::vector<uint8_t> replacement(bytes);
513 std::vector<zelda3::PreviewTile8> tiles;
514 tiles.reserve(decoded->size());
515 for (const auto& [character, pixels] : *decoded)
516 tiles.push_back(pixels);
517 resource.pending_bytes.swap(replacement);
518 resource.tiles.swap(tiles);
519 histories_[index].undo.clear();
520 histories_[index].redo.clear();
522 return absl::OkStatus();
523}
524
525absl::StatusOr<zelda3::OverworldGraphicsPreview>
527 if (!loaded())
528 return absl::FailedPreconditionError(
529 "Load a project graphics resource first");
530 auto status = CheckUnchanged();
531 if (!status.ok())
532 return status;
534 for (const auto& resource : resources_) {
535 for (const auto& slice : resource.slices) {
536 if (slice.source_offset > resource.pending_bytes.size() ||
537 slice.byte_count >
538 resource.pending_bytes.size() - slice.source_offset)
539 return absl::InvalidArgumentError(
540 "Pending graphics resource slice is outside its resource");
542 std::span<const uint8_t>(resource.pending_bytes)
543 .subspan(slice.source_offset, slice.byte_count),
544 slice.vram_word_address, slice.bg_character_base_word);
545 if (!decoded.ok())
546 return decoded.status();
547 for (int parent : slice.parent_maps) {
548 auto& area = result[parent];
549 for (const auto& [character, pixels] : *decoded) {
550 if (!area.emplace(character, pixels).second)
551 return absl::InvalidArgumentError(
552 "Overlapping pending graphics preview transfers");
553 }
554 }
555 }
556 }
557 return result;
558}
559
564
566 for (const auto& [path, hash] : loaded_hashes_) {
567 auto bytes = Read(path);
568 if (!bytes.ok())
569 return bytes.status();
570 if (core::ComputeSourceArtifactSha256(*bytes) != hash)
571 return absl::FailedPreconditionError(
572 "Graphics source changed on disk; keep the draft and reload or "
573 "rebind before publication: " +
574 path);
575 }
576 return absl::OkStatus();
577}
578
579absl::StatusOr<Json> ProjectGraphicsSource::Preview() {
581 if (!dirty())
582 return absl::FailedPreconditionError(
583 "The selected graphics resource has no pending changes");
584 if (!backend_available())
585 return absl::FailedPreconditionError(
586 "The bound source repository has no supported graphics publisher at "
587 "Scripts/Graphics/edit_graphics_source.py");
588 auto status = CheckUnchanged();
589 if (!status.ok())
590 return status;
591 auto files = std::make_unique<PreviewFiles>();
592 std::error_code error;
593 const auto temporary_root = fs::temp_directory_path(error);
594 if (error)
595 return absl::InternalError(
596 "Cannot locate a private graphics preview directory");
597 const auto temp = fs::canonical(temporary_root, error);
598 if (error)
599 return absl::InternalError(
600 "Cannot resolve the private graphics preview directory");
601 static std::atomic<uint64_t> sequence{0};
602 for (int attempt = 0; attempt < 100; ++attempt) {
603 const auto candidate =
604 temp /
605 ("yaze-graphics-source-" +
606 std::to_string(
607 std::chrono::steady_clock::now().time_since_epoch().count()) +
608 "-" + std::to_string(sequence++));
609 if (fs::create_directory(candidate, error)) {
610 files->directory = candidate;
611 break;
612 }
613 if (error && error != std::errc::file_exists)
614 return absl::InternalError(
615 "Cannot create a private graphics preview directory");
616 }
617 if (files->directory.empty())
618 return absl::AlreadyExistsError(
619 "Cannot reserve a private graphics preview directory");
620 fs::permissions(files->directory, fs::perms::owner_all, error);
621 if (error)
622 return absl::InternalError(
623 "Cannot protect the private graphics preview directory");
624 files->replacement = files->directory / "replacement.4bpp";
625 files->plan = files->directory / "review-plan.json";
626 const auto& resource = resources_[selected_];
627 status = Write(files->replacement, resource.pending_bytes);
628 if (!status.ok())
629 return status;
630 files->replacement_sha256 = Hash(resource.pending_bytes);
631 auto result = RunPublisher(
632 {"python3", "-B",
633 (fs::path(source_root_) / "Scripts/Graphics/edit_graphics_source.py")
634 .string(),
635 "plan", "--source-root", source_root_, "--portable-catalog",
636 portable_catalog_, "--resource-id", resource.id, "--replacement",
637 files->replacement.string(), "--plan", files->plan.string()},
638 files->directory);
639 if (!result.ok())
640 return result.status();
641 auto plan_bytes = Read(files->plan);
642 if (!plan_bytes.ok())
643 return plan_bytes.status();
644 auto plan = Parse(*plan_bytes);
645 if (!plan.ok())
646 return plan.status();
647 try {
648 if (*plan != *result || plan->at("operation") != "plan" ||
649 plan->at("result") != "ok" ||
650 plan->at("before_resource_id") != resource.id ||
651 plan->at("resource_sha256") != resource.sha256 ||
652 plan->at("replacement_sha256") != files->replacement_sha256 ||
653 plan->at("source_root") != source_root_ ||
654 plan->at("portable_catalog") != portable_catalog_ ||
655 plan->at("replacement_path") != files->replacement.string() ||
656 !plan->at("after_resource_id").is_string())
657 return absl::DataLossError(
658 "Graphics publisher plan does not match the selected draft");
659 } catch (const Json::exception&) {
660 return absl::DataLossError(
661 "Graphics publisher returned an incomplete review plan");
662 }
663 status = CheckUnchanged();
664 if (!status.ok())
665 return status;
666 files->plan_sha256 = core::ComputeSourceArtifactSha256(*plan_bytes);
667 preview_json_ = std::move(*plan);
668 preview_ = std::move(files);
669 return preview_json_;
670}
671
672absl::StatusOr<Json> ProjectGraphicsSource::Publish() {
673 if (!preview_)
674 return absl::FailedPreconditionError(
675 "Preview this graphics source draft before publication");
676 auto status = CheckUnchanged();
677 if (!status.ok())
678 return status;
679 auto replacement = Read(preview_->replacement);
680 auto plan = Read(preview_->plan);
681 if (!replacement.ok())
682 return replacement.status();
683 if (!plan.ok())
684 return plan.status();
685 if (core::ComputeSourceArtifactSha256(*replacement) !=
686 preview_->replacement_sha256 ||
687 core::ComputeSourceArtifactSha256(*plan) != preview_->plan_sha256)
688 return absl::FailedPreconditionError(
689 "Graphics review plan changed; preview the draft again");
690 auto result = RunPublisher(
691 {"python3", "-B",
692 (fs::path(source_root_) / "Scripts/Graphics/edit_graphics_source.py")
693 .string(),
694 "apply", "--source-root", source_root_, "--portable-catalog",
695 portable_catalog_, "--resource-id", selected_resource()->id, "--plan",
696 preview_->plan.string(), "--expected-plan-sha256",
697 preview_->plan_sha256},
698 preview_->directory);
699 if (!result.ok())
700 return result.status();
701 if (!result->contains("operation") || result->at("operation") != "apply" ||
702 result->at("result") != "published" ||
703 !result->contains("before_resource_id") ||
704 result->at("before_resource_id") != selected_resource()->id ||
705 !result->contains("after_resource_id") ||
706 result->at("after_resource_id") != preview_json_.at("after_resource_id"))
707 return absl::DataLossError(
708 "Graphics publisher did not confirm publication");
709
710 // Reload the published identity while retaining independent drafts whose
711 // original resource bytes are still unchanged by this publication.
712 ProjectGraphicsSource refreshed;
713 status = refreshed.Load(source_root_, portable_catalog_, parent_map_);
714 if (!status.ok())
715 return absl::DataLossError(
716 "Graphics source published, but reload failed: " +
717 std::string(status.message()));
718 for (size_t old = 0; old < resources_.size(); ++old) {
719 if (old == selected_ || !resources_[old].dirty())
720 continue;
721 bool retained = false;
722 for (size_t next = 0; next < refreshed.resources_.size(); ++next) {
723 if (refreshed.resources_[next].id == resources_[old].id &&
724 refreshed.resources_[next].original_bytes ==
725 resources_[old].original_bytes) {
726 refreshed.resources_[next].pending_bytes =
727 resources_[old].pending_bytes;
728 refreshed.resources_[next].tiles = resources_[old].tiles;
729 refreshed.histories_[next] = histories_[old];
730 retained = true;
731 }
732 }
733 if (!retained)
734 return absl::DataLossError(
735 "Graphics source published, but another pending resource changed; "
736 "keep the draft and rebind");
737 }
738 const auto after_id =
739 preview_json_.at("after_resource_id").get<std::string>();
740 status = refreshed.SelectResource(after_id);
741 if (!status.ok())
742 return absl::DataLossError(
743 "Graphics source published, but new resource identity is missing");
744 if (refreshed.selected_resource()->original_bytes !=
746 return absl::DataLossError(
747 "Graphics source published, but replacement bytes did not match on "
748 "reload");
749 // Saving changes the dirty baseline, not the user's pixel-edit history.
750 refreshed.histories_[refreshed.selected_] = histories_[selected_];
751 *this = std::move(refreshed);
752 return result;
753}
754
755} // namespace yaze::editor
const char * what() const noexcept override
Definition json.h:96
static Json parse(const std::string &)
Definition json.h:36
void WritePixel(size_t tile, int x, int y, uint8_t index)
absl::Status ReplaceResourceBytes(const std::vector< uint8_t > &bytes)
absl::StatusOr< zelda3::OverworldGraphicsPreview > PendingGraphicsPreview() const
absl::Status CheckPixel(size_t tile, int x, int y) const
std::unique_ptr< PreviewFiles > preview_
std::map< std::string, std::string > loaded_hashes_
absl::Status ReplaceResourceBytesAt(size_t resource, const std::vector< uint8_t > &bytes)
absl::StatusOr< nlohmann::json > Preview()
absl::Status Load(const std::string &source_root, const std::string &portable_catalog, int parent_map)
absl::Status SelectResource(const std::string &resource_id)
absl::StatusOr< uint8_t > GetPixel(size_t tile, int x, int y) const
absl::StatusOr< nlohmann::json > Publish()
absl::Status SetPixel(size_t tile, int x, int y, uint8_t index)
std::string ComputeSourceArtifactSha256(std::string_view content)
absl::StatusOr< zelda3::OverworldGraphicsPreview > LoadOverworldGraphicsPreview(const std::string &filename)
absl::StatusOr< fs::path > Resolve(const fs::path &path)
absl::StatusOr< Json > LoadCatalog(const fs::path &path, std::map< std::string, std::string > *hashes)
std::string Hash(const std::vector< uint8_t > &bytes)
absl::StatusOr< std::string > Read(const fs::path &path)
absl::StatusOr< Json > RunPublisher(const std::vector< std::string > &arguments, const fs::path &directory)
absl::Status Write(const fs::path &path, const std::vector< uint8_t > &bytes)
Editors are the view controllers for the application.
absl::StatusOr< AreaGraphicsPreview > DecodeGraphicsPreviewTransfer(std::span< const uint8_t > bytes, int destination_word, int bg_base_word)
std::map< int, AreaGraphicsPreview > OverworldGraphicsPreview
char ** environ