Monopoly — Portage moderne 0.1.0
Portage moderne C/C++23 du Monopoly de 1999
Loading...
Searching...
No Matches
ArchiveAssembly.cpp
Go to the documentation of this file.
1#include "ArchiveAssembly.hpp"
2#include "LegacyBitmap.hpp"
3#include "LegacyChunk.hpp"
4#include "LegacyManifest.hpp"
5#include "LegacyMeshData.hpp"
7
8#include <algorithm>
9#include <array>
10#include <charconv>
11#include <chrono>
12#include <fstream>
13#include <iostream>
14#include <iterator>
15#include <set>
16#include <sstream>
17#include <stdexcept>
18#include <string_view>
19
21{
22 namespace
23 {
24 namespace fs = std::filesystem;
25 constexpr std::uintmax_t PayloadLimit = 512ULL * 1024ULL * 1024ULL;
26 struct Bank { const char* name; const char* header; std::uint16_t group; };
27 constexpr std::array Banks{
28 Bank{"dat_main", "dat_main.h", 2}, Bank{"dat_pat", "dat_pat.h", 3},
29 Bank{"dat_bord", "dat_bord.h", 6}, Bank{"dat_brd2", "dat_brd2.h", 7},
30 Bank{"dat_3d", "dat_3d.h", 8}, Bank{"dat_lm01", "dat_lm01.h", 5},
31 Bank{"dat_lk01", "dat_lk01.h", 10}};
32
33 std::string lower(std::string value)
34 {
35 for (char& c : value) if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
36 return value;
37 }
38 std::string pathText(const fs::path& path)
39 {
40 const auto value = path.generic_u8string();
41 return {reinterpret_cast<const char*>(value.data()), value.size()};
42 }
43 fs::path utf8Path(std::string_view value)
44 { return fs::path(std::u8string(value.begin(), value.end())); }
45 bool inside(const fs::path& parent, const fs::path& child)
46 {
47 auto p = parent.begin(), c = child.begin();
48 for (; p != parent.end(); ++p, ++c)
49 {
50 if (c == child.end()) return false;
51#ifdef _WIN32
52 if (lower(pathText(*p)) != lower(pathText(*c))) return false;
53#else
54 if (*p != *c) return false;
55#endif
56 }
57 return true;
58 }
59 fs::path resolvePath(const fs::path& path)
60 { return fs::weakly_canonical(fs::absolute(path)); }
61 void rejectOverlap(const fs::path& output, const fs::path& input)
62 {
63 if (inside(input, output) || inside(output, input))
64 throw std::runtime_error("output and input trees must not overlap: " + pathText(input));
65 }
66 std::string cell(std::string value)
67 {
68 std::string out;
69 for (char c : value)
70 {
71 if (c == '\t') out += "\\t";
72 else if (c == '\r') out += "\\r";
73 else if (c == '\n') out += "\\n";
74 else if (c == '\\') out += "\\\\";
75 else out += c;
76 }
77 return out;
78 }
79 std::ofstream report(const fs::path& path)
80 {
81 std::ofstream out(path, std::ios::binary);
82 if (!out) throw std::runtime_error("cannot create " + pathText(path));
83 out.exceptions(std::ios::badbit | std::ios::failbit);
84 return out;
85 }
86 DataBytes readPayload(const fs::path& path)
87 {
88 const auto size = fs::file_size(path);
89 if (size == 0 || size > PayloadLimit)
90 throw std::runtime_error("payload must be nonempty and at most 512 MiB");
91 DataBytes bytes(static_cast<std::size_t>(size));
92 std::ifstream in(path, std::ios::binary);
93 if (!in.read(reinterpret_cast<char*>(bytes.data()), static_cast<std::streamsize>(bytes.size())) ||
94 in.peek() != std::char_traits<char>::eof())
95 throw std::runtime_error("payload read failed or size changed: " + pathText(path));
96 return bytes;
97 }
98 std::uint32_t u32(std::span<const std::byte> bytes, std::size_t n)
99 {
100 return std::to_integer<std::uint32_t>(bytes[n]) |
101 (std::to_integer<std::uint32_t>(bytes[n+1]) << 8U) |
102 (std::to_integer<std::uint32_t>(bytes[n+2]) << 16U) |
103 (std::to_integer<std::uint32_t>(bytes[n+3]) << 24U);
104 }
105 bool magic(std::span<const std::byte> bytes, std::size_t n, std::string_view wanted)
106 {
107 if (n > bytes.size() || wanted.size() > bytes.size() - n) return false;
108 for (std::size_t i = 0; i < wanted.size(); ++i)
109 if (std::to_integer<unsigned char>(bytes[n+i]) != static_cast<unsigned char>(wanted[i])) return false;
110 return true;
111 }
112 // Validate disk formats without loading SDL, decoding video, or inventing
113 // absent content. Cross-bank CNK references are not a gameplay validation.
114 void validate(LegacyDataType type, const DataBytes& bytes)
115 {
116 if (type == LegacyDataType::Bitmap)
117 {
118 const auto result = inspectLegacyBitmap(bytes);
119 if (!result) throw std::runtime_error(result.error().detail);
120 }
121 else if (type == LegacyDataType::Uap)
122 {
123 const auto result = inspectLegacyUap(bytes);
124 if (!result) throw std::runtime_error(result.error().detail);
125 }
126 else if (type == LegacyDataType::Chunky)
127 {
128 LegacyChunkReader reader{std::span<const std::byte>(bytes)};
129 const auto first = reader.descend();
130 if (!first) throw std::runtime_error(first.error().detail);
131 if (first->id < 1 || first->id > 10 || first->endOffset != bytes.size())
132 throw std::runtime_error("CNK must contain one complete root sequence");
133 }
134 else if (type == LegacyDataType::Hmd)
135 {
136 const auto result = LegacyMeshData::parse(std::make_shared<const DataBytes>(bytes));
137 if (!result) throw std::runtime_error(result.error().detail);
138 }
139 else if (type == LegacyDataType::Wave)
140 {
141 if (!magic(bytes, 0, "RIFF") || !magic(bytes, 8, "WAVE") || bytes.size() < 12)
142 throw std::runtime_error("WAV payload has no RIFF/WAVE header");
143 const std::uint64_t end = 8ULL + u32(bytes, 4);
144 if (end != bytes.size()) throw std::runtime_error("RIFF size does not match payload");
145 bool format{}, data{};
146 for (std::uint64_t p = 12; p < end;)
147 {
148 if (end - p < 8) throw std::runtime_error("truncated RIFF chunk header");
149 const auto at = static_cast<std::size_t>(p);
150 const auto size = u32(bytes, at + 4);
151 const auto next = p + 8 + size + (size & 1U);
152 if (next > end) throw std::runtime_error("RIFF chunk exceeds payload");
153 if (magic(bytes, at, "fmt ")) format = size >= 16;
154 if (magic(bytes, at, "data")) data = size > 0;
155 p = next;
156 }
157 if (!format || !data) throw std::runtime_error("WAV is missing format or audio data");
158 }
159 else throw std::runtime_error("unsupported manifest payload type");
160 }
161
162 struct File { std::size_t root; fs::path relative; fs::path absolute; };
163 using FileIndex = std::map<std::string, std::vector<File>>;
164 FileIndex indexFiles(const std::vector<fs::path>& roots)
165 {
166 FileIndex result;
167 for (std::size_t i = 0; i < roots.size(); ++i)
168 {
169 if (!fs::is_directory(roots[i]))
170 throw std::runtime_error("resource root is not a directory: " + pathText(roots[i]));
171 for (const auto& entry : fs::recursive_directory_iterator(roots[i]))
172 {
173 if (entry.is_symlink() || !entry.is_regular_file()) continue;
174 const auto absolute = resolvePath(entry.path());
175 if (!inside(roots[i], absolute)) continue;
176 const auto relative = absolute.lexically_relative(roots[i]);
177 result[lower(pathText(absolute.filename()))].push_back({i, relative, absolute});
178 }
179 }
180 for (auto& [name, files] : result)
181 {
182 (void)name;
183 std::ranges::sort(files, [](const File& a, const File& b) {
184 if (a.root != b.root) return a.root < b.root;
185 return a.relative < b.relative;
186 });
187 }
188 return result;
189 }
190 using MappingKey = std::pair<std::string, std::string>;
191 using Mapping = std::map<MappingKey, File>;
192 Mapping readMappings(const std::optional<fs::path>& path, const std::vector<fs::path>& roots,
193 const std::set<MappingKey>& allowed)
194 {
195 Mapping result;
196 if (!path) return result;
197 if (fs::file_size(*path) > 16U * 1024U * 1024U)
198 throw std::runtime_error("resource mapping exceeds 16 MiB");
199 std::ifstream in(*path);
200 if (!in) throw std::runtime_error("cannot open resource mapping");
201 std::string line;
202 std::size_t lineNumber{};
203 while (std::getline(in, line))
204 {
205 ++lineNumber;
206 if (!line.empty() && line.back() == '\r') line.pop_back();
207 if (line.empty() || line[0] == '#') continue;
208 if (lineNumber == 1 && line == "bank\tsymbol\troot\tpath") continue;
209 const auto a = line.find('\t'), b = line.find('\t', a == line.npos ? a : a+1);
210 const auto c = line.find('\t', b == line.npos ? b : b+1);
211 if (a == line.npos || b == line.npos || c == line.npos || line.find('\t', c+1) != line.npos)
212 throw std::runtime_error("mapping must have four TSV columns at line " + std::to_string(lineNumber));
213 const MappingKey key{lower(line.substr(0,a)), line.substr(a+1,b-a-1)};
214 if (!allowed.contains(key)) throw std::runtime_error("unknown bank/symbol in mapping: " + line.substr(0,b));
215 const auto rootNumber = std::string_view(line).substr(b+1,c-b-1);
216 std::size_t rootIndex{};
217 const auto number = std::from_chars(rootNumber.data(),rootNumber.data()+rootNumber.size(),rootIndex);
218 if (number.ec != std::errc{} || number.ptr != rootNumber.data()+rootNumber.size() || rootIndex >= roots.size())
219 throw std::runtime_error("invalid root index in mapping line " + std::to_string(lineNumber));
220 const auto relative = utf8Path(line.substr(c+1));
221 if (relative.empty() || relative.is_absolute() || relative.has_root_name())
222 throw std::runtime_error("mapping path must be relative to its resource root");
224 if (!inside(roots[rootIndex], absolute) || !fs::is_regular_file(absolute))
225 throw std::runtime_error("mapped file missing or outside resource root: " + pathText(relative));
226 if (!result.emplace(key, File{rootIndex, relative, absolute}).second)
227 throw std::runtime_error("duplicate bank/symbol mapping");
228 }
229 if (in.bad()) throw std::runtime_error("mapping read failed");
230 return result;
231 }
232 std::vector<std::string> namesFor(const LegacyManifestEntry& entry)
233 {
234 const auto prefixEnd = entry.symbol.find('_');
235 const auto stem = entry.symbol.substr(prefixEnd+1);
236 std::vector<std::string> extensions;
237 switch (entry.type)
238 {
239 case LegacyDataType::Bitmap: extensions = {"bmp"}; break;
240 case LegacyDataType::Uap: extensions = {"tab", "uap"}; break;
241 case LegacyDataType::Chunky: extensions = {"cnk"}; break;
242 case LegacyDataType::Hmd: extensions = {"hmd"}; break;
243 case LegacyDataType::Wave: extensions = {"wav"}; break;
244 default: throw std::runtime_error("unsupported manifest type");
245 }
246 std::vector<std::string> names;
247 for (const auto& ext : extensions)
248 {
249 names.push_back(lower(stem+"."+ext));
250 names.push_back(lower(entry.symbol+"."+ext));
251 }
252 return names;
253 }
254 fs::path writeArchive(const fs::path& run, const BankResult& bank,
255 std::uint16_t group, const std::vector<ArchiveBuildItem>& items)
256 {
257 if (bank.available == 0) return {}; // Never emit an all-empty bank.
258 const auto folder = run/(bank.complete() ? "Dat_Mon" : "partial");
259 fs::create_directories(folder);
260 const auto target = folder/(bank.name+(bank.complete() ? ".dat" : ".partial.dat"));
261 auto pending = target;
262 pending += ".writing";
263 const auto written = writeLegacyDataArchive(pending, items);
264 if (!written) throw std::runtime_error(written.error().detail);
265 ArchiveOpenOptions options;
266 options.checksumPolicy = ChecksumPolicy::Verify;
267 auto opened = LegacyDataArchive::open(pending, group, options);
268 if (!opened) throw std::runtime_error(opened.error().detail);
269 if ((*opened)->itemCount() != items.size())
270 throw std::runtime_error("archive read-back changed the number of tags");
271 for (std::size_t tag = 0; tag < items.size(); ++tag)
272 {
273 const auto meta = (*opened)->metadata(static_cast<DataTag>(tag));
274 if (!meta || meta->type != items[tag].type || meta->present() == items[tag].emptySlot())
275 throw std::runtime_error("archive read-back changed a tag or type");
276 if (items[tag].emptySlot()) continue;
277 const auto payload = (*opened)->load(static_cast<DataTag>(tag));
278 if (!payload || **payload != items[tag].payload)
279 throw std::runtime_error("archive read-back changed resource bytes");
280 }
281 if (group == 9)
282 {
283 const auto language = LanguageCatalog::open(LanguageId::EnglishUs, *opened);
284 if (!language) throw std::runtime_error(language.error().detail);
285 }
286 (*opened)->close();
287 fs::rename(pending, target); // Published only after disk read-back.
288 return target;
289 }
290 std::string joined(const std::vector<std::string>& values)
291 {
292 std::string result;
293 for (const auto& value : values) { if (!result.empty()) result += " | "; result += value; }
294 return result;
295 }
296 }
297
299 { return banks.size() == 8 && std::ranges::all_of(banks, &BankResult::complete); }
300
301 Result run(const Request& request)
302 {
303 const auto source = resolvePath(request.sourceRoot);
304 const auto output = resolvePath(request.outputRoot);
305 std::vector<fs::path> roots{source};
306 for (const auto& extra : request.extraRoots) roots.push_back(resolvePath(extra));
307 for (const auto& input : roots) rejectOverlap(output, input);
308#ifdef MONOPOLY_PROTECTED_SOURCE_ROOT
310#endif
311 const auto files = indexFiles(roots);
312 std::map<std::string, LegacyBankManifest> manifests;
313 std::set<MappingKey> allowed;
314 for (const auto& bank : Banks)
315 {
316 auto manifest = readDmakeManifest(source/"Dat_Mon"/bank.header);
317 if (!manifest) throw std::runtime_error(pathText(manifest.error().path) + ": " + manifest.error().detail);
318 for (const auto& entry : manifest->entries) allowed.emplace(bank.name,entry.symbol);
319 manifests.emplace(bank.name,std::move(*manifest));
320 }
321 const auto mappings = readMappings(request.mappings, roots, allowed);
322 const auto english = readEnglishText(source/"Dat_Mon/dat_lang.h", source/"English.atr");
323 fs::create_directories(output);
324 Result result;
325 for (unsigned attempt = 0; attempt < 100; ++attempt)
326 {
327 const auto stamp = std::chrono::system_clock::now().time_since_epoch().count();
328 const auto path = output/("run-"+std::to_string(stamp)+"-"+std::to_string(attempt));
329 if (fs::create_directory(path)) { result.directory=path; break; }
330 }
331 if (result.directory.empty()) throw std::runtime_error("cannot create an exclusive assembly directory");
332 auto details = report(result.directory/"resources.tsv");
333 details << "bank\ttag_or_message_id\ttype\tsymbol\tstatus\troot\tpath\tdetail\n";
334 auto bankReport = report(result.directory/"banks.tsv");
335 bankReport << "bank\texpected\tavailable\tinvalid\tstatus\tarchive\n";
336 std::set<fs::path> consumed;
337 const auto describeBank = [&](const BankResult& bank) {
338 const auto status = bank.complete() ? "MANIFEST_COMPLETE" : "INCOMPLETE";
339 bankReport << bank.name << '\t' << bank.expected << '\t' << bank.available << '\t'
340 << bank.invalid << '\t' << status << '\t' << cell(pathText(bank.archive)) << '\n';
341 std::cout << bank.name << ": " << bank.available << '/' << bank.expected << " " << status
342 << "; invalid=" << bank.invalid << '\n';
343 };
344 for (const auto& definition : Banks)
345 {
346 const auto& manifest = manifests.at(definition.name);
347 BankResult bank{definition.name, manifest.entries.size()};
348 std::vector<ArchiveBuildItem> items(manifest.declaredItemCount);
349 std::uintmax_t totalPayload{};
350 for (const auto& entry : manifest.entries)
351 {
352 std::vector<File> candidates;
353 const auto mapping = mappings.find({bank.name,entry.symbol});
354 const bool explicitMapping = mapping != mappings.end();
355 if (explicitMapping) candidates.push_back(mapping->second);
356 else
357 {
358 std::set<fs::path> seen;
359 for (const auto& name : namesFor(entry))
360 if (const auto found = files.find(name); found != files.end())
361 for (const auto& file : found->second)
362 if (seen.insert(file.absolute).second) candidates.push_back(file);
363 }
364 std::string status, why;
365 std::string fromRoot, fromPath;
366 if (candidates.empty()) { status="missing"; why=joined(namesFor(entry)); }
367 else if (candidates.size() != 1)
368 {
369 status="ambiguous";
370 for (const auto& file : candidates) why += std::to_string(file.root)+":"+pathText(file.relative)+" | ";
371 }
372 else
373 {
374 const auto& file = candidates.front();
375 fromRoot=std::to_string(file.root); fromPath=pathText(file.relative);
376 try
377 {
378 const auto size = fs::file_size(file.absolute);
379 if (size > PayloadLimit || totalPayload > PayloadLimit - size)
380 throw std::runtime_error("bank payload budget exceeds 512 MiB");
381 auto bytes = readPayload(file.absolute);
382 validate(entry.type,bytes);
383 totalPayload += bytes.size();
384 items[entry.tag]={entry.type,std::move(bytes)};
385 ++bank.available;
386 consumed.insert(file.absolute);
387 status="packed";
388 why=explicitMapping ? "explicit mapping; format inspected" : "unique exact filename; format inspected";
389 }
390 catch (const std::exception& error) { status="invalid"; why=error.what(); ++bank.invalid; }
391 }
392 details << bank.name << '\t' << entry.tag << '\t' << legacyDataTypeName(entry.type) << '\t'
393 << entry.symbol << '\t' << status << '\t' << fromRoot << '\t' << cell(fromPath) << '\t' << cell(why) << '\n';
394 }
395 bank.archive=writeArchive(result.directory,bank,definition.group,items);
397 result.banks.push_back(std::move(bank));
398 }
399 BankResult language{"dat_ln01",english.requiredNames.size(),english.text.size()};
400 for (const auto& [id,name] : english.requiredNames)
401 {
402 const bool present=english.text.contains(id);
403 details << language.name << '\t' << id << "\tString\t" << name << '\t'
404 << (present ? "packed" : "missing") << "\t0\tEnglish.atr\t"
405 << (present ? "exact symbolic ID; ASCII to UTF-16LE" : "no exact symbolic text in supplied ATR") << '\n';
406 }
407 for (const auto& name : english.unmappedSymbols)
408 details << "dat_ln01\t\tString\t" << name
409 << "\tunmapped-symbol\t0\tEnglish.atr\tnot defined in active dat_lang.h; no numeric ID guessed\n";
410 language.archive=writeArchive(result.directory,language,9,buildEnglishItems(english));
411 describeBank(language);
412 result.banks.push_back(std::move(language));
413 auto unused = report(result.directory/"unassigned-files.tsv");
414 unused << "root\tpath\treason\n";
415 const std::set<std::string> extensions{".bmp",".tab",".uap",".wav",".cnk",".hmd"};
416 for (const auto& [name, matches] : files)
417 {
418 (void)name;
419 for (const auto& file : matches)
420 if (extensions.contains(lower(pathText(file.relative.extension()))) && !consumed.contains(file.absolute))
421 unused << file.root << '\t' << cell(pathText(file.relative))
422 << "\tno unique manifest assignment; may already be used as an external texture\n";
423 }
424 auto summary = report(result.directory/"README.txt");
425 summary << "Monopoly archive assembly\n"
426 << (result.complete() ? "All manifest entries have payloads.\n" : "INCOMPLETE - not a playable game data set.\n")
427 << "No files were modified in Source/ or installed in the game's Dat_Mon.\n"
428 << "Partial archives contain ONLY recovered payloads. Original numeric tags are preserved.\n"
429 << "A missing slot is not a substitute for an animation, sound, model or image.\n"
430 << "Dat_Mon/ contains only banks complete against their manifests; partial/ is never installed.\n"
431 << "Language conversion only maps exact symbols shared by English.atr and dat_lang.h.\n"
432 << "No numbers from English.A or unrelated legacy headers were assumed equivalent.\n"
433 << "resources.tsv lists every resource decision. banks.tsv summarizes banks.\n"
434 << "unassigned-files.tsv also lists external texture files; unassigned does NOT mean missing.\n"
435 << "Container CRC and byte-for-byte read-back are checked during publication.\n"
436 << "Payload structural inspection does not establish cross-bank references or gameplay fidelity.\n";
437 for (std::size_t n=0;n<roots.size();++n) summary << "resource root " << n << ": " << pathText(roots[n]) << '\n';
438 summary.close(); unused.close(); details.close(); bankReport.close();
439 std::cout << "Assembly directory: " << pathText(result.directory) << '\n'
440 << (result.complete() ? "ASSEMBLED; gameplay not qualified\n" : "INCOMPLETE; runtime installation left untouched\n");
441 return result;
442 }
443}
std::vector< std::uint8_t > data
fs::path absolute
const char * name
fs::path relative
std::size_t root
std::uint16_t group
const char * header
std::vector< std::byte > bytes
std::shared_ptr< const data::HmdTextureImage > source
std::uint64_t key
unsigned index
data::DataTag first
std::array< data::DataTag, 3 > roots
std::string path
std::string error
Bytes input
std::deque< Bytes > output
std::size_t at
std::uint16_t value
Definition Timers.cpp:16
data::DataId out
SDL_GPUShaderFormat format
static std::expected< std::shared_ptr< LanguageCatalog >, DataError > open(LanguageId language, std::shared_ptr< LegacyDataArchive > textArchive)
static std::expected< std::shared_ptr< LegacyDataArchive >, DataError > open(const std::filesystem::path &path, std::uint16_t group, ArchiveOpenOptions options={})
static std::expected< LegacyMeshData, MeshDataError > parse(SharedDataBytes bytes, MeshParseLimits limits={})
EnglishText readEnglishText(const std::filesystem::path &definitions, const std::filesystem::path &atr)
std::vector< ArchiveBuildItem > buildEnglishItems(const EnglishText &input)
Result run(const Request &request)
std::string_view legacyDataTypeName(LegacyDataType type) noexcept
std::vector< std::byte > DataBytes
std::uint16_t DataTag
Definition DataBanks.hpp:11
std::expected< LegacyBankManifest, ManifestError > readDmakeManifest(const std::filesystem::path &path)
std::expected< LegacyUapMetadata, BitmapError > inspectLegacyUap(std::span< const std::byte > bytes)
std::expected< void, DataError > writeLegacyDataArchive(const std::filesystem::path &path, std::span< const ArchiveBuildItem > items, ArchiveBuildOptions options)
std::expected< LegacyBitmapMetadata, BitmapError > inspectLegacyBitmap(std::span< const std::byte > bytes)
void validate(GameOptions &options)
std::size_t size()
std::optional< std::filesystem::path > mappings
std::vector< std::filesystem::path > extraRoots
std::vector< BankResult > banks