yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
sprite_catalog.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <filesystem>
5#include <fstream>
6#include <set>
7#include <sstream>
8#include <stdexcept>
9
10#include "absl/status/status.h"
11#include "absl/strings/strip.h"
12#include "nlohmann/json.hpp"
13
14namespace yaze::zelda3 {
15namespace {
16using nlohmann::json;
17constexpr size_t kMaxDocumentBytes = 1024 * 1024;
18
19absl::StatusOr<std::string> ReadDocument(const std::string& path) {
20 std::error_code ec;
21 if (!std::filesystem::is_regular_file(path, ec) || ec) {
22 return absl::NotFoundError("Source file is unavailable: " + path);
23 }
24 std::ifstream stream(path, std::ios::binary);
25 if (!stream)
26 return absl::NotFoundError("Cannot open source file: " + path);
27 std::string text(kMaxDocumentBytes + 1, '\0');
28 stream.read(text.data(), text.size());
29 text.resize(static_cast<size_t>(stream.gcount()));
30 if (stream.bad())
31 return absl::DataLossError("Cannot read source file: " + path);
32 if (text.size() > kMaxDocumentBytes) {
33 return absl::ResourceExhaustedError("Source file exceeds 1 MiB: " + path);
34 }
35 return text;
36}
37
38bool SafeRelativePath(const std::string& value) {
39 if (value.empty() || value.find('\0') != std::string::npos ||
40 value.find('\\') != std::string::npos ||
41 value.find(':') != std::string::npos)
42 return false;
43 const std::filesystem::path path(value);
44 // On Windows, a POSIX-style path such as "/outside.asm" has a
45 // root-directory but no root-name, so is_absolute() reports false; check
46 // has_root_directory() too so it is still rejected there.
47 if (path.is_absolute() || path.has_root_name() || path.has_root_directory())
48 return false;
49 for (const auto& part : path) {
50 if (part == "..")
51 return false;
52 }
53 return true;
54}
55
56bool IsIdentifier(const std::string& value) {
57 return !value.empty() && value.find_first_not_of(
58 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTU"
59 "VWXYZ0123456789_.-") == std::string::npos;
60}
61
62// Unknown fields are rejected so an unsupported selector cannot silently degrade
63// into the v1 exact-value/fallback semantics. The catalog has no save operation.
64void CheckFields(const json& object,
65 std::initializer_list<const char*> fields) {
66 if (!object.is_object())
67 throw std::runtime_error("Expected an object");
68 for (const auto& item : object.items()) {
69 if (std::none_of(fields.begin(), fields.end(),
70 [&](const char* field) { return item.key() == field; })) {
71 throw std::runtime_error("Unsupported field: " + item.key());
72 }
73 }
74}
75
76int ReadInteger(const json& value, int min, int max) {
77 if (!value.is_number_integer() || value < min || value > max) {
78 throw std::runtime_error("Integer outside supported range");
79 }
80 return value.get<int>();
81}
82
83std::string ReadString(const json& object, const char* key, bool identifier) {
84 auto value = object.at(key).get<std::string>();
85 if (value.empty() || value.find('\0') != std::string::npos ||
86 (identifier && !IsIdentifier(value))) {
87 throw std::runtime_error(std::string("Invalid ") + key);
88 }
89 return value;
90}
91
92std::vector<SpriteSourceBinding> ReadBindings(const json& array) {
93 if (!array.is_array() || array.empty()) {
94 throw std::runtime_error("Source bindings must be a nonempty array");
95 }
96 std::set<std::string> roles;
97 std::vector<SpriteSourceBinding> bindings;
98 for (const auto& source : array) {
99 CheckFields(source, {"role", "path", "label", "adapter"});
100 SpriteSourceBinding binding{ReadString(source, "role", true),
101 ReadString(source, "path", false),
102 ReadString(source, "label", true)};
103 if (source.contains("adapter")) {
104 binding.adapter = source.at("adapter").get<std::string>();
105 if (binding.role != "draw" || (binding.adapter != "literal_v1" &&
106 binding.adapter != "oracle_maple_v1"))
107 throw std::runtime_error("Unsupported draw adapter");
108 }
109 if (!SafeRelativePath(binding.path) || !roles.insert(binding.role).second) {
110 throw std::runtime_error("Unsafe source path or duplicate source role");
111 }
112 bindings.push_back(std::move(binding));
113 }
114 return bindings;
115}
116} // namespace
117
118absl::StatusOr<SpriteCatalog> SpriteCatalog::Parse(absl::string_view text) {
119 if (text.size() > kMaxDocumentBytes) {
120 return absl::ResourceExhaustedError("Catalog exceeds 1 MiB");
121 }
122 try {
123 std::vector<std::set<std::string>> object_keys;
124 const auto root = json::parse(
125 text.begin(), text.end(),
126 [&](int depth, json::parse_event_t event, json& parsed) {
127 if (depth > 32)
128 throw std::runtime_error("Catalog nesting exceeds limit");
129 if (event == json::parse_event_t::object_start) {
130 object_keys.emplace_back();
131 } else if (event == json::parse_event_t::key) {
132 if (!object_keys.back().insert(parsed.get<std::string>()).second)
133 throw std::runtime_error("Duplicate JSON field");
134 } else if (event == json::parse_event_t::object_end) {
135 object_keys.pop_back();
136 }
137 return true;
138 });
139 CheckFields(root, {"schema_version", "profile", "families"});
140 if (ReadInteger(root.at("schema_version"), 1, 255) != 1) {
141 return absl::UnimplementedError("Unsupported sprite catalog version");
142 }
143 if (ReadString(root, "profile", true) != "oracle") {
144 return absl::UnimplementedError("Unsupported sprite catalog profile");
145 }
146 const auto& families = root.at("families");
147 if (!families.is_array() || families.empty()) {
148 return absl::InvalidArgumentError("Catalog requires a families array");
149 }
150 SpriteCatalog catalog;
151 std::set<std::string> keys;
152 std::set<int> ids;
153 for (const auto& entry : families) {
154 CheckFields(
155 entry, {"key", "name", "main_id", "selector", "sources", "variants"});
156 SpriteCatalogFamily family;
157 family.key = ReadString(entry, "key", true);
158 family.name = ReadString(entry, "name", false);
159 family.main_id = ReadInteger(entry.at("main_id"), 0, 0xF2);
160 if (!keys.insert(family.key).second || !ids.insert(family.main_id).second)
161 return absl::InvalidArgumentError("Duplicate family key or main ID");
162 const auto& selector = entry.at("selector");
163 CheckFields(selector, {"kind", "fallback_variant"});
164 if (ReadString(selector, "kind", true) != "exact_with_fallback") {
165 return absl::UnimplementedError("Unsupported sprite selector");
166 }
167 family.fallback_variant = ReadString(selector, "fallback_variant", true);
168 family.sources = ReadBindings(entry.at("sources"));
169 const auto& variants = entry.at("variants");
170 if (!variants.is_array() || variants.empty()) {
171 return absl::InvalidArgumentError("Family requires variants");
172 }
173 std::set<int> subtypes;
174 bool found_fallback = false;
175 for (const auto& variant : variants) {
176 CheckFields(variant, {"key", "name", "authored_subtype", "sources"});
178 parsed.key = ReadString(variant, "key", true);
179 parsed.name = ReadString(variant, "name", false);
180 parsed.authored_subtype =
181 ReadInteger(variant.at("authored_subtype"), 0, 31);
182 if (!keys.insert(parsed.key).second ||
183 !subtypes.insert(parsed.authored_subtype).second) {
184 return absl::InvalidArgumentError("Duplicate variant key or subtype");
185 }
186 parsed.sources = ReadBindings(variant.at("sources"));
187 found_fallback |= parsed.key == family.fallback_variant;
188 family.variants.push_back(std::move(parsed));
189 }
190 if (!found_fallback) {
191 return absl::InvalidArgumentError(
192 "Fallback must reference this family's variant");
193 }
194 catalog.families_.push_back(std::move(family));
195 }
196 return catalog;
197 } catch (const std::exception& error) {
198 return absl::InvalidArgumentError(std::string("Invalid sprite catalog: ") +
199 error.what());
200 }
201}
202
203absl::StatusOr<SpriteCatalog> SpriteCatalog::Load(const std::string& path) {
204 auto text = ReadDocument(path);
205 if (!text.ok())
206 return text.status();
207 return Parse(*text);
208}
209
210const SpriteCatalogFamily* SpriteCatalog::FindFamily(int main_id) const {
211 for (const auto& family : families_) {
212 if (family.main_id == main_id)
213 return &family;
214 }
215 return nullptr;
216}
217
218SpriteCatalogResolution SpriteCatalog::Resolve(int main_id,
219 int raw_subtype) const {
221 result.raw_subtype = raw_subtype;
222 const auto* family = FindFamily(main_id);
223 if (!family || raw_subtype < 0 || raw_subtype > 255) {
224 result.explanation =
225 "Unknown family or subtype outside byte range; value preserved.";
226 return result;
227 }
228 for (const auto& variant : family->variants) {
229 if (variant.authored_subtype == raw_subtype) {
230 result.variant = &variant;
231 result.explanation = "Canonical subtype. Catalog browsing only.";
232 return result;
233 }
234 }
235 for (const auto& variant : family->variants) {
236 if (variant.key == family->fallback_variant) {
237 result.variant = &variant;
238 result.used_fallback = true;
239 result.explanation =
240 "Legacy fallback; raw subtype preserved, not normalized.";
241 return result;
242 }
243 }
244 return result;
245}
246
247absl::StatusOr<SpriteSourceDocument> ReadSpriteSourceFile(
248 const std::string& source_root, const std::string& relative_path) {
249 if (source_root.empty()) {
250 return absl::FailedPreconditionError(
251 "Configure sprite_source_root to view source.");
252 }
253 if (!SafeRelativePath(relative_path)) {
254 return absl::InvalidArgumentError("Invalid source binding");
255 }
256 std::error_code ec;
257 const auto root = std::filesystem::canonical(source_root, ec);
258 if (ec)
259 return absl::NotFoundError("Source root is unavailable: " + source_root);
260 const auto path = std::filesystem::canonical(root / relative_path, ec);
261 if (ec)
262 return absl::NotFoundError("Source file is unavailable: " + relative_path);
263 auto root_it = root.begin();
264 auto path_it = path.begin();
265 for (; root_it != root.end(); ++root_it, ++path_it) {
266 if (path_it == path.end() || *root_it != *path_it) {
267 return absl::PermissionDeniedError(
268 "Source binding escapes configured root");
269 }
270 }
271 auto text = ReadDocument(path.string());
272 if (!text.ok())
273 return text.status();
274 SpriteSourceDocument document;
275 document.content = *text;
276 document.path = path.string();
277 std::istringstream stream(*text);
278 std::string line;
279 while (std::getline(stream, line)) {
280 if (!line.empty() && line.back() == '\r')
281 line.pop_back();
282 document.lines.push_back(line);
283 }
284 return document;
285}
286
287absl::StatusOr<SpriteSourceDocument> ReadSpriteSource(
288 const std::string& source_root, const SpriteSourceBinding& binding) {
289 if (!IsIdentifier(binding.label))
290 return absl::InvalidArgumentError("Invalid source label");
291 auto result = ReadSpriteSourceFile(source_root, binding.path);
292 if (!result.ok())
293 return result.status();
294 auto document = std::move(*result);
295 for (size_t i = 0; i < document.lines.size(); ++i) {
296 const auto& line = document.lines[i];
297 auto code = absl::StripAsciiWhitespace(
298 absl::string_view(line).substr(0, line.find(';')));
299 const auto colon = code.find(':');
300 if (colon != absl::string_view::npos &&
301 absl::StripAsciiWhitespace(code.substr(0, colon)) == binding.label) {
302 if (document.label_line) {
303 return absl::FailedPreconditionError("Source label is ambiguous: " +
304 binding.label);
305 }
306 document.label_line = static_cast<int>(i + 1);
307 }
308 }
309 if (!document.label_line)
310 return absl::NotFoundError("Source label not found: " + binding.label);
311 return document;
312}
313} // namespace yaze::zelda3
const std::vector< SpriteCatalogFamily > & families() const
static absl::StatusOr< SpriteCatalog > Parse(absl::string_view json)
std::vector< SpriteCatalogFamily > families_
std::vector< SpriteSourceBinding > ReadBindings(const json &array)
int ReadInteger(const json &value, int min, int max)
void CheckFields(const json &object, std::initializer_list< const char * > fields)
std::string ReadString(const json &object, const char *key, bool identifier)
absl::StatusOr< std::string > ReadDocument(const std::string &path)
Zelda 3 specific classes and functions.
nlohmann::json json
absl::StatusOr< SpriteSourceDocument > ReadSpriteSource(const std::string &source_root, const SpriteSourceBinding &binding)
absl::StatusOr< SpriteSourceDocument > ReadSpriteSourceFile(const std::string &source_root, const std::string &relative_path)
const SpriteCatalogVariant * variant
std::vector< std::string > lines