Porytiles
Loading...
Searching...
No Matches
overload_and_remove_strategy.cpp
Go to the documentation of this file.
2
3#include <algorithm>
4#include <array>
5#include <cstddef>
6#include <cstdint>
7#include <deque>
8#include <format>
9#include <map>
10#include <optional>
11#include <random>
12#include <set>
13#include <string>
14#include <utility>
15#include <vector>
16
21
22namespace {
23
24using namespace porytiles;
25
27struct TileInfo {
28 PackableTile tile;
29 std::set<std::size_t> forbidden_palettes;
30
31 explicit TileInfo(PackableTile t) : tile{std::move(t)}, forbidden_palettes{} {}
32};
33
51[[nodiscard]] std::optional<std::size_t> find_best_palette_excluding_forbidden(
52 const TileInfo &info,
53 const std::vector<PackedPalette> &palettes,
54 bool force_assignment = false,
55 const ShapeGroupMetadata *metadata = nullptr)
56{
57 std::optional<std::size_t> best_idx;
58 double best_cost = std::numeric_limits<double>::max();
59
60 for (std::size_t i = 0; i < palettes.size(); ++i) {
61 // Skip forbidden palettes
62 if (info.forbidden_palettes.contains(i)) {
63 continue;
64 }
65
66 // Use fast metric function with cached color counts - O(colors) instead of O(tiles × colors)
67 double cost = compute_weighted_cost_in_palette_fast(info.tile.color_set(), palettes[i]);
68
69 // Add sharing penalty to deprioritize palettes that already contain a shape group sibling
70 if (metadata != nullptr) {
71 cost += compute_sharing_penalty(info.tile, palettes[i], *metadata);
72 }
73
74 if (cost < best_cost) {
75 best_cost = cost;
76 best_idx = i;
77 }
78 }
79
80 // If best cost equals tile size (no overlap benefit), return nullopt to create new palette.
81 // This happens when all colors in the tile are new to the palette (each color contributes 1.0).
82 // Skip this check when force_assignment is true, since the caller wants a palette regardless.
83 if (!force_assignment && best_idx.has_value() && best_cost >= static_cast<double>(info.tile.color_count())) {
84 return std::nullopt;
85 }
86
87 return best_idx;
88}
89
90struct OarParams {
91 ShuffleStrategy shuffle_strategy;
92 std::size_t max_attempts;
93 std::uint64_t seed;
94};
95
107[[nodiscard]] std::array<OarParams, 17> build_preset_matrix()
108{
109 std::array<OarParams, 17> matrix{};
110 std::size_t idx = 0;
111
112 constexpr std::array<std::uint64_t, 4> seeds = {42, 123, 456, 789};
113
114 // Phase 1: single FFD (1 entry)
115 matrix[idx++] = OarParams{ShuffleStrategy::single_ffd, 1, 42};
116
117 // Phase 2: noisy_ffd with 20 attempts (4 entries)
118 for (std::uint64_t seed : seeds) {
119 matrix[idx++] = OarParams{ShuffleStrategy::noisy_ffd, 20, seed};
120 }
121
122 // Phase 3: random with 20 attempts (4 entries)
123 for (std::uint64_t seed : seeds) {
124 matrix[idx++] = OarParams{ShuffleStrategy::random, 20, seed};
125 }
126
127 // Phase 4: noisy_ffd with 75 attempts (4 entries)
128 for (std::uint64_t seed : seeds) {
129 matrix[idx++] = OarParams{ShuffleStrategy::noisy_ffd, 75, seed};
130 }
131
132 // Phase 5: random with 75 attempts (4 entries)
133 for (std::uint64_t seed : seeds) {
134 matrix[idx++] = OarParams{ShuffleStrategy::random, 75, seed};
135 }
136
137 return matrix;
138}
139
140[[nodiscard]] std::string format_oar_params_line(const OarParams &params)
141{
142 return std::format(
143 "shuffle_strategy={}, max_attempts={}, seed={}.",
144 to_string(params.shuffle_strategy),
145 params.max_attempts,
146 params.seed);
147}
148
149void emit_success_remark(const UserDiagnostics &diag, const OarParams &params, bool is_preset)
150{
151 std::vector<std::string> lines;
152 if (is_preset) {
153 lines.emplace_back("Overload-and-Remove search succeeded with preset config:");
154 }
155 else {
156 lines.emplace_back("Overload-and-Remove search succeeded:");
157 }
158 lines.emplace_back(format_oar_params_line(params));
159 diag.remark("overload-and-remove-search", lines);
160}
161
162} // namespace
163
164namespace porytiles {
165
167{
168 if (use_preset_matrix_) {
169 auto matrix = build_preset_matrix();
170
171 for (const auto &params : matrix) {
172 auto result = run_multi_start(input, params.shuffle_strategy, params.max_attempts, params.seed);
173 if (result.has_value()) {
174 if (diag_ != nullptr) {
175 emit_success_remark(*diag_, params, true);
176 }
177 return result;
178 }
179 }
180
181 return FormattableError{
182 "Overload-and-Remove strategy failed to find a valid palette assignment after all preset configurations."};
183 }
184
185 // Single-config mode: run one multi-start search with the configured parameters
186 OarParams params{shuffle_strategy_, max_attempts_, seed_};
187 auto result = run_multi_start(input, shuffle_strategy_, max_attempts_, seed_);
188 if (result.has_value()) {
189 if (diag_ != nullptr) {
190 emit_success_remark(*diag_, params, false);
191 }
192 return result;
193 }
194
195 return FormattableError{
196 "Overload-and-Remove strategy failed to find a valid palette assignment with the configured parameters."};
197}
198
199ChainableResult<PackingOutput> OverloadAndRemoveStrategy::run_multi_start(
200 const PackingInput &input, ShuffleStrategy shuffle_strategy, std::size_t max_attempts, std::uint64_t seed) const
201{
202 // First attempt: FFD ordering (deterministic, theoretically best for bin packing)
203 auto first_result = try_pack(input, shuffle_strategy, std::nullopt);
204 if (first_result.has_value() || shuffle_strategy == ShuffleStrategy::single_ffd || max_attempts <= 1) {
205 return first_result;
206 }
207
208 // Subsequent attempts: orderings determined by shuffle_strategy with seeded PRNG
209 std::mt19937_64 seed_generator{seed};
210 for (std::size_t attempt = 1; attempt < max_attempts; ++attempt) {
211 std::uint64_t shuffle_seed = seed_generator();
212 auto result = try_pack(input, shuffle_strategy, shuffle_seed);
213 if (result.has_value()) {
214 return result;
215 }
216 }
217
218 // All attempts failed. Return the first attempt's error (most informative).
219 return first_result;
220}
221
222ChainableResult<PackingOutput> OverloadAndRemoveStrategy::try_pack(
223 const PackingInput &input, ShuffleStrategy shuffle_strategy, std::optional<std::uint64_t> shuffle_seed) const
224{
225 PackingOutput output;
226 PalettePool palette_pool = input.palette_pool_;
227
228 // Extract shape group metadata pointer (nullptr when not sharing-aware)
229 const ShapeGroupMetadata *metadata =
230 input.shape_group_metadata_.has_value() ? &input.shape_group_metadata_.value() : nullptr;
231
232 // Initialize output palettes from prefilled palettes
234
235 // Ensure we have at least one palette
236 if (output.palettes_.empty()) {
237 if (!palette_pool.has_available_palette()) {
238 return FormattableError{"Overload-And-Remove: no palettes available in pool."};
239 }
240 output.palettes_.emplace_back(palette_pool.checkout(), input.palette_capacity_);
241 }
242
243 // Build tile pool (hints first, then regular tiles)
244 std::deque<TileInfo> tile_pool;
245 for (const auto &hint : input.hints_) {
246 tile_pool.emplace_back(hint);
247 }
248 for (const auto &tile : input.tiles_) {
249 tile_pool.emplace_back(tile);
250 }
251
252 if (tile_pool.empty()) {
253 return output;
254 }
255
256 // Right now, we mix together the hints and regular tiles before sorting. Do we want this? I think it's probably ok,
257 // since hints still guarantee that colors in the same hint will be in the same palette. And if the user supplied
258 // hints that are larger than any individual tile, they'll go first as expected. However, I think it makes sense to
259 // allow regular tiles that are large to go before smaller hints, since this probably helps to find an optimal
260 // result -- that larger tile *has to* get put somewhere in order for a solution to be found. No sense placing the
261 // hint first, only to block ourselves from finding a possible solution down the line. In other words, the promised
262 // hint precondition is not violated, and we potentially get a better solution.
263
264 // Order tiles based on shuffle strategy
265 if (shuffle_seed.has_value()) {
266 std::mt19937_64 rng{shuffle_seed.value()};
267 std::ranges::shuffle(tile_pool, rng);
268 if (shuffle_strategy == ShuffleStrategy::noisy_ffd) {
269 // Noisy FFD: shuffle first for random tiebreaking, then stable_sort by color_count descending.
270 // This preserves the large-first FFD property while randomly reordering tiles of equal size.
271 std::ranges::stable_sort(tile_pool, [](const TileInfo &a, const TileInfo &b) {
272 return a.tile.color_count() > b.tile.color_count();
273 });
274 }
275 // ShuffleStrategy::random: just the shuffle above (original behavior)
276 }
277 else {
278 // FFD: sort by color count descending (deterministic first attempt for all strategies)
279 std::ranges::stable_sort(tile_pool, [](const TileInfo &a, const TileInfo &b) {
280 return a.tile.color_count() > b.tile.color_count();
281 });
282 }
283
284 // Pop first tile and assign to first available palette
285 TileInfo first_tile_info = std::move(tile_pool.front());
286 tile_pool.pop_front();
287
288 // Find or create a palette for the first tile. First, we try searching through the current state output palettes.
289 // If we find one, use it! If we don't find one, then try checking a new one out from our PalettePool if one is
290 // available. If there is no palette available, fail.
291 bool first_assigned = false;
292 for (std::size_t i = 0; i < output.palettes_.size(); ++i) {
293 if (output.palettes_[i].can_fit(first_tile_info.tile.color_set())) {
294 output.palettes_[i].add_tile(first_tile_info.tile);
295 output.tile_to_palette_[first_tile_info.tile.id()] = output.palettes_[i].hardware_index();
296 first_assigned = true;
297 break;
298 }
299 }
300 if (!first_assigned) {
301 if (palette_pool.has_available_palette()) {
302 output.palettes_.emplace_back(palette_pool.checkout(), input.palette_capacity_);
303 output.palettes_.back().add_tile(first_tile_info.tile);
304 output.tile_to_palette_[first_tile_info.tile.id()] = output.palettes_.back().hardware_index();
305 }
306 else {
307 return FormattableError{"Overload-And-Remove: first tile cannot fit in any palette."};
308 }
309 }
310
311 // Build a map of tile_id -> ColorSet from all input tiles.
312 // Note: This is only needed for recreating TileInfo when tiles are removed from palettes.
313 // Palette-local cost computation now uses cached color counts in PackedPalette.
314 std::map<PackableTile::Id, ColorSet> tile_colors_map;
315 for (const auto &hint : input.hints_) {
316 tile_colors_map[hint.id()] = hint.color_set();
317 }
318 for (const auto &tile : input.tiles_) {
319 tile_colors_map[tile.id()] = tile.color_set();
320 }
321
322 // Track forbidden palettes for each tile across removal cycles
323 // This ensures termination: a tile can never return to a palette it was removed from
324 std::map<PackableTile::Id, std::set<std::size_t>> forbidden_map;
325
326 // Main loop: process tiles from pool
327 while (!tile_pool.empty()) {
328 TileInfo tile_info = std::move(tile_pool.front());
329 tile_pool.pop_front();
330
331 // Find best palette excluding forbidden ones (using cached palette color counts)
332 auto maybe_best_idx = find_best_palette_excluding_forbidden(tile_info, output.palettes_, false, metadata);
333
334 if (!maybe_best_idx.has_value()) {
335 // Create new palette if possible
336 if (palette_pool.has_available_palette()) {
337 output.palettes_.emplace_back(palette_pool.checkout(), input.palette_capacity_);
338 output.palettes_.back().add_tile(tile_info.tile);
339 output.tile_to_palette_[tile_info.tile.id()] = output.palettes_.back().hardware_index();
340 continue;
341 }
342
343 // Pool exhausted and no palette offers overlap benefit. Try two fallback strategies:
344 //
345 // 1. First-fit with can_fit: fast, no cascading removals. Succeeds when a palette has physical room.
346 // 2. Force-assignment with overload/remove: slower but more capable. Allows the overload/remove mechanism
347 // to redistribute tiles. Termination guaranteed because forbidden sets grow monotonically.
348
349 // Fallback 1: strict first-fit (no overload)
350 bool assigned = false;
351 for (std::size_t i = 0; i < output.palettes_.size(); ++i) {
352 if (!tile_info.forbidden_palettes.contains(i) &&
353 output.palettes_[i].can_fit(tile_info.tile.color_set())) {
354 output.palettes_[i].add_tile(tile_info.tile);
355 output.tile_to_palette_[tile_info.tile.id()] = output.palettes_[i].hardware_index();
356 assigned = true;
357 break;
358 }
359 }
360 if (assigned) {
361 continue;
362 }
363
364 // Fallback 2: force-assign to least-bad palette, let overload/remove handle it
365 maybe_best_idx = find_best_palette_excluding_forbidden(tile_info, output.palettes_, true, metadata);
366 if (!maybe_best_idx.has_value()) {
367 return FormattableError{
368 "Overload-and-Remove: cannot assign tile - all palettes forbidden - " +
369 to_string(tile_info.tile.id())};
370 }
371 // Fall through to add_tile + overload/remove loop below
372 }
373
374 auto best_idx = maybe_best_idx.value();
375
376 // Add tile to best palette (may cause overload)
377 auto &best_palette = output.palettes_[best_idx];
378 best_palette.add_tile(tile_info.tile);
379 output.tile_to_palette_[tile_info.tile.id()] = best_palette.hardware_index();
380
381 // Handle overload by removing worst-fitting tiles
382 while (best_palette.color_count() > input.palette_capacity_) {
383 const auto &assigned_ids = best_palette.assigned_tile_ids();
384 if (assigned_ids.size() <= 1) {
385 break; // Can't remove the only tile
386 }
387
388 // Find tile with minimum efficiency using fast O(colors) computation
389 // Uses cached color counts in PackedPalette instead of rebuilding multiplicity map
390 double min_efficiency = std::numeric_limits<double>::max();
391 double max_efficiency = std::numeric_limits<double>::lowest();
392
393 PackableTile::Id worst_tile_id = assigned_ids.front();
394 for (PackableTile::Id tid : assigned_ids) {
395 // Skip system tiles from fixed palettes -- these cannot be changed
396 if (std::holds_alternative<PackableTile::PrefilledPaletteId>(tid)) {
397 continue;
398 }
399
400 const auto it = tile_colors_map.find(tid);
401 if (it == tile_colors_map.end()) {
402 continue;
403 }
404
405 // Use fast efficiency function with cached color counts
406 double eff = compute_palette_local_efficiency_fast(it->second, best_palette);
407 if (eff < min_efficiency) {
408 min_efficiency = eff;
409 worst_tile_id = tid;
410 }
411 if (eff > max_efficiency) {
412 max_efficiency = eff;
413 }
414 }
415
416 // If all tiles have same efficiency, use tiebreakers instead of giving up
417 if (std::abs(min_efficiency - max_efficiency) < 1e-9) {
418 // Primary tiebreaker: remove tile with most colors (frees most palette capacity)
419 // Secondary tiebreaker: among equal color counts, remove most recently added (LIFO)
420 std::optional<std::size_t> best_removal_pos;
421 std::size_t best_color_count = 0;
422
423 for (std::size_t pos = 0; pos < assigned_ids.size(); ++pos) {
424 const auto &tid = assigned_ids[pos];
425 // Never remove prefilled palette tiles
426 if (std::holds_alternative<PackableTile::PrefilledPaletteId>(tid)) {
427 continue;
428 }
429 const auto it = tile_colors_map.find(tid);
430 if (it == tile_colors_map.end()) {
431 continue;
432 }
433
434 std::size_t cc = color_set_count(it->second);
435 // Prefer higher color count (frees more capacity), then later position (LIFO)
436 if (!best_removal_pos.has_value() || cc > best_color_count ||
437 (cc == best_color_count && pos > best_removal_pos.value())) {
438 best_removal_pos = pos;
439 best_color_count = cc;
440 }
441 }
442
443 // If no removable tile found (only prefilled tiles), truly stuck
444 if (!best_removal_pos.has_value()) {
445 break;
446 }
447
448 worst_tile_id = assigned_ids[best_removal_pos.value()];
449 }
450
451 // Remove worst tile and re-add to pool with forbidden marker
452 best_palette.remove_tile(worst_tile_id);
453 output.tile_to_palette_.erase(worst_tile_id);
454
455 // Record this palette as forbidden for this tile (persists across removal cycles)
456 forbidden_map[worst_tile_id].insert(best_idx);
457
458 if (const auto colors_it = tile_colors_map.find(worst_tile_id); colors_it != tile_colors_map.end()) {
459 TileInfo removed_info{PackableTile{worst_tile_id, colors_it->second}};
460 // Restore ALL accumulated forbidden palettes for this tile
461 removed_info.forbidden_palettes = forbidden_map[worst_tile_id];
462 tile_pool.push_back(std::move(removed_info));
463 }
464 }
465 }
466
467 // Final cleanup: remove tiles from any remaining overloaded palettes
468 std::vector<TileInfo> remaining_tile_pool{};
469 for (auto &palette : output.palettes_) {
470 while (palette.color_count() > input.palette_capacity_ && !palette.assigned_tile_ids().empty()) {
471 // Search from the back for the last removable (non-prefilled) tile
472 const auto &ids = palette.assigned_tile_ids();
473 std::optional<PackableTile::Id> removable_tid;
474 for (auto it = ids.rbegin(); it != ids.rend(); ++it) {
475 if (!std::holds_alternative<PackableTile::PrefilledPaletteId>(*it)) {
476 removable_tid = *it;
477 break;
478 }
479 }
480 if (!removable_tid.has_value()) {
481 break; // Only prefilled tiles remain, nothing left to remove
482 }
483
484 palette.remove_tile(removable_tid.value());
485 output.tile_to_palette_.erase(removable_tid.value());
486
487 if (const auto it = tile_colors_map.find(removable_tid.value()); it != tile_colors_map.end()) {
488 remaining_tile_pool.emplace_back(PackableTile{removable_tid.value(), it->second});
489 }
490 }
491 }
492
493 // First-Fit pass for remaining tiles
494 for (auto &tile_info : remaining_tile_pool) {
495 bool assigned = false;
496 for (std::size_t i = 0; i < output.palettes_.size(); ++i) {
497 if (output.palettes_[i].can_fit(tile_info.tile.color_set())) {
498 output.palettes_[i].add_tile(tile_info.tile);
499 output.tile_to_palette_[tile_info.tile.id()] = output.palettes_[i].hardware_index();
500 assigned = true;
501 break;
502 }
503 }
504 if (!assigned) {
505 if (palette_pool.has_available_palette()) {
506 output.palettes_.emplace_back(palette_pool.checkout(), input.palette_capacity_);
507 output.palettes_.back().add_tile(tile_info.tile);
508 output.tile_to_palette_[tile_info.tile.id()] = output.palettes_.back().hardware_index();
509 }
510 else {
511 return FormattableError{
512 "Overload-and-Remove: cannot assign tile in final pass - " + to_string(tile_info.tile.id())};
513 }
514 }
515 }
516
517 return output;
518}
519
520} // namespace porytiles
A result type that maintains a chainable sequence of errors for debugging and error reporting.
General-purpose error implementation with formatted message support.
Definition error.hpp:57
ChainableResult< PackingOutput > pack(const PackingInput &input) const override
Packs tiles into palettes using the Overload-And-Remove algorithm with multi-start.
Wraps a ColorSet with a tile ID for tracking during palette packing.
std::variant< HintId, PrefilledPaletteId, RegularId, AnimId, PrimaryTileId > Id
Variant type for tile identification.
Manages allocation of hardware palette indexes with stack-based checkout semantics.
bool has_available_palette() const
Checks if there is at least one available palette that can be checked out.
std::size_t checkout()
Checks out the next available hardware palette index.
Abstract class for structured error reporting and diagnostic output.
virtual void remark(const std::string &tag, const std::vector< std::string > &lines) const =0
Display a tagged remark message.
double compute_weighted_cost_in_palette_fast(const ColorSet &tile_colors, const PackedPalette &palette)
Computes the weighted cost of placing a tile in a palette using cached counts.
double compute_sharing_penalty(const PackableTile &tile, const PackedPalette &palette, const ShapeGroupMetadata &metadata, double sharing_weight=0.5)
Computes the sharing penalty for placing a tile in a palette.
ShuffleStrategy
Controls how tile orderings are generated during multi-start packing.
@ single_ffd
One FFD attempt only, no multi-start retries.
@ noisy_ffd
FFD first, then perturbed FFD orderings that preserve the large-first property.
double compute_palette_local_efficiency_fast(const ColorSet &tile_colors, const PackedPalette &palette)
Computes the palette-local efficiency of a tile using cached counts.
std::size_t color_set_count(const ColorSet &set)
Counts the number of colors in a ColorSet.
Definition color_set.cpp:44
std::string to_string(const PrimaryPairingMode m)
Converts a PrimaryPairingMode to its canonical string representation.
std::vector< PackedPalette > initialize_packed_palettes(const std::set< PrefilledPalette > &prefilled_palettes, PalettePool &palette_pool, std::size_t palette_capacity)
Initializes packed palettes from prefilled palettes.
Metrics for Bin Packing with Overlapping Items (Pagination problem).
Input data aggregate for the low-level palette packing algorithm.
std::optional< ShapeGroupMetadata > shape_group_metadata_
Optional shape group metadata for sharing-aware packing.
PalettePool palette_pool_
A bitset marking which palettes are available for editing.
std::set< PrefilledPalette > prefilled_palettes_
Pre-assigned palettes with fixed colors.
std::size_t palette_capacity_
Maximum number of colors per palette.
The final palette assignments after a successful packing operation.
std::vector< PackedPalette > palettes_
The packed palettes with their colors and assigned tiles.
std::map< PackableTile::Id, std::size_t > tile_to_palette_
Maps tile IDs to their assigned hardware palette indices.
Metadata that maps PackableTile IDs to shape group membership for sharing-aware packing.