yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
dungeon_wall_profile.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <filesystem>
5#include <fstream>
6#include <initializer_list>
7#include <set>
8#include <stdexcept>
9#include <string>
10#include <utility>
11
12#include "absl/status/status.h"
13#include "absl/strings/str_format.h"
14#include "nlohmann/json.hpp"
15
16namespace yaze::zelda3 {
17namespace {
18
19using Json = nlohmann::json;
20constexpr uint32_t kFirstPatternPc = 0x1B52;
21constexpr uint32_t kPatternBankEnd = 0x8000;
22
23class JsonSourceError : public std::runtime_error {
24 public:
25 using std::runtime_error::runtime_error;
26};
27
28bool ValidIdentifier(const std::string& value) {
29 if (value.empty() || value.size() > 48 || value.front() < 'a' ||
30 value.front() > 'z') {
31 return false;
32 }
33 return std::all_of(value.begin(), value.end(), [](char ch) {
34 return (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_';
35 });
36}
37
38absl::Status CheckObject(const Json& value,
39 std::initializer_list<absl::string_view> allowed,
40 std::initializer_list<absl::string_view> required,
41 const std::string& where) {
42 if (!value.is_object()) {
43 return absl::InvalidArgumentError(where + " must be an object");
44 }
45 for (const auto& item : value.items()) {
46 if (std::none_of(allowed.begin(), allowed.end(),
47 [&](auto key) { return key == item.key(); })) {
48 return absl::InvalidArgumentError(where + ": unknown field " +
49 item.key());
50 }
51 }
52 for (auto key : required) {
53 if (!value.contains(std::string(key))) {
54 return absl::InvalidArgumentError(where + ": missing field " +
55 std::string(key));
56 }
57 }
58 return absl::OkStatus();
59}
60
61absl::StatusOr<uint64_t> ReadUnsigned(const Json& value, uint64_t maximum,
62 const std::string& where) {
63 if (!value.is_number_unsigned()) {
64 return absl::InvalidArgumentError(where +
65 " must be an unsigned JSON integer");
66 }
67 const uint64_t number = value.get<uint64_t>();
68 if (number > maximum) {
69 return absl::InvalidArgumentError(where + " is out of range");
70 }
71 return number;
72}
73
74absl::StatusOr<std::string> ReadIdentifier(const Json& value,
75 const std::string& where) {
76 if (!value.is_string() ||
77 !ValidIdentifier(value.get_ref<const std::string&>())) {
78 return absl::InvalidArgumentError(where +
79 " must match [a-z][a-z0-9_]{0,47}");
80 }
81 return value.get<std::string>();
82}
83
84absl::Status CheckArray(const Json& value, size_t maximum,
85 const std::string& where) {
86 if (!value.is_array()) {
87 return absl::InvalidArgumentError(where + " must be an array");
88 }
89 if (value.size() > maximum) {
90 return absl::ResourceExhaustedError(where + " exceeds its item limit");
91 }
92 return absl::OkStatus();
93}
94
95} // namespace
96
98 std::vector<DungeonWallProfile> profiles,
99 std::vector<DungeonWallProfileBinding> bindings, std::string source_text,
100 std::string source_path)
101 : profiles_(std::move(profiles)),
102 bindings_(std::move(bindings)),
103 source_text_(std::move(source_text)),
104 source_path_(std::move(source_path)) {}
105
106ResolvedWallProfile::ResolvedWallProfile(std::optional<std::string> profile_id,
107 DungeonTilePatterns patterns)
108 : profile_id_(std::move(profile_id)), patterns_(std::move(patterns)) {}
109
110absl::StatusOr<std::shared_ptr<const DungeonWallProfileCollection>>
111ParseDungeonWallProfiles(absl::string_view source) {
112 if (source.size() > kWallProfileMaxSourceBytes) {
113 return absl::ResourceExhaustedError("Wall profile source exceeds 1 MiB");
114 }
115 if (source.substr(0, 3) == "\xEF\xBB\xBF") {
116 return absl::InvalidArgumentError(
117 "Wall profile JSON must not contain a UTF-8 BOM");
118 }
119 Json root;
120 try {
121 std::vector<std::set<std::string>> keys;
122 const auto callback = [&](int depth, Json::parse_event_t event,
123 Json& parsed) {
124 if (depth > 16) {
125 throw JsonSourceError(
126 "Wall profile JSON nesting exceeds the v1 schema");
127 }
128 if (event == Json::parse_event_t::object_start) {
129 keys.emplace_back();
130 } else if (event == Json::parse_event_t::key) {
131 const auto& key = parsed.get_ref<const std::string&>();
132 if (keys.empty() || !keys.back().insert(key).second) {
133 throw JsonSourceError("Duplicate wall profile JSON key: " + key);
134 }
135 } else if (event == Json::parse_event_t::object_end) {
136 keys.pop_back();
137 }
138 return true;
139 };
140 root = Json::parse(source.begin(), source.end(), callback);
141 } catch (const JsonSourceError& error) {
142 return absl::InvalidArgumentError(error.what());
143 } catch (const Json::exception& error) {
144 return absl::InvalidArgumentError(
145 std::string("Invalid wall profile JSON: ") + error.what());
146 }
147
148 auto status = CheckObject(root, {"schema_version", "profiles", "bindings"},
149 {"schema_version", "profiles", "bindings"}, "root");
150 if (!status.ok())
151 return status;
152 auto version = ReadUnsigned(root["schema_version"], 1, "schema_version");
153 if (!version.ok())
154 return version.status();
155 if (*version != 1) {
156 return absl::InvalidArgumentError(
157 "Only wall profile schema_version 1 is supported");
158 }
159 status = CheckArray(root["profiles"], kWallProfileMaxProfiles, "profiles");
160 if (!status.ok())
161 return status;
162 if (root["profiles"].empty()) {
163 return absl::InvalidArgumentError("Profiles must not be empty");
164 }
165 status = CheckArray(root["bindings"], kWallProfileMaxBindings, "bindings");
166 if (!status.ok())
167 return status;
168
169 std::vector<DungeonWallProfile> profiles;
170 std::set<std::string> ids;
171 for (const auto& item : root["profiles"]) {
172 status =
173 CheckObject(item, {"id", "patterns"}, {"id", "patterns"}, "profile");
174 if (!status.ok())
175 return status;
176 auto id = ReadIdentifier(item["id"], "profile.id");
177 if (!id.ok())
178 return id.status();
179 if (!ids.insert(*id).second) {
180 return absl::InvalidArgumentError("Duplicate wall profile id: " + *id);
181 }
182 status = CheckArray(item["patterns"], kWallProfileMaxPatterns,
183 "profile.patterns");
184 if (!status.ok())
185 return status;
186 if (item["patterns"].empty()) {
187 return absl::InvalidArgumentError("Profile patterns must not be empty");
188 }
189 DungeonWallProfile profile{*id, {}};
190 std::set<std::string> names;
191 std::set<uint32_t> addresses;
192 size_t profile_words = 0;
193 for (const auto& entry : item["patterns"]) {
194 status = CheckObject(entry, {"name", "pc_offset", "words"},
195 {"name", "pc_offset", "words"}, "pattern");
196 if (!status.ok())
197 return status;
198 auto name = ReadIdentifier(entry["name"], "pattern.name");
199 if (!name.ok())
200 return name.status();
201 if (!names.insert(*name).second) {
202 return absl::InvalidArgumentError("Duplicate pattern name: " + *name);
203 }
204 auto pc = ReadUnsigned(entry["pc_offset"], kPatternBankEnd - 2,
205 "pattern.pc_offset");
206 if (!pc.ok())
207 return pc.status();
208 if (*pc < kFirstPatternPc || (*pc & 1) != 0) {
209 return absl::InvalidArgumentError(
210 "Pattern PC must be aligned in 0x1B52-0x7FFE");
211 }
212 status = CheckArray(entry["words"], kWallProfileMaxWordsPerProfile,
213 "pattern.words");
214 if (!status.ok())
215 return status;
216 if (entry["words"].empty()) {
217 return absl::InvalidArgumentError("Pattern words must not be empty");
218 }
219 profile_words += entry["words"].size();
220 if (profile_words > kWallProfileMaxWordsPerProfile) {
221 return absl::ResourceExhaustedError(
222 "Wall profile word budget exceeded");
223 }
224 if (*pc + entry["words"].size() * 2 > kPatternBankEnd) {
225 return absl::InvalidArgumentError(
226 "Pattern words cross bank-00 data boundary");
227 }
228 DungeonWallPattern pattern{*name, static_cast<uint32_t>(*pc), {}};
229 for (const auto& word_json : entry["words"]) {
230 auto word = ReadUnsigned(word_json, 0xFFFF, "pattern.words[]");
231 if (!word.ok())
232 return word.status();
233 const uint32_t address = pattern.pc_offset + pattern.words.size() * 2;
234 if (!addresses.insert(address).second) {
235 return absl::InvalidArgumentError(
236 "Wall profile pattern spans overlap");
237 }
238 pattern.words.push_back(static_cast<uint16_t>(*word));
239 }
240 profile.patterns.push_back(std::move(pattern));
241 }
242 profiles.push_back(std::move(profile));
243 }
244
245 std::vector<DungeonWallProfileBinding> bindings;
246 for (const auto& item : root["bindings"]) {
247 status = CheckObject(item, {"room", "profile", "world", "entrance"},
248 {"room", "profile"}, "binding");
249 if (!status.ok())
250 return status;
251 auto room = ReadUnsigned(item["room"], 0x127, "binding.room");
252 if (!room.ok())
253 return room.status();
254 auto profile = ReadIdentifier(item["profile"], "binding.profile");
255 if (!profile.ok())
256 return profile.status();
257 if (!ids.contains(*profile)) {
258 return absl::InvalidArgumentError("Binding names an unknown profile: " +
259 *profile);
260 }
261 DungeonWallProfileBinding binding{static_cast<uint16_t>(*room), *profile,
262 std::nullopt, std::nullopt};
263 if (item.contains("world")) {
264 auto world = ReadUnsigned(item["world"], 1, "binding.world");
265 if (!world.ok())
266 return world.status();
267 binding.world = static_cast<uint8_t>(*world);
268 }
269 if (item.contains("entrance")) {
270 auto entrance = ReadUnsigned(item["entrance"], 0x84, "binding.entrance");
271 if (!entrance.ok())
272 return entrance.status();
273 binding.entrance = static_cast<uint16_t>(*entrance);
274 }
275 const int rank = binding.world.has_value() + binding.entrance.has_value();
276 for (const auto& other : bindings) {
277 const int other_rank =
278 other.world.has_value() + other.entrance.has_value();
279 const bool world_overlaps =
280 !binding.world || !other.world || binding.world == other.world;
281 const bool entrance_overlaps = !binding.entrance || !other.entrance ||
282 binding.entrance == other.entrance;
283 if (other.room == binding.room && rank == other_rank && world_overlaps &&
284 entrance_overlaps) {
285 return absl::InvalidArgumentError(
286 "Ambiguous equal-specificity wall profile bindings");
287 }
288 }
289 bindings.push_back(std::move(binding));
290 }
291 return std::shared_ptr<const DungeonWallProfileCollection>(
292 new DungeonWallProfileCollection(std::move(profiles), std::move(bindings),
293 std::string(source), ""));
294}
295
296absl::StatusOr<std::shared_ptr<const DungeonWallProfileCollection>>
297LoadDungeonWallProfiles(const std::string& path) {
298 std::error_code error;
299 const bool exists = std::filesystem::exists(path, error);
300 if (error) {
301 return absl::InternalError("Cannot inspect wall profile source: " + path);
302 }
303 if (!exists)
304 return absl::NotFoundError("Wall profile source is missing: " + path);
305 const auto size = std::filesystem::file_size(path, error);
306 if (error)
307 return absl::FailedPreconditionError("Cannot size wall profile source: " +
308 path);
309 if (size > kWallProfileMaxSourceBytes) {
310 return absl::ResourceExhaustedError("Wall profile source exceeds 1 MiB: " +
311 path);
312 }
313 std::ifstream input(path, std::ios::binary);
314 if (!input)
315 return absl::PermissionDeniedError("Cannot open wall profile source: " +
316 path);
317 std::string source(kWallProfileMaxSourceBytes + 1, '\0');
318 input.read(source.data(), static_cast<std::streamsize>(source.size()));
319 source.resize(static_cast<size_t>(input.gcount()));
320 if (input.bad())
321 return absl::DataLossError("Cannot read wall profile source: " + path);
322 auto parsed = ParseDungeonWallProfiles(source);
323 if (!parsed.ok())
324 return parsed.status();
325 const auto canonical = std::filesystem::canonical(path, error);
326 if (error)
327 return absl::NotFoundError("Cannot resolve wall profile source: " + path);
328 return std::shared_ptr<const DungeonWallProfileCollection>(
329 new DungeonWallProfileCollection((*parsed)->profiles(),
330 (*parsed)->bindings(), std::move(source),
331 canonical.string()));
332}
333
334absl::StatusOr<ResolvedWallProfile> ResolveWallProfile(
335 const DungeonWallProfileCollection& collection,
336 const WallProfileContext& context, const DungeonTilePatterns& legacy) {
337 if (context.room < 0 || context.room > 0x127 ||
338 (context.world && (*context.world < 0 || *context.world > 1)) ||
339 (context.entrance &&
340 (*context.entrance < 0 || *context.entrance > 0x84))) {
341 return absl::InvalidArgumentError("Wall profile context is out of range");
342 }
343 const DungeonWallProfileBinding* selected = nullptr;
344 int best_specificity = -1;
345 bool ambiguous = false;
346 for (const auto& binding : collection.bindings()) {
347 if (binding.room != context.room ||
348 (binding.world &&
349 (!context.world || *binding.world != *context.world)) ||
350 (binding.entrance &&
351 (!context.entrance || *binding.entrance != *context.entrance))) {
352 continue;
353 }
354 const int specificity =
355 binding.world.has_value() + binding.entrance.has_value();
356 if (specificity > best_specificity) {
357 selected = &binding;
358 best_specificity = specificity;
359 ambiguous = false;
360 } else if (specificity == best_specificity) {
361 ambiguous = true;
362 }
363 }
364 if (ambiguous) {
365 return absl::FailedPreconditionError(
366 "Ambiguous equally specific wall profile bindings");
367 }
368 if (selected == nullptr)
369 return ResolvedWallProfile(std::nullopt, legacy);
370 const auto profile = std::find_if(
371 collection.profiles().begin(), collection.profiles().end(),
372 [&](const auto& value) { return value.id == selected->profile; });
373 if (profile == collection.profiles().end()) {
374 return absl::InternalError(
375 "Validated wall profile binding lost its profile");
376 }
377 DungeonTilePatterns merged = legacy;
378 merged.source_path.clear();
379 merged.source_text.clear();
380 merged.tokens.clear();
381 for (const auto& pattern : profile->patterns) {
382 for (size_t i = 0; i < pattern.words.size(); ++i) {
383 merged.words[pattern.pc_offset + i * 2] = pattern.words[i];
384 }
385 }
386 return ResolvedWallProfile(profile->id, std::move(merged));
387}
388
389} // namespace yaze::zelda3
static Json parse(const std::string &)
Definition json.h:36
const std::vector< DungeonWallProfile > & profiles() const
const std::vector< DungeonWallProfileBinding > & bindings() const
DungeonWallProfileCollection(std::vector< DungeonWallProfile > profiles, std::vector< DungeonWallProfileBinding > bindings, std::string source_text, std::string source_path)
ResolvedWallProfile(std::optional< std::string > profile_id, DungeonTilePatterns patterns)
absl::StatusOr< uint64_t > ReadUnsigned(const Json &value, uint64_t maximum, const std::string &where)
absl::Status CheckArray(const Json &value, size_t maximum, const std::string &where)
absl::StatusOr< std::string > ReadIdentifier(const Json &value, const std::string &where)
absl::Status CheckObject(const Json &value, std::initializer_list< absl::string_view > allowed, std::initializer_list< absl::string_view > required, const std::string &where)
Zelda 3 specific classes and functions.
constexpr size_t kWallProfileMaxBindings
absl::StatusOr< std::shared_ptr< const DungeonWallProfileCollection > > ParseDungeonWallProfiles(absl::string_view source)
constexpr size_t kWallProfileMaxPatterns
absl::StatusOr< std::shared_ptr< const DungeonWallProfileCollection > > LoadDungeonWallProfiles(const std::string &path)
constexpr size_t kWallProfileMaxWordsPerProfile
absl::StatusOr< ResolvedWallProfile > ResolveWallProfile(const DungeonWallProfileCollection &collection, const WallProfileContext &context, const DungeonTilePatterns &legacy)
constexpr size_t kWallProfileMaxProfiles
constexpr size_t kWallProfileMaxSourceBytes
std::map< uint32_t, Token > tokens
std::map< uint32_t, uint16_t > words