Porytiles
Loading...
Searching...
No Matches
palette_builder.cpp
Go to the documentation of this file.
2
3#include <algorithm>
4#include <map>
5#include <set>
6
9
10namespace porytiles {
11
12namespace {
13
17struct PaletteBuildState {
18 std::size_t hw_index;
19 std::map<Rgba32, ColorPosition> color_positions;
20 std::set<std::size_t> prefilled_slots;
21};
22
31[[nodiscard]] std::optional<std::size_t> try_resolve_indirect(
32 const IndirectPosition &start, const std::array<std::optional<PaletteBuildState>, pal::num_pals> &states)
33{
34 IndirectPosition current = start;
35 for (std::size_t iter = 0; iter < pal::num_pals; ++iter) {
36 if (!states.at(current.ref_pal_index).has_value()) {
37 return std::nullopt;
38 }
39 const auto &ref_state = states.at(current.ref_pal_index).value();
40 if (!ref_state.color_positions.contains(current.ref_color)) {
41 return std::nullopt;
42 }
43 const auto &ref_position = ref_state.color_positions.at(current.ref_color);
44
45 if (std::holds_alternative<AbsolutePosition>(ref_position)) {
46 return std::get<AbsolutePosition>(ref_position).slot;
47 }
48 else if (std::holds_alternative<IndirectPosition>(ref_position)) {
49 current = std::get<IndirectPosition>(ref_position);
50 }
51 else {
52 // Undetermined: reference palette not yet sequentially filled
53 return std::nullopt;
54 }
55 }
56 panic("Indirect chain resolution exceeded maximum iterations (cycle detected)");
57}
58
59} // namespace
60
61std::array<std::optional<Palette<Rgba32, pal::max_size>>, pal::num_pals> build_all_output_palettes(
62 const std::vector<PackedPalette> &packed_pals,
63 const std::array<std::optional<Palette<Rgba32, pal::max_size>>, pal::num_pals> &prefilled_pals,
64 const ColorIndexMap<Rgba32> &color_map,
65 const Rgba32 &default_slot_zero,
66 const std::vector<IndirectLink> &indirect_links,
67 AlignmentFailureCounts *failure_counts)
68{
69 // Build per-palette state indexed by hardware index
70 std::array<std::optional<PaletteBuildState>, pal::num_pals> states{};
71
72 // Phase 1: Initialize position maps
73 for (const PackedPalette &packed_pal : packed_pals) {
74 const std::size_t hw = packed_pal.hardware_index();
75 if (hw >= pal::num_pals) {
76 panic("invalid hardware index " + std::to_string(hw) + ": out of range");
77 }
78
79 PaletteBuildState state;
80 state.hw_index = hw;
81
82 const Palette<Rgba32, pal::max_size> *prefilled_ptr =
83 prefilled_pals.at(hw).has_value() ? &prefilled_pals.at(hw).value() : nullptr;
84
85 // Track prefilled (locked) slots
86 if (prefilled_ptr != nullptr) {
87 for (std::size_t i = 1; i < pal::max_size; ++i) {
88 if (!prefilled_ptr->is_wildcard(i)) {
89 state.prefilled_slots.insert(i);
90 }
91 }
92 }
93
94 // Collect colors from PackedPalette that need to be placed.
95 // Use for_each_color to iterate in the same order as the old build_output_palette.
96 std::set<Rgba32> already_placed_colors;
97
98 // Colors from prefilled slots are already placed
99 if (prefilled_ptr != nullptr) {
100 for (std::size_t i = 1; i < pal::max_size; ++i) {
101 if (!prefilled_ptr->is_wildcard(i)) {
102 already_placed_colors.insert(prefilled_ptr->at(i));
103 // Prefilled colors get Absolute positions at their locked slots
104 state.color_positions[prefilled_ptr->at(i)] = AbsolutePosition{i};
105 }
106 }
107 }
108
109 // Remaining colors from the packed palette start as Undetermined
110 for_each_color(packed_pal.color_set(), [&](const std::size_t color_index) {
111 const auto color_opt = color_map.color_at_index(ColorIndex{color_index});
112 if (!color_opt.has_value()) {
113 panic("color_index " + std::to_string(color_index) + " not found in color map");
114 }
115 const auto &color = color_opt.value();
116 if (!already_placed_colors.contains(color)) {
117 state.color_positions[color] = UndeterminedPosition{};
118 }
119 });
120
121 states.at(hw) = std::move(state);
122 }
123
124 // Track successfully applied indirect links for post-resolution verification
125 struct AppliedIndirect {
126 std::size_t source_pal;
127 Rgba32 source_color;
128 std::size_t ref_pal;
129 Rgba32 ref_color;
130 std::size_t source_group_index;
131 };
132 std::vector<AppliedIndirect> applied_indirects;
133
134 // Phase 2: Apply Indirect links
135 for (const auto &link : indirect_links) {
136 if (!states.at(link.source_pal).has_value()) {
137 continue;
138 }
139 auto &state = states.at(link.source_pal).value();
140
141 if (!state.color_positions.contains(link.source_color)) {
142 continue;
143 }
144
145 auto &position = state.color_positions.at(link.source_color);
146 // First-writer-wins: only set Indirect on Undetermined positions (prevents cycles)
147 if (std::holds_alternative<UndeterminedPosition>(position)) {
148 position = IndirectPosition{link.ref_pal, link.ref_color, link.source_group_index};
149 // Don't record here. Phase 4 will record if resolution succeeds.
150 }
151 else if (std::holds_alternative<IndirectPosition>(position)) {
152 const auto &existing = std::get<IndirectPosition>(position);
153 // Compatible: same reference, no actual conflict
154 if (existing.ref_pal_index == link.ref_pal && existing.ref_color == link.ref_color) {
155 // The existing IndirectPosition already satisfies this link. Record for post-verification
156 // (Phase 4 will only record the winning group, so compatible groups need recording here)
157 applied_indirects.push_back(
158 AppliedIndirect{
159 link.source_pal, link.source_color, link.ref_pal, link.ref_color, link.source_group_index});
160 continue;
161 }
162 if (failure_counts != nullptr) {
163 failure_counts->first_writer_wins_details.push_back(
164 FirstWriterWinsDetail{
165 .source_group_index = link.source_group_index,
166 .source_pal_index = link.source_pal,
167 .source_color = link.source_color,
168 .winning_group_index = existing.source_group_index,
169 .winning_ref_pal_index = existing.ref_pal_index,
170 .winning_ref_color = existing.ref_color,
171 .losing_ref_pal_index = link.ref_pal,
172 .losing_ref_color = link.ref_color});
173 }
174 }
175 else if (std::holds_alternative<AbsolutePosition>(position)) {
176 // Link dropped: source color is prefilled (locked). Phase 1 set it to AbsolutePosition.
177 // However, if the ref color in the ref palette is also AbsolutePosition at the same slot,
178 // alignment is naturally satisfied, so there is no conflict to report.
179 bool naturally_aligned = false;
180 const auto source_slot = std::get<AbsolutePosition>(position).slot;
181 if (states.at(link.ref_pal).has_value()) {
182 const auto &ref_state = states.at(link.ref_pal).value();
183 if (ref_state.color_positions.contains(link.ref_color)) {
184 const auto &ref_position = ref_state.color_positions.at(link.ref_color);
185 if (std::holds_alternative<AbsolutePosition>(ref_position) &&
186 std::get<AbsolutePosition>(ref_position).slot == source_slot) {
187 naturally_aligned = true;
188 }
189 }
190 }
191 if (!naturally_aligned && failure_counts != nullptr) {
192 failure_counts->prefilled_source_conflict_details.push_back(
193 PrefilledSourceConflictDetail{
194 .source_group_index = link.source_group_index,
195 .source_pal_index = link.source_pal,
196 .source_color = link.source_color,
197 .ref_pal_index = link.ref_pal,
198 .ref_color = link.ref_color});
199 }
200 }
201 }
202
203 /*
204 * === Phase 3: Sequential fill ALL palettes (skipping Indirect) ===
205 *
206 * Every Undetermined color gets an Absolute slot. Indirect colors are left untouched; they'll be resolved in
207 * Phase 4. After this phase, all reference colors (which are Undetermined, not Indirect) have stable Absolute
208 * positions, enabling Indirect chain resolution.
209 */
210 for (auto &state_opt : states) {
211 if (!state_opt.has_value()) {
212 continue;
213 }
214 auto &state = state_opt.value();
215
216 // Collect slots already used by Absolute positions (prefilled)
217 std::set<std::size_t> used_slots;
218 used_slots.insert(0); // Slot 0 is always reserved
219 for (const auto &[color, position] : state.color_positions) {
220 if (std::holds_alternative<AbsolutePosition>(position)) {
221 used_slots.insert(std::get<AbsolutePosition>(position).slot);
222 }
223 }
224
225 // Assign next free slot to each Undetermined color; skip Indirect colors
226 std::size_t next_slot = 1;
227 for (auto &[color, position] : state.color_positions) {
228 if (!std::holds_alternative<UndeterminedPosition>(position)) {
229 continue;
230 }
231 while (next_slot < pal::max_size && used_slots.contains(next_slot)) {
232 ++next_slot;
233 }
234 if (next_slot < pal::max_size) {
235 position = AbsolutePosition{next_slot};
236 used_slots.insert(next_slot);
237 ++next_slot;
238 }
239 else {
240 panic("ran out of palette slots during sequential fill for palette " + std::to_string(state.hw_index));
241 }
242 }
243 }
244
245 /*
246 * === Phase 4: Resolve Indirect chains with eviction ===
247 *
248 * Now all reference colors have Absolute positions (from Phase 3). Resolve each Indirect color to the reference
249 * color's slot. If the target slot is already occupied by a sequential-fill color, evict the occupant to the next
250 * free slot. Prefilled slots are never evicted.
251 *
252 * This handles cross-palette Indirect dependencies (where palette A links to B and B links to A for different
253 * shape groups) without deadlocking, because the reference colors are always Undetermined (not Indirect) and
254 * were assigned Absolute positions in Phase 3.
255 */
256 for (std::size_t pal_index = 0; pal_index < states.size(); ++pal_index) {
257 if (!states.at(pal_index).has_value()) {
258 continue;
259 }
260 auto &state = states.at(pal_index).value();
261
262 // Collect all Indirect colors and their resolved target slots
263 struct IndirectResolution {
264 Rgba32 color;
265 std::size_t target_slot;
266 std::size_t source_group_index;
267 std::size_t ref_pal_index;
268 Rgba32 ref_color;
269 };
270 std::vector<IndirectResolution> resolutions;
271
272 for (const auto &[color, position] : state.color_positions) {
273 if (!std::holds_alternative<IndirectPosition>(position)) {
274 continue;
275 }
276
277 const auto &indirect_pos = std::get<IndirectPosition>(position);
278 auto resolved = try_resolve_indirect(indirect_pos, states);
279 if (!resolved.has_value()) {
280 panic(
281 "Indirect chain resolution returned nullopt for color in palette " + std::to_string(pal_index) +
282 ": internal invariant violated");
283 }
284
285 // Skip if the target slot is prefilled (can't evict prefilled)
286 if (state.prefilled_slots.contains(resolved.value())) {
287 if (failure_counts != nullptr) {
288 // Capture detail: find the color occupying the locked slot
289 Rgba32 locked_color{};
290 for (const auto &[c, p] : state.color_positions) {
291 if (std::holds_alternative<AbsolutePosition>(p) &&
292 std::get<AbsolutePosition>(p).slot == resolved.value()) {
293 locked_color = c;
294 break;
295 }
296 }
297 failure_counts->prefilled_destination_conflict_details.push_back(
298 PrefilledDestinationConflictDetail{
299 .source_group_index = indirect_pos.source_group_index,
300 .palette_index = pal_index,
301 .target_slot = resolved.value(),
302 .blocked_color = color,
303 .locked_color = locked_color});
304 }
305 continue;
306 }
307
308 resolutions.push_back(
309 IndirectResolution{
310 color,
311 resolved.value(),
312 indirect_pos.source_group_index,
313 indirect_pos.ref_pal_index,
314 indirect_pos.ref_color});
315 }
316
317 // Apply resolutions with eviction
318 for (const auto &[indirect_color, target_slot, source_group_index, res_ref_pal, res_ref_color] : resolutions) {
319 // Check if the target slot is occupied by a sequential-fill color
320 Rgba32 evicted_color{};
321 bool needs_eviction = false;
322
323 for (auto &[color, position] : state.color_positions) {
324 if (color == indirect_color) {
325 continue;
326 }
327 if (std::holds_alternative<AbsolutePosition>(position) &&
328 std::get<AbsolutePosition>(position).slot == target_slot &&
329 !state.prefilled_slots.contains(target_slot)) {
330 evicted_color = color;
331 needs_eviction = true;
332 break;
333 }
334 }
335
336 if (needs_eviction) {
337 /*
338 * Find next free slot for the evicted color. Note: target_slot stays in all_used so the free
339 * slot search won't pick target_slot itself (that slot is being claimed by the Indirect color).
340 */
341 std::set<std::size_t> all_used;
342 all_used.insert(0);
343 for (const auto &[c, p] : state.color_positions) {
344 if (std::holds_alternative<AbsolutePosition>(p)) {
345 all_used.insert(std::get<AbsolutePosition>(p).slot);
346 }
347 }
348 std::size_t free_slot = 1;
349 while (free_slot < pal::max_size && all_used.contains(free_slot)) {
350 ++free_slot;
351 }
352 if (free_slot < pal::max_size) {
353 state.color_positions.at(evicted_color) = AbsolutePosition{free_slot};
354 }
355 else {
356 panic(
357 "no free slot for eviction in palette " + std::to_string(pal_index) +
358 ": internal invariant violated. Phase 3 guarantees one free slot per Indirect color.");
359 }
360 }
361
362 // Place the Indirect color at the target slot
363 state.color_positions.at(indirect_color) = AbsolutePosition{target_slot};
364
365 // Record successfully resolved link for post-resolution verification
366 applied_indirects.push_back(
367 AppliedIndirect{pal_index, indirect_color, res_ref_pal, res_ref_color, source_group_index});
368 }
369 }
370
371 /*
372 * === Phase 5: Fallback, assign free slots to unresolved Indirect colors ===
373 *
374 * Phase 4 may leave colors in IndirectPosition if resolution failed (prefilled destination conflict). These colors
375 * still need placement in the final palette, so assign them sequential
376 * free slots, identical to Phase 3's logic but targeting IndirectPosition instead of UndeterminedPosition.
377 */
378 for (auto &state_opt : states) {
379 if (!state_opt.has_value()) {
380 continue;
381 }
382 auto &state = state_opt.value();
383
384 // Collect slots already used by Absolute positions
385 std::set<std::size_t> used_slots;
386 used_slots.insert(0); // Slot 0 is always reserved
387 for (const auto &[color, position] : state.color_positions) {
388 if (std::holds_alternative<AbsolutePosition>(position)) {
389 used_slots.insert(std::get<AbsolutePosition>(position).slot);
390 }
391 }
392
393 // Assign next free slot to each remaining Indirect color
394 std::size_t next_slot = 1;
395 for (auto &[color, position] : state.color_positions) {
396 if (!std::holds_alternative<IndirectPosition>(position)) {
397 continue;
398 }
399 while (next_slot < pal::max_size && used_slots.contains(next_slot)) {
400 ++next_slot;
401 }
402 if (next_slot < pal::max_size) {
403 position = AbsolutePosition{next_slot};
404 used_slots.insert(next_slot);
405 ++next_slot;
406 }
407 else {
408 panic(
409 "ran out of palette slots during Indirect fallback fill for palette " +
410 std::to_string(state.hw_index));
411 }
412 }
413 }
414
415 // Post-resolution verification: detect eviction displacement
416 if (failure_counts != nullptr) {
417 for (const auto &ai : applied_indirects) {
418 if (!states.at(ai.source_pal).has_value() || !states.at(ai.ref_pal).has_value()) {
419 continue;
420 }
421 const auto &source_state = states.at(ai.source_pal).value();
422 const auto &ref_state = states.at(ai.ref_pal).value();
423
424 if (!source_state.color_positions.contains(ai.source_color) ||
425 !ref_state.color_positions.contains(ai.ref_color)) {
426 continue;
427 }
428
429 const auto &source_pos = source_state.color_positions.at(ai.source_color);
430 const auto &ref_pos = ref_state.color_positions.at(ai.ref_color);
431
432 if (!std::holds_alternative<AbsolutePosition>(source_pos) ||
433 !std::holds_alternative<AbsolutePosition>(ref_pos)) {
434 continue;
435 }
436
437 const auto source_slot = std::get<AbsolutePosition>(source_pos).slot;
438 const auto ref_slot = std::get<AbsolutePosition>(ref_pos).slot;
439
440 if (source_slot != ref_slot) {
441 failure_counts->post_resolution_mismatch_details.push_back(
442 PostResolutionMismatchDetail{
443 .source_group_index = ai.source_group_index,
444 .source_pal_index = ai.source_pal,
445 .source_color = ai.source_color,
446 .source_final_slot = source_slot,
447 .ref_pal_index = ai.ref_pal,
448 .ref_color = ai.ref_color,
449 .ref_final_slot = ref_slot});
450 }
451 }
452 }
453
454 // Deduplicate detail records: multiple IndirectLinks for the same color pair (from different group members) can
455 // produce identical detail records. Sort + unique ensures counts and diagnostic output reflect unique failures.
456 if (failure_counts != nullptr) {
457 auto dedup = [](auto &vec) {
458 std::ranges::sort(vec);
459 auto [first, last] = std::ranges::unique(vec);
460 vec.erase(first, last);
461 };
462 dedup(failure_counts->prefilled_destination_conflict_details);
463 dedup(failure_counts->prefilled_source_conflict_details);
464 dedup(failure_counts->first_writer_wins_details);
465 dedup(failure_counts->post_resolution_mismatch_details);
466 }
467
468 // Phase 6: Build final palettes from resolved positions
469 std::array<std::optional<Palette<Rgba32, pal::max_size>>, pal::num_pals> result{};
470
471 for (const auto &state_opt : states) {
472 if (!state_opt.has_value()) {
473 continue;
474 }
475 const auto &state = state_opt.value();
476 const std::size_t hw = state.hw_index;
477
478 Palette<Rgba32, pal::max_size> output{Rgba32{0, 0, 0, Rgba32::alpha_opaque}};
479
480 // Set slot 0
481 const Palette<Rgba32, pal::max_size> *prefilled_ptr =
482 prefilled_pals.at(hw).has_value() ? &prefilled_pals.at(hw).value() : nullptr;
483 if (prefilled_ptr != nullptr && !prefilled_ptr->is_wildcard(0)) {
484 output.set(0, prefilled_ptr->at(0));
485 }
486 else {
487 output.set(0, default_slot_zero);
488 }
489
490 // Place prefilled slots
491 if (prefilled_ptr != nullptr) {
492 for (std::size_t i = 1; i < pal::max_size; ++i) {
493 if (!prefilled_ptr->is_wildcard(i)) {
494 output.set(i, prefilled_ptr->at(i));
495 }
496 }
497 }
498
499 // Place all Absolute-position colors
500 for (const auto &[color, position] : state.color_positions) {
501 if (std::holds_alternative<AbsolutePosition>(position)) {
502 const std::size_t slot = std::get<AbsolutePosition>(position).slot;
503 if (!state.prefilled_slots.contains(slot)) {
504 output.set(slot, color);
505 }
506 }
507 }
508
509 result.at(hw) = output;
510 }
511
512 return result;
513}
514
515} // namespace porytiles
A bidirectional mapping between pixel color values and sequential integer indices.
Represents a hardware palette after packing with accumulated colors and assigned tiles.
A generic palette container for colors that support transparency checking.
Definition palette.hpp:47
ColorType at(std::size_t index) const
Gets the color at a specific index.
Definition palette.hpp:276
bool is_wildcard(std::size_t index) const
Checks if a slot is a wildcard.
Definition palette.hpp:198
Represents a 32-bit RGBA color.
Definition rgba32.hpp:23
static constexpr std::uint8_t alpha_opaque
Definition rgba32.hpp:26
std::size_t start
constexpr std::size_t num_pals
Definition palette.hpp:21
constexpr std::size_t max_size
Definition palette.hpp:19
void for_each_color(const ColorSet &set, Func &&func)
Iterates over each color index in a ColorSet.
void panic(const StringViewSourceLoc &s)
Unconditionally terminates the program with a panic message.
Definition panic.cpp:43
std::array< std::optional< Palette< Rgba32, pal::max_size > >, pal::num_pals > build_all_output_palettes(const std::vector< PackedPalette > &packed_pals, const std::array< std::optional< Palette< Rgba32, pal::max_size > >, pal::num_pals > &prefilled_pals, const ColorIndexMap< Rgba32 > &color_map, const Rgba32 &default_slot_zero, const std::vector< IndirectLink > &indirect_links, AlignmentFailureCounts *failure_counts=nullptr)
Builds all output palettes from packed palettes and Indirect links in a single call.
std::size_t hw_index
std::map< Rgba32, ColorPosition > color_positions
std::set< std::size_t > prefilled_slots
Position state for a color assigned to a specific palette slot.
Detailed records of alignment failures during Indirect link application and chain resolution.
Position state for a color that has not yet been assigned a palette slot.