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