Monopoly — Portage moderne 0.1.0
Portage moderne C/C++23 du Monopoly de 1999
Loading...
Searching...
No Matches
OptionsSaveRuntime.cpp
Go to the documentation of this file.
2
3#include "FontRuntime.hpp"
5#include "ResourceRuntime.hpp"
6
7#include <SDL3/SDL.h>
8
9#include <algorithm>
10#include <bit>
11#include <fstream>
12#include <limits>
13#include <system_error>
14
15namespace monopoly::optionsui
16{
17 namespace
18 {
19 inline constexpr std::size_t DescriptionUnits = 100;
20 inline constexpr std::size_t CustomBoardBytes = 100;
21 inline constexpr std::size_t MetadataBytes =
23 inline constexpr int SlotLeft = 152;
24 inline constexpr int SlotTop = 112;
25 inline constexpr int SlotWidth = 496;
26 inline constexpr int SlotHeight = 34;
27 inline constexpr int SlotStepY = 68;
28 inline constexpr int MaximumDescriptionWidth = 446;
29 inline constexpr std::uint32_t ButtonCancelTextId = 932;
30 inline constexpr std::uint32_t ButtonOkayTextId = 933;
31 void writeU16(std::vector<std::uint8_t>& bytes, std::uint16_t value)
32 {
33 bytes.push_back(static_cast<std::uint8_t>(value));
34 bytes.push_back(static_cast<std::uint8_t>(value >> 8U));
35 }
36
37 void writeU32(std::vector<std::uint8_t>& bytes, std::uint32_t value)
38 {
39 for (unsigned shift = 0; shift < 32; shift += 8)
40 bytes.push_back(static_cast<std::uint8_t>(value >> shift));
41 }
42
43 [[nodiscard]] std::uint16_t readU16(
44 std::span<const std::uint8_t> bytes, std::size_t& offset) noexcept
45 {
46 const std::uint16_t result = static_cast<std::uint16_t>(
47 static_cast<std::uint16_t>(bytes[offset]) |
48 (static_cast<std::uint16_t>(bytes[offset + 1]) << 8U));
49 offset += 2;
50 return result;
51 }
52
53 [[nodiscard]] std::uint32_t readU32(
54 std::span<const std::uint8_t> bytes, std::size_t& offset) noexcept
55 {
56 std::uint32_t result{};
57 for (unsigned shift = 0; shift < 32; shift += 8)
58 result |= static_cast<std::uint32_t>(bytes[offset++]) << shift;
59 return result;
60 }
61
62 [[nodiscard]] std::vector<std::uint8_t> encodeMetadata(
63 const SaveMetadata& metadata)
64 {
65 std::vector<std::uint8_t> bytes;
66 bytes.reserve(MetadataBytes);
67 for (std::size_t index = 0; index < DescriptionUnits; ++index)
68 {
69 const std::uint16_t value = index < metadata.description.size()
70 ? static_cast<std::uint16_t>(metadata.description[index]) : 0U;
72 }
73 writeU32(bytes, std::bit_cast<std::uint32_t>(metadata.city));
74 writeU32(bytes, std::bit_cast<std::uint32_t>(metadata.system));
75 for (const auto earning : metadata.squareGameEarnings)
77
78 for (std::size_t index = 0; index < CustomBoardBytes; ++index)
79 {
80 const std::uint8_t value = index < metadata.customBoardName.size()
81 ? static_cast<std::uint8_t>(metadata.customBoardName[index])
82 : std::uint8_t{0};
83 bytes.push_back(value);
84 }
85 return bytes;
86 }
87
88 [[nodiscard]] std::expected<SaveMetadata, std::string> decodeMetadata(
89 std::span<const std::uint8_t> bytes)
90 {
91 if (bytes.size() < MetadataBytes)
92 return std::unexpected("saved-game metadata is truncated");
93
94 SaveMetadata metadata{};
95 std::size_t offset{};
96 bool descriptionTerminated = false;
97 for (std::size_t index = 0; index < DescriptionUnits; ++index)
98 {
99 const auto value = readU16(bytes, offset);
100 if (value == 0)
101 {
103 continue;
104 }
106 metadata.description.size() + 1U >= DescriptionUnits)
107 continue;
108 metadata.description.push_back(static_cast<char16_t>(value));
109 }
110 metadata.city = std::bit_cast<std::int32_t>(readU32(bytes, offset));
111 metadata.system = std::bit_cast<std::int32_t>(readU32(bytes, offset));
112 for (auto& earning : metadata.squareGameEarnings)
114
115 for (std::size_t index = 0; index < CustomBoardBytes; ++index)
116 {
117 const auto value = static_cast<char>(bytes[offset++]);
118 if (value == '\0') break;
119 metadata.customBoardName.push_back(value);
120 }
121 return metadata;
122 }
123
124 [[nodiscard]] std::expected<std::vector<std::uint8_t>, std::string> readFile(
125 const std::filesystem::path& path)
126 {
127 std::ifstream input(path, std::ios::binary | std::ios::ate);
128 if (!input) return std::unexpected("unable to open " + path.string());
129 const auto end = input.tellg();
130 if (end < 0) return std::unexpected("unable to size " + path.string());
131 const auto size = static_cast<std::uint64_t>(end);
132 if (size > static_cast<std::uint64_t>(std::numeric_limits<std::size_t>::max()))
133 return std::unexpected("file is too large: " + path.string());
134 std::vector<std::uint8_t> bytes(static_cast<std::size_t>(size));
135 input.seekg(0, std::ios::beg);
136 if (!bytes.empty() && !input.read(
137 reinterpret_cast<char*>(bytes.data()),
138 static_cast<std::streamsize>(bytes.size())))
139 return std::unexpected("unable to read " + path.string());
140 return bytes;
141 }
142
143 [[nodiscard]] std::expected<void, std::string> writeFile(
144 const std::filesystem::path& path, std::span<const std::uint8_t> bytes)
145 {
146 std::ofstream output(path, std::ios::binary | std::ios::trunc);
147 if (!output) return std::unexpected("unable to create " + path.string());
148 if (!bytes.empty())
149 {
150 output.write(reinterpret_cast<const char*>(bytes.data()),
151 static_cast<std::streamsize>(bytes.size()));
152 if (!output) return std::unexpected("unable to write " + path.string());
153 }
154 return {};
155 }
156
157 void appendUtf8(std::u16string& output, std::string_view text)
158 {
159 for (std::size_t index = 0; index < text.size();)
160 {
161 const auto first = static_cast<std::uint8_t>(text[index]);
162 std::uint32_t codepoint{};
163 std::size_t count{};
164 if (first < 0x80U) { codepoint = first; count = 1; }
165 else if ((first & 0xE0U) == 0xC0U) { codepoint = first & 0x1FU; count = 2; }
166 else if ((first & 0xF0U) == 0xE0U) { codepoint = first & 0x0FU; count = 3; }
167 else if ((first & 0xF8U) == 0xF0U) { codepoint = first & 0x07U; count = 4; }
168 else { ++index; continue; }
169 if (index + count > text.size()) break;
170 bool valid = true;
171 for (std::size_t part = 1; part < count; ++part)
172 {
173 const auto byte = static_cast<std::uint8_t>(text[index + part]);
174 if ((byte & 0xC0U) != 0x80U) { valid = false; break; }
175 codepoint = (codepoint << 6U) | (byte & 0x3FU);
176 }
177 if (!valid) { ++index; continue; }
178 index += count;
179 if (codepoint >= 0xD800U && codepoint <= 0xDFFFU) continue;
180 if (codepoint <= 0xFFFFU)
181 {
182 output.push_back(static_cast<char16_t>(codepoint));
183 }
184 else if (codepoint <= 0x10FFFFU)
185 {
186 codepoint -= 0x10000U;
187 output.push_back(static_cast<char16_t>(0xD800U + (codepoint >> 10U)));
188 output.push_back(static_cast<char16_t>(0xDC00U + (codepoint & 0x3FFU)));
189 }
190 }
191 }
192
193 [[nodiscard]] std::optional<std::pair<int, int>> textMetrics(
194 std::uint32_t messageId, fonts::Runtime* fontRuntime,
195 const std::shared_ptr<const data::ResourceSnapshot>& resources)
196 {
197 if (!fontRuntime || !fontRuntime->ready() || !resources)
198 return std::nullopt;
199 const auto language = resources->language();
200 if (!language || !language->catalog) return std::nullopt;
201 const auto text = language->catalog->message(messageId);
202 if (!text) return std::nullopt;
203 const auto measured = fontRuntime->measure(std::u16string_view(**text));
204 if (!measured) return std::nullopt;
205 return std::pair<int, int>{measured->width, measured->height};
206 }
207
209 const std::u16string& text, fonts::Runtime* fontRuntime)
210 {
211 if (text.size() + 1U >= DescriptionUnits) return false;
212 if (!fontRuntime || !fontRuntime->ready()) return true;
213 const auto measured = fontRuntime->measure(std::u16string_view(text));
214 return !measured || measured->width < MaximumDescriptionWidth;
215 }
216
217 [[nodiscard]] std::uint32_t legacyEarning(std::int64_t value) noexcept
218 {
219 if (value <= 0) return 0;
220 if (static_cast<std::uint64_t>(value) >
221 std::numeric_limits<std::uint32_t>::max())
222 return std::numeric_limits<std::uint32_t>::max();
223 return static_cast<std::uint32_t>(value);
224 }
225 }
226
227 std::filesystem::path saveGameDirectory()
228 {
229 const char* base = SDL_GetBasePath();
230 if (base == nullptr || *base == '\0') return {};
231 return std::filesystem::path(base) / "savegame";
232 }
233
234 std::filesystem::path gameBlobPath(std::size_t slot)
235 {
236 const auto root = saveGameDirectory();
237 if (root.empty() || slot >= SaveSlotCount) return {};
238 return root / ("game" + std::to_string(slot + 1U) + ".msv");
239 }
240
241 std::filesystem::path gameMetadataPath(std::size_t slot)
242 {
243 const auto root = saveGameDirectory();
244 if (root.empty() || slot >= SaveSlotCount) return {};
245 return root / ("game" + std::to_string(slot + 1U) + ".sgd");
246 }
247
248 Rect saveSlotRect(std::size_t slot) noexcept
249 {
250 if (slot >= SaveSlotCount) return {};
251 const int top = SlotTop + static_cast<int>(slot) * SlotStepY;
253 }
254
255 Rect dialogOkayRect(int width, int height) noexcept
256 {
257 return {500, 430, 500 + std::max(width, 0), 430 + std::max(height, 0)};
258 }
259
261 {
262 return {600, 430, 600 + std::max(width, 0), 430 + std::max(height, 0)};
263 }
264
265 std::expected<void, std::string> refreshSaveSlots(
267 {
268 if (mode == FileDialogMode::None)
269 return std::unexpected("cannot open an empty File dialog");
270 if (saveGameDirectory().empty())
271 return std::unexpected("SDL base path is unavailable for savegame storage");
272
273 state.dialog = mode;
274 state.slots = {};
275 state.selectedSlot = -1;
276 state.draftDescription.clear();
277 bool anyMetadata = false;
278
279 for (std::size_t slot = 0; slot < SaveSlotCount; ++slot)
280 {
281 const auto path = gameMetadataPath(slot);
282 std::error_code error;
283 if (!std::filesystem::is_regular_file(path, error) || error) continue;
284 anyMetadata = true;
285 state.slots[slot].occupied = true;
286 const auto bytes = readFile(path);
287 if (!bytes) continue;
288 const auto decoded = decodeMetadata(*bytes);
289 if (decoded) state.slots[slot].metadata = *decoded;
290 }
291
292 // UDOPTIONS_DisplayLoad/SaveDialogBox sets CurrentGameFile to zero
293 // whenever at least one metadata file was discovered.
294 state.selectedSlot = anyMetadata ? 0 : -1;
295 ++state.revision;
296 return {};
297 }
298
300 {
301 if (state.dialog == FileDialogMode::None) return;
303 state.selectedSlot = -1;
304 state.draftDescription.clear();
305 ++state.revision;
306 }
307
310 const uimsg::Message& message,
312 std::shared_ptr<const data::ResourceSnapshot> resources)
313 {
314 SaveDialogInput result{};
315 if (state.dialog == FileDialogMode::None) return result;
316 result.consumed = true;
317
318 const auto requestOkay = [&]()
319 {
320 if (state.selectedSlot < 0 ||
321 state.selectedSlot >= static_cast<int>(SaveSlotCount))
322 return;
323 if (state.dialog == FileDialogMode::Load)
324 result.requestLoad = true;
325 else
326 result.requestSave = true;
327 result.closeDialog = true;
328 result.playClick = true;
329 };
330
331 if (message.type == uimsg::Type::KeyboardPressed)
332 {
333 const auto key = static_cast<SDL_Scancode>(message.numberA);
335 {
336 requestOkay();
337 return result;
338 }
340 state.dialog == FileDialogMode::Save &&
341 state.selectedSlot >= 0 && !state.draftDescription.empty())
342 {
343 if (state.draftDescription.size() >= 2U)
344 {
345 const auto last = static_cast<std::uint16_t>(state.draftDescription.back());
346 const auto previous = static_cast<std::uint16_t>(
347 state.draftDescription[state.draftDescription.size() - 2U]);
348 if (last >= 0xDC00U && last <= 0xDFFFU &&
349 previous >= 0xD800U && previous <= 0xDBFFU)
350 state.draftDescription.pop_back();
351 }
352 state.draftDescription.pop_back();
353 ++state.revision;
354 return result;
355 }
356 return result;
357 }
358
359 if (message.type == uimsg::Type::TextInput &&
360 state.dialog == FileDialogMode::Save &&
361 state.selectedSlot >= 0)
362 {
363 std::u16string added;
364 appendUtf8(added, message.text);
365 bool changed = false;
366 for (std::size_t index = 0; index < added.size();)
367 {
368 std::size_t units = 1;
369 const auto first = static_cast<std::uint16_t>(added[index]);
370 if (first >= 0xD800U && first <= 0xDBFFU &&
371 index + 1U < added.size())
372 units = 2;
373 if (state.draftDescription.size() + units >= DescriptionUnits ||
374 !descriptionHasRoom(state.draftDescription, fontRuntime))
375 break;
376 state.draftDescription.append(added, index, units);
377 index += units;
378 changed = true;
379 }
380 if (changed) ++state.revision;
381 return result;
382 }
383
384 if (message.type != uimsg::Type::MouseLeftDown) return result;
385 const int x = static_cast<int>(message.numberA);
386 const int y = static_cast<int>(message.numberB);
387 for (std::size_t slot = 0; slot < SaveSlotCount; ++slot)
388 {
389 if (!saveSlotRect(slot).contains(x, y)) continue;
390 if (state.dialog == FileDialogMode::Load && !state.slots[slot].occupied)
391 return result;
392 state.selectedSlot = static_cast<int>(slot);
393 state.draftDescription.clear();
394 ++state.revision;
395 return result;
396 }
397
398 const auto okay = textMetrics(ButtonOkayTextId, fontRuntime, resources);
399 const auto cancel = textMetrics(ButtonCancelTextId, fontRuntime, resources);
400 if (okay && dialogOkayRect(okay->first, okay->second).contains(x, y))
401 {
402 requestOkay();
403 return result;
404 }
405 if (cancel && dialogCancelRect(cancel->first, cancel->second).contains(x, y))
406 {
407 result.closeDialog = true;
408 result.playClick = true;
409 return result;
410 }
411 return result;
412 }
413
414 std::expected<std::vector<std::uint8_t>, std::string>
416 {
417 if (state.dialog != FileDialogMode::Load ||
418 state.selectedSlot < 0 ||
419 state.selectedSlot >= static_cast<int>(SaveSlotCount))
420 return std::unexpected("no load-game slot is selected");
421 const auto slot = static_cast<std::size_t>(state.selectedSlot);
422 if (!state.slots[slot].occupied)
423 return std::unexpected("selected load-game slot is empty");
424 const auto bytes = readFile(gameBlobPath(slot));
425 if (!bytes) return std::unexpected(bytes.error());
426 if (bytes->size() < 12U)
427 return std::unexpected("saved game blob is shorter than the retail minimum");
428 return *bytes;
429 }
430
431 std::expected<void, std::string> beginPendingSave(
433 const rules::GameState& ruleState,
434 int city,
435 int system,
436 std::string customBoardName,
438 {
439 if (state.pendingSaveSlot)
440 return std::unexpected("a save-game request is already pending");
442 return std::unexpected("no local save-game requester exists");
443 if (state.dialog != FileDialogMode::Save ||
444 state.selectedSlot < 0 ||
445 state.selectedSlot >= static_cast<int>(SaveSlotCount))
446 return std::unexpected("no save-game slot is selected");
447
448 const auto slot = static_cast<std::size_t>(state.selectedSlot);
450 metadata.description = state.draftDescription;
451 metadata.city = city;
452 metadata.system = system;
453 if (city == -1)
454 {
455 if (customBoardName.size() >= CustomBoardBytes)
456 customBoardName.resize(CustomBoardBytes - 1U);
457 metadata.customBoardName = std::move(customBoardName);
458 }
459 for (std::size_t square = 0; square < rules::SquareCount; ++square)
460 metadata.squareGameEarnings[square] =
461 legacyEarning(ruleState.squares[square].gameEarnings);
462
463 state.pendingSaveSlot = slot;
464 state.pendingMetadata = std::move(metadata);
465 state.pendingSaveRequester = requester;
466 return {};
467 }
468
470 {
471 state.pendingSaveSlot.reset();
472 state.pendingMetadata = {};
473 state.pendingSaveRequester = rules::NobodyPlayer;
474 }
475
476 std::expected<void, std::string> persistPendingSave(
478 std::span<const std::uint8_t> gameBlob)
479 {
480 if (!state.pendingSaveSlot || *state.pendingSaveSlot >= SaveSlotCount)
481 return std::unexpected("no pending save-game request exists");
482 const auto slot = *state.pendingSaveSlot;
483 const auto metadata = state.pendingMetadata;
485 if (gameBlob.empty())
486 return std::unexpected("RULE returned an empty save-game blob");
487
488 const auto root = saveGameDirectory();
489 if (root.empty())
490 return std::unexpected("SDL base path is unavailable for savegame storage");
491 std::error_code error;
492 std::filesystem::create_directories(root, error);
493 if (error)
494 return std::unexpected("unable to create savegame directory: " + error.message());
495
496 // Retail writes the sidecar first, but a sidecar failure does not prevent
497 // the authoritative .msv blob from being attempted.
498 const auto encoded = encodeMetadata(metadata);
501 if (!blobWrite) return std::unexpected(blobWrite.error());
502
503 state.slots[slot].occupied = true;
504 state.slots[slot].metadata = metadata;
505 ++state.revision;
507 return {};
508 }
509
510 std::expected<void, std::string> applySelectedMetadata(
511 const SaveRuntimeState& state,
512 rules::GameState& ruleState,
513 int& city,
514 int& system)
515 {
516 if (state.selectedSlot < 0 ||
517 state.selectedSlot >= static_cast<int>(SaveSlotCount))
518 return std::unexpected("no saved-game metadata slot is selected");
519 const auto slot = static_cast<std::size_t>(state.selectedSlot);
520 if (!state.slots[slot].occupied)
521 return std::unexpected("selected saved-game metadata slot is empty");
522
523 const auto& metadata = state.slots[slot].metadata;
524 city = metadata.city;
525 system = metadata.system;
526 for (std::size_t square = 0; square < rules::SquareCount; ++square)
527 ruleState.squares[square].gameEarnings =
528 static_cast<std::int64_t>(metadata.squareGameEarnings[square]);
529 return {};
530 }
531}
std::size_t count
std::size_t offset
std::size_t root
std::string_view text
LegacyBitmapMetadata metadata
std::vector< std::byte > bytes
int top
PlayerNumber slot
std::uint64_t key
std::uint32_t height
std::uint32_t width
unsigned state
unsigned index
data::DataTag first
float units
std::string path
std::string error
bool valid
Bytes input
std::deque< Bytes > output
std::uint16_t value
Definition Timers.cpp:16
void closeSaveDialog(SaveRuntimeState &state) noexcept
SaveDialogInput processSaveDialogInput(SaveRuntimeState &state, const uimsg::Message &message, fonts::Runtime *fontRuntime, std::shared_ptr< const data::ResourceSnapshot > resources)
std::expected< void, std::string > persistPendingSave(SaveRuntimeState &state, std::span< const std::uint8_t > gameBlob)
Rect saveSlotRect(std::size_t slot) noexcept
Rect dialogOkayRect(int width, int height) noexcept
constexpr std::size_t SaveSlotCount
std::expected< std::vector< std::uint8_t >, std::string > readSelectedGameBlob(const SaveRuntimeState &state)
void clearPendingSave(SaveRuntimeState &state) noexcept
std::expected< void, std::string > refreshSaveSlots(SaveRuntimeState &state, FileDialogMode mode)
std::filesystem::path gameMetadataPath(std::size_t slot)
std::filesystem::path gameBlobPath(std::size_t slot)
std::expected< void, std::string > applySelectedMetadata(const SaveRuntimeState &state, rules::GameState &ruleState, int &city, int &system)
std::filesystem::path saveGameDirectory()
std::expected< void, std::string > beginPendingSave(SaveRuntimeState &state, const rules::GameState &ruleState, int city, int system, std::string customBoardName, rules::PlayerNumber requester)
Rect dialogCancelRect(int width, int height) noexcept
std::uint8_t PlayerNumber
Definition RuleTypes.hpp:12
constexpr std::size_t SquareCount
Definition RuleTypes.hpp:24
constexpr PlayerNumber MaxPlayers
Definition RuleTypes.hpp:14
constexpr PlayerNumber NobodyPlayer
Definition RuleTypes.hpp:16
std::shared_ptr< const data::ResourceSnapshot > resources() noexcept
std::size_t size()
bool contains(int x, int y) const noexcept
Definition OptionsUI.hpp:80
std::array< SquareState, SquareCount > squares