yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
menu_tilemap.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <filesystem>
5#include <fstream>
6#include <system_error>
7
8#include "absl/strings/str_format.h"
9#include "util/macro.h"
10
11namespace yaze::zelda3 {
12namespace {
13
14namespace fs = std::filesystem;
15
16absl::StatusOr<std::vector<uint8_t>> ReadWholeFile(const std::string& path) {
17 std::ifstream in(path, std::ios::binary | std::ios::ate);
18 if (!in) {
19 return absl::NotFoundError(
20 absl::StrFormat("could not open '%s' for reading", path));
21 }
22 std::streamsize size = in.tellg();
23 if (size < 0) {
24 return absl::InternalError(
25 absl::StrFormat("could not determine size of '%s'", path));
26 }
27 in.seekg(0, std::ios::beg);
28 std::vector<uint8_t> data(static_cast<size_t>(size));
29 if (size > 0 && !in.read(reinterpret_cast<char*>(data.data()), size)) {
30 return absl::InternalError(absl::StrFormat("short read on '%s'", path));
31 }
32 return data;
33}
34
35absl::Status WriteWholeFileAtomically(const std::string& path,
36 const std::vector<uint8_t>& data) {
37 fs::path dest(path);
38 fs::path tmp = dest;
39 tmp += ".tmp";
40 {
41 std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
42 if (!out) {
43 return absl::InternalError(
44 absl::StrFormat("could not open '%s' for writing", tmp.string()));
45 }
46 if (!data.empty() &&
47 !out.write(reinterpret_cast<const char*>(data.data()),
48 static_cast<std::streamsize>(data.size()))) {
49 return absl::InternalError(
50 absl::StrFormat("short write on '%s'", tmp.string()));
51 }
52 }
53 std::error_code ec;
54 fs::rename(tmp, dest, ec);
55 if (ec) {
56 // Cross-device or other rename failure: fall back to copy+remove so a
57 // save into a different filesystem (e.g. a mounted scratch dir) still
58 // works, still ending with a single well-formed destination file.
59 fs::copy_file(tmp, dest, fs::copy_options::overwrite_existing, ec);
60 if (ec) {
61 return absl::InternalError(
62 absl::StrFormat("could not move '%s' to '%s': %s", tmp.string(),
63 dest.string(), ec.message()));
64 }
65 std::error_code remove_ec;
66 fs::remove(tmp, remove_ec);
67 }
68 return absl::OkStatus();
69}
70
71} // namespace
72
73absl::Status MenuTilemapDocument::ValidateSize(size_t size) {
74 if (size == 0 || size % kBytesPerRow != 0) {
75 return absl::InvalidArgumentError(absl::StrFormat(
76 "menu tilemap size %zu is not a positive multiple of %d bytes "
77 "(32 words/row * 2 bytes/word)",
78 size, kBytesPerRow));
79 }
80 if (size > static_cast<size_t>(kMaxBytes)) {
81 return absl::InvalidArgumentError(
82 absl::StrFormat("menu tilemap size %zu exceeds the maximum of %d "
83 "bytes (32x32 words)",
84 size, kMaxBytes));
85 }
86 return absl::OkStatus();
87}
88
89absl::Status MenuTilemapDocument::LoadFromBytes(std::vector<uint8_t> bytes,
90 std::string source_path) {
91 absl::Status size_status = ValidateSize(bytes.size());
92 if (!size_status.ok()) {
93 return size_status;
94 }
95 bytes_ = std::move(bytes);
97 rows_ = static_cast<int>(bytes_.size() / kBytesPerRow);
98 path_ = std::move(source_path);
99 dirty_ = false;
100 backup_written_ = false;
101 backup_path_.clear();
102 return absl::OkStatus();
103}
104
105absl::Status MenuTilemapDocument::LoadFromFile(const std::string& path) {
106 ASSIGN_OR_RETURN(std::vector<uint8_t> bytes, ReadWholeFile(path));
107 return LoadFromBytes(std::move(bytes), path);
108}
109
111 if (path_.empty()) {
112 return absl::FailedPreconditionError(
113 "MenuTilemapDocument::Save() called with no path set; use SaveAs()");
114 }
115 return SaveAs(path_);
116}
117
118absl::Status MenuTilemapDocument::SaveAs(const std::string& path) {
119 // Saving must never change the file's byte size (Oracle's asm incbins a
120 // fixed-size buffer at a fixed VRAM offset). Since edits only ever go
121 // through SetCell/SetCellWord (which write into the existing bytes_
122 // buffer in place) bytes_.size() is invariant after load, so there's
123 // nothing further to check here -- WriteWholeFileAtomically() writes
124 // exactly bytes_.size() bytes.
125 if (bytes_.empty()) {
126 return absl::FailedPreconditionError(
127 "MenuTilemapDocument::SaveAs() called with no data loaded");
128 }
129
130 if (!backup_written_) {
131 fs::path backup = fs::path(path) += ".bak";
132 absl::Status backup_status =
133 WriteWholeFileAtomically(backup.string(), pristine_bytes_);
134 if (backup_status.ok()) {
135 backup_written_ = true;
136 backup_path_ = backup.string();
137 }
138 // A failed backup is not fatal to the save itself, but callers can
139 // check has_backup() to warn the user it wasn't captured.
140 }
141
142 RETURN_IF_ERROR(WriteWholeFileAtomically(path, bytes_));
143
144 path_ = path;
146 dirty_ = false;
147
148 return absl::OkStatus();
149}
150
152 if (path_.empty()) {
153 return absl::FailedPreconditionError(
154 "MenuTilemapDocument::Revert() called with no path set");
155 }
156 std::string path = path_;
157 bool had_backup = backup_written_;
158 std::string backup_path = backup_path_;
160 // Preserve backup bookkeeping across Revert(): the pristine copy already
161 // on disk from *this session's* first save should not be re-taken (and
162 // would otherwise silently overwrite itself with the now-current file).
163 backup_written_ = had_backup;
165 return absl::OkStatus();
166}
167
169 if (path_.empty()) {
170 return absl::FailedPreconditionError(
171 "MenuTilemapDocument has no associated path");
172 }
173 std::error_code ec;
174 if (!fs::exists(path_, ec) || ec) {
175 return absl::NotFoundError(absl::StrFormat("'%s' no longer exists", path_));
176 }
177 // Compare content, not stat() data. A same-size rewrite that lands in the
178 // same filesystem timestamp tick leaves size and mtime untouched (seen on
179 // the Ubuntu CI runner), and a touch that changes only the mtime is not a
180 // change. The baseline is pristine_bytes_ -- what this document last read
181 // from or wrote to the file -- not bytes_, so unsaved in-memory edits never
182 // look like an external change. Files are at most 2 KB.
183 ASSIGN_OR_RETURN(std::vector<uint8_t> on_disk, ReadWholeFile(path_));
184 return on_disk != pristine_bytes_;
185}
186
188 if (path_.empty()) {
189 return absl::FailedPreconditionError(
190 "MenuTilemapDocument has no associated path");
191 }
192 ASSIGN_OR_RETURN(std::vector<uint8_t> on_disk, ReadWholeFile(path_));
193 pristine_bytes_ = std::move(on_disk);
195 return absl::OkStatus();
196}
197
198absl::Status MenuTilemapDocument::RestoreBytes(std::vector<uint8_t> bytes) {
199 if (!loaded()) {
200 return absl::FailedPreconditionError(
201 "MenuTilemapDocument::RestoreBytes() called with no data loaded");
202 }
203 if (bytes.size() != bytes_.size()) {
204 return absl::InvalidArgumentError(absl::StrFormat(
205 "RestoreBytes size %zu does not match the loaded size %zu",
206 bytes.size(), bytes_.size()));
207 }
208 bytes_ = std::move(bytes);
209 // Dirty means "differs from the file as last loaded/saved", so undoing
210 // back to that state is clean and redoing away from it is dirty again.
212 return absl::OkStatus();
213}
214
216 int r0 = std::max(0, rect.row);
217 int c0 = std::max(0, rect.col);
218 int r1 = std::min(rows_, rect.row + rect.rows);
219 int c1 = std::min(kCols, rect.col + rect.cols);
220 rect.row = r0;
221 rect.col = c0;
222 rect.rows = std::max(0, r1 - r0);
223 rect.cols = std::max(0, c1 - c0);
224}
225
227 return gfx::WordToTileInfo(GetCellWord(row, col));
228}
229
230uint16_t MenuTilemapDocument::GetCellWord(int row, int col) const {
231 if (!InBounds(row, col))
232 return 0;
233 size_t offset = static_cast<size_t>(row) * kBytesPerRow + col * 2;
234 return static_cast<uint16_t>(bytes_[offset] | (bytes_[offset + 1] << 8));
235}
236
237bool MenuTilemapDocument::SetCell(int row, int col, gfx::TileInfo info) {
238 return SetCellWord(row, col, gfx::TileInfoToWord(info));
239}
240
241bool MenuTilemapDocument::SetCellWord(int row, int col, uint16_t word) {
242 if (!InBounds(row, col))
243 return false;
244 size_t offset = static_cast<size_t>(row) * kBytesPerRow + col * 2;
245 uint8_t lo = static_cast<uint8_t>(word & 0xFF);
246 uint8_t hi = static_cast<uint8_t>((word >> 8) & 0xFF);
247 if (bytes_[offset] == lo && bytes_[offset + 1] == hi) {
248 return true; // no-op write; don't mark dirty
249 }
250 bytes_[offset] = lo;
251 bytes_[offset + 1] = hi;
252 dirty_ = true;
253 return true;
254}
255
257 ClipRect(rect);
258 Clipboard clip;
259 clip.rows = rect.rows;
260 clip.cols = rect.cols;
261 clip.cells.reserve(static_cast<size_t>(rect.rows) * rect.cols);
262 for (int r = 0; r < rect.rows; ++r) {
263 for (int c = 0; c < rect.cols; ++c) {
264 clip.cells.push_back(GetCell(rect.row + r, rect.col + c));
265 }
266 }
267 return clip;
268}
269
270int MenuTilemapDocument::PasteRect(int row, int col, const Clipboard& clip) {
271 int touched = 0;
272 for (int r = 0; r < clip.rows; ++r) {
273 for (int c = 0; c < clip.cols; ++c) {
274 if (SetCell(row + r, col + c, clip.cells[r * clip.cols + c])) {
275 if (InBounds(row + r, col + c))
276 touched++;
277 }
278 }
279 }
280 return touched;
281}
282
284 ClipRect(rect);
285 int touched = 0;
286 for (int r = 0; r < rect.rows; ++r) {
287 for (int c = 0; c < rect.cols; ++c) {
288 if (SetCell(rect.row + r, rect.col + c, info))
289 touched++;
290 }
291 }
292 return touched;
293}
294
295int MenuTilemapDocument::EraseRect(Rect rect, uint16_t erase_word) {
296 ClipRect(rect);
297 int touched = 0;
298 for (int r = 0; r < rect.rows; ++r) {
299 for (int c = 0; c < rect.cols; ++c) {
300 if (SetCellWord(rect.row + r, rect.col + c, erase_word))
301 touched++;
302 }
303 }
304 return touched;
305}
306
308 const ChrPixelFn& chr) const {
309 int width = render_width();
310 int height = render_height();
311 std::vector<uint8_t> out(static_cast<size_t>(width) * height, 0);
312 if (!chr)
313 return out;
314 for (int row = 0; row < rows_; ++row) {
315 for (int col = 0; col < kCols; ++col) {
316 gfx::TileInfo info = GetCell(row, col);
317 int base_x = col * 8;
318 int base_y = row * 8;
319 for (int y = 0; y < 8; ++y) {
320 int sy = info.vertical_mirror_ ? 7 - y : y;
321 for (int x = 0; x < 8; ++x) {
322 int sx = info.horizontal_mirror_ ? 7 - x : x;
323 uint8_t color = chr(info.id_, sx, sy) & 0x03;
324 uint8_t index =
325 static_cast<uint8_t>((info.palette_ & 0x07) * 4 + color);
326 size_t out_idx =
327 static_cast<size_t>(base_y + y) * width + (base_x + x);
328 out[out_idx] = index;
329 }
330 }
331 }
332 }
333 return out;
334}
335
336std::vector<uint8_t> ComposeMenuTilemapRgba(
337 const std::vector<uint8_t>& indexed, int width, int height,
338 const std::array<gfx::SnesColor, 32>& subpalette_colors) {
339 std::vector<uint8_t> rgba(static_cast<size_t>(width) * height * 4, 0);
340 for (size_t i = 0; i < indexed.size() && i * 4 + 3 < rgba.size(); ++i) {
341 uint8_t idx = indexed[i];
342 uint8_t color_in_subpalette = idx & 0x03;
343 size_t out = i * 4;
344 if (color_in_subpalette == 0) {
345 rgba[out + 0] = 0;
346 rgba[out + 1] = 0;
347 rgba[out + 2] = 0;
348 rgba[out + 3] = 0; // transparent
349 continue;
350 }
351 const auto& color = subpalette_colors[idx];
352 auto rgb = color.rgb();
353 rgba[out + 0] = static_cast<uint8_t>(rgb.x);
354 rgba[out + 1] = static_cast<uint8_t>(rgb.y);
355 rgba[out + 2] = static_cast<uint8_t>(rgb.z);
356 rgba[out + 3] = 255;
357 }
358 return rgba;
359}
360
362 const std::vector<uint8_t>& sheet_8bpp_indexed) {
363 return
364 [&sheet_8bpp_indexed](int tile_id, int local_x, int local_y) -> uint8_t {
365 if (tile_id < 0 || local_x < 0 || local_x >= 8 || local_y < 0 ||
366 local_y >= 8) {
367 return 0;
368 }
369 constexpr int kTilesPerChunk = 128;
370 constexpr int kTilesPerChunkRow = 16;
371 constexpr int kChunkWidth = 128;
372 constexpr size_t kChunkBytes = 8192; // 128 * 64
373 int chunk = tile_id / kTilesPerChunk;
374 int local_tile = tile_id % kTilesPerChunk;
375 int tile_col = local_tile % kTilesPerChunkRow;
376 int tile_row = local_tile / kTilesPerChunkRow;
377 size_t base = static_cast<size_t>(chunk) * kChunkBytes;
378 size_t idx = base +
379 static_cast<size_t>(tile_row * 8 + local_y) * kChunkWidth +
380 (tile_col * 8 + local_x);
381 if (idx >= sheet_8bpp_indexed.size())
382 return 0;
383 return sheet_8bpp_indexed[idx] & 0x03;
384 };
385}
386
387} // namespace yaze::zelda3
SNES 16-bit tile metadata container.
Definition snes_tile.h:52
std::vector< uint8_t > RenderIndexed(const ChrPixelFn &chr) const
absl::Status LoadFromBytes(std::vector< uint8_t > bytes, std::string source_path="")
absl::StatusOr< bool > ExternalChangeDetected() const
bool SetCellWord(int row, int col, uint16_t word)
static constexpr int kBytesPerRow
bool SetCell(int row, int col, gfx::TileInfo info)
std::function< uint8_t(int tile_id, int local_x, int local_y)> ChrPixelFn
Clipboard CopyRect(Rect rect) const
std::vector< uint8_t > pristine_bytes_
static constexpr int kMaxBytes
absl::Status LoadFromFile(const std::string &path)
std::vector< uint8_t > bytes_
int FillRect(Rect rect, gfx::TileInfo info)
static absl::Status ValidateSize(size_t size)
const std::string & backup_path() const
absl::Status RestoreBytes(std::vector< uint8_t > bytes)
gfx::TileInfo GetCell(int row, int col) const
uint16_t GetCellWord(int row, int col) const
int EraseRect(Rect rect, uint16_t erase_word=0x0000)
bool InBounds(int row, int col) const
int PasteRect(int row, int col, const Clipboard &clip)
absl::Status SaveAs(const std::string &path)
void ClipRect(Rect &rect) const
const std::string & path() const
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
uint16_t TileInfoToWord(TileInfo tile_info)
Definition snes_tile.cc:362
TileInfo WordToTileInfo(uint16_t word)
Definition snes_tile.cc:379
absl::Status WriteWholeFileAtomically(const std::string &path, const std::vector< uint8_t > &data)
absl::StatusOr< std::vector< uint8_t > > ReadWholeFile(const std::string &path)
Zelda 3 specific classes and functions.
MenuTilemapDocument::ChrPixelFn MakeChrPixelFn(const std::vector< uint8_t > &sheet_8bpp_indexed)
std::vector< uint8_t > ComposeMenuTilemapRgba(const std::vector< uint8_t > &indexed, int width, int height, const std::array< gfx::SnesColor, 32 > &subpalette_colors)
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22