implements PLN-0082

This commit is contained in:
bQUARKz 2026-05-23 22:20:24 +01:00
parent ad2bfeb66f
commit 0be02622c4
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
8 changed files with 120 additions and 106 deletions

View File

@ -1,5 +1,5 @@
{"type":"meta","next_id":{"DSC":39,"AGD":42,"DEC":38,"PLN":87,"LSN":53,"CLSN":1}}
{"type":"discussion","id":"DSC-0038","status":"open","ticket":"studio-packer-rgba8888-asset-pipeline","title":"Studio and Packer RGBA8888 Asset Pipeline Alignment","created_at":"2026-05-23","updated_at":"2026-05-23","tags":["studio","packer","assets","glyph-bank","palette","rgba8888","runtime-alignment"],"agendas":[{"id":"AGD-0041","file":"AGD-0041-studio-packer-rgba8888-asset-pipeline.md","status":"accepted","created_at":"2026-05-23","updated_at":"2026-05-23"}],"decisions":[{"id":"DEC-0037","file":"DEC-0037-studio-packer-rgba8888-asset-pipeline.md","status":"accepted","created_at":"2026-05-23","updated_at":"2026-05-23","ref_agenda":"AGD-0041"}],"plans":[{"id":"PLN-0081","file":"PLN-0081-rgba8888-packer-and-studio-spec-propagation.md","status":"done","created_at":"2026-05-23","updated_at":"2026-05-23","ref_decisions":["DEC-0037"]},{"id":"PLN-0082","file":"PLN-0082-rgba8888-palette-model-and-argb-conversion.md","status":"review","created_at":"2026-05-23","updated_at":"2026-05-23","ref_decisions":["DEC-0037"]},{"id":"PLN-0083","file":"PLN-0083-rgba8888-glyph-payload-emission-and-sizes.md","status":"review","created_at":"2026-05-23","updated_at":"2026-05-23","ref_decisions":["DEC-0037"]},{"id":"PLN-0084","file":"PLN-0084-rgba8888-packer-projections-and-workspace-apis.md","status":"review","created_at":"2026-05-23","updated_at":"2026-05-23","ref_decisions":["DEC-0037"]},{"id":"PLN-0085","file":"PLN-0085-rgba8888-tests-fixtures-and-conformance.md","status":"review","created_at":"2026-05-23","updated_at":"2026-05-23","ref_decisions":["DEC-0037"]},{"id":"PLN-0086","file":"PLN-0086-rgba8888-final-integration-and-residue-removal.md","status":"review","created_at":"2026-05-23","updated_at":"2026-05-23","ref_decisions":["DEC-0037"]}],"lessons":[]}
{"type":"discussion","id":"DSC-0038","status":"open","ticket":"studio-packer-rgba8888-asset-pipeline","title":"Studio and Packer RGBA8888 Asset Pipeline Alignment","created_at":"2026-05-23","updated_at":"2026-05-23","tags":["studio","packer","assets","glyph-bank","palette","rgba8888","runtime-alignment"],"agendas":[{"id":"AGD-0041","file":"AGD-0041-studio-packer-rgba8888-asset-pipeline.md","status":"accepted","created_at":"2026-05-23","updated_at":"2026-05-23"}],"decisions":[{"id":"DEC-0037","file":"DEC-0037-studio-packer-rgba8888-asset-pipeline.md","status":"accepted","created_at":"2026-05-23","updated_at":"2026-05-23","ref_agenda":"AGD-0041"}],"plans":[{"id":"PLN-0081","file":"PLN-0081-rgba8888-packer-and-studio-spec-propagation.md","status":"done","created_at":"2026-05-23","updated_at":"2026-05-23","ref_decisions":["DEC-0037"]},{"id":"PLN-0082","file":"PLN-0082-rgba8888-palette-model-and-argb-conversion.md","status":"done","created_at":"2026-05-23","updated_at":"2026-05-23","ref_decisions":["DEC-0037"]},{"id":"PLN-0083","file":"PLN-0083-rgba8888-glyph-payload-emission-and-sizes.md","status":"review","created_at":"2026-05-23","updated_at":"2026-05-23","ref_decisions":["DEC-0037"]},{"id":"PLN-0084","file":"PLN-0084-rgba8888-packer-projections-and-workspace-apis.md","status":"review","created_at":"2026-05-23","updated_at":"2026-05-23","ref_decisions":["DEC-0037"]},{"id":"PLN-0085","file":"PLN-0085-rgba8888-tests-fixtures-and-conformance.md","status":"review","created_at":"2026-05-23","updated_at":"2026-05-23","ref_decisions":["DEC-0037"]},{"id":"PLN-0086","file":"PLN-0086-rgba8888-final-integration-and-residue-removal.md","status":"review","created_at":"2026-05-23","updated_at":"2026-05-23","ref_decisions":["DEC-0037"]}],"lessons":[]}
{"type":"discussion","id":"DSC-0037","status":"done","ticket":"pbs-autocomplete-parameter-names","title":"PBS autocomplete parameter names for stdlib and method calls","created_at":"2026-05-08","updated_at":"2026-05-14","tags":["compiler-pbs","studio","lsp","autocomplete","signature-help","stdlib"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0052","file":"discussion/lessons/DSC-0037-pbs-autocomplete-parameter-names/LSN-0052-canonical-callable-parameter-names-through-pbs-editor-assistance.md","status":"done","created_at":"2026-05-14","updated_at":"2026-05-14"}]}
{"type":"discussion","id":"DSC-0036","status":"open","ticket":"pbs-symbol-documentation-and-hover-markdown","title":"Modelo de documentacao de simbolos em PBS e consumo markdown no hover","created_at":"2026-05-08","updated_at":"2026-05-08","tags":["compiler","compiler-pbs","studio","lsp","vscode","editor","hover","documentation","markdown"],"agendas":[{"id":"AGD-0039","file":"AGD-0039-pbs-symbol-documentation-and-hover-markdown.md","status":"open","created_at":"2026-05-08","updated_at":"2026-05-08"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0035","status":"done","ticket":"pbs-lsp-editor-assistance-wave-1","title":"Wave 1 de assistencia editorial via LSP para PBS no VS Code","created_at":"2026-05-08","updated_at":"2026-05-08","tags":["studio","lsp","vscode","compiler","compiler-pbs","editor","completion","hover","signature-help"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0051","file":"discussion/lessons/DSC-0035-pbs-lsp-editor-assistance-wave-1/LSN-0051-compiler-backed-editor-assistance-for-pbs.md","status":"done","created_at":"2026-05-08","updated_at":"2026-05-08"}]}

View File

@ -2,9 +2,9 @@
id: PLN-0082
ticket: studio-packer-rgba8888-asset-pipeline
title: RGBA8888 Palette Model and ARGB Conversion
status: review
status: done
created: 2026-05-23
completed:
completed: 2026-05-23
ref_decisions:
- DEC-0037
tags:
@ -124,12 +124,12 @@ diagnostic wording to remove `indices 1..15`.
## Acceptance Criteria
- [ ] `PackerPaletteV1` exposes `rgba8888` and no RGB565 field.
- [ ] `PackerGlyphBankWalker` stores canonical RGBA values.
- [ ] Magenta is not special.
- [ ] Alpha zero and partial alpha are valid palette data.
- [ ] All 16 palette indices are usable.
- [ ] Unit tests cover conversion and palette indexing semantics.
- [x] `PackerPaletteV1` exposes `rgba8888` and no RGB565 field.
- [x] `PackerGlyphBankWalker` stores canonical RGBA values.
- [x] Magenta is not special.
- [x] Alpha zero and partial alpha are valid palette data.
- [x] All 16 palette indices are usable.
- [x] Unit tests cover conversion and palette indexing semantics.
## Dependencies

View File

@ -1,6 +1,5 @@
package p.packer.models;
public record PackerPaletteV1(
int[] originalArgb8888,
short[] convertedRgb565) {
}
int[] rgba8888) {
}

View File

@ -383,13 +383,10 @@ public class PackerAssetWalker {
if (!indexNode.isInt() || !paletteNode.isObject()) {
continue;
}
final int originalCount = paletteNode.path("originalArgb8888").isArray()
? paletteNode.path("originalArgb8888").size()
final int colorCount = paletteNode.path("rgba8888").isArray()
? paletteNode.path("rgba8888").size()
: 0;
final int convertedCount = paletteNode.path("convertedRgb565").isArray()
? paletteNode.path("convertedRgb565").size()
: 0;
palettes.add(new DeclaredPalette(indexNode.intValue(), Math.min(originalCount, convertedCount)));
palettes.add(new DeclaredPalette(indexNode.intValue(), colorCount));
}
palettes.sort(java.util.Comparator.comparingInt(DeclaredPalette::index));
return List.copyOf(palettes);

View File

@ -11,8 +11,7 @@ import java.nio.file.Path;
import java.util.*;
public class PackerGlyphBankWalker extends PackerAbstractBankWalker<PackerGlyphBankRequirements> {
private static final int MAX_COLORS_PER_PALETTE = 15;
private static final int COLOR_KEY_RGB = 0x00FF00FF;
private static final int MAX_COLORS_PER_PALETTE = 16;
private static final Set<String> SUPPORTED_MIME_TYPES = Set.of(
"image/png"
);
@ -69,58 +68,37 @@ public class PackerGlyphBankWalker extends PackerAbstractBankWalker<PackerGlyphB
));
}
final Map<Integer, Integer> paletteIndexByRgb = new LinkedHashMap<>();
final Map<Integer, Integer> paletteIndexByRgba = new LinkedHashMap<>();
final byte[] paletteIndices = new byte[image.getWidth() * image.getHeight()];
boolean partialAlphaFound = false;
boolean exceededPaletteLimit = false;
int offset = 0;
for (var y = 0; y < image.getHeight(); y++) {
for (var x = 0; x < image.getWidth(); x++) {
final int argb = image.getRGB(x, y);
final int alpha = (argb >>> 24) & 0xFF;
final int rgb = argb & 0x00FFFFFF;
final int rgba = argbToRgba(argb);
if (alpha == 0 || rgb == COLOR_KEY_RGB) {
paletteIndices[offset++] = 0;
continue;
}
if (alpha < 0xFF) {
partialAlphaFound = true;
}
Integer paletteIndex = paletteIndexByRgb.get(rgb);
Integer paletteIndex = paletteIndexByRgba.get(rgba);
if (paletteIndex == null) {
if (paletteIndexByRgb.size() >= MAX_COLORS_PER_PALETTE) {
if (paletteIndexByRgba.size() >= MAX_COLORS_PER_PALETTE) {
exceededPaletteLimit = true;
paletteIndices[offset++] = 0;
continue;
}
paletteIndex = paletteIndexByRgb.size() + 1;
paletteIndexByRgb.put(rgb, paletteIndex);
paletteIndex = paletteIndexByRgba.size();
paletteIndexByRgba.put(rgba, paletteIndex);
}
paletteIndices[offset++] = paletteIndex.byteValue();
}
}
if (partialAlphaFound) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.WARNING,
PackerDiagnosticCategory.HYGIENE,
"Tile contains partial alpha; flattening to RGB and ignoring alpha channel",
fileProbe.path(),
false
));
}
if (exceededPaletteLimit) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Tile image exceeds color limit for " + fileProbe.path().getFileName()
+ ": expected at most " + MAX_COLORS_PER_PALETTE + " colors for indices 1..15",
+ ": expected at most " + MAX_COLORS_PER_PALETTE + " RGBA8888 colors",
fileProbe.path(),
true
));
@ -131,7 +109,7 @@ public class PackerGlyphBankWalker extends PackerAbstractBankWalker<PackerGlyphB
}
final var tile = new PackerTileIndexedV1(image.getWidth(), image.getHeight(), paletteIndices);
final var palette = buildPalette(paletteIndexByRgb);
final var palette = buildPalette(paletteIndexByRgba);
// Disabled for now: derived tile/palette JSON artifacts are not emitted during normal walks.
// If we want filesystem-side debug artifacts again, uncomment the call below.
// serializeProbeArtifacts(fileProbe, assetRoot, tile, palette, diagnostics);
@ -150,7 +128,7 @@ public class PackerGlyphBankWalker extends PackerAbstractBankWalker<PackerGlyphB
final Object width = tile.get("width");
final Object height = tile.get("height");
final Object indices = tile.get("paletteIndices");
final Object colors = palette.get("originalArgb8888");
final Object colors = palette.get("rgba8888");
return width instanceof Number
&& height instanceof Number
&& indices instanceof List<?>
@ -224,39 +202,27 @@ public class PackerGlyphBankWalker extends PackerAbstractBankWalker<PackerGlyphB
}
private Map<String, Object> toPalettePayload(final PackerPaletteV1 palette) {
final List<Integer> convertedRgb565 = new ArrayList<>(palette.convertedRgb565().length);
for (final short value : palette.convertedRgb565()) {
convertedRgb565.add(Short.toUnsignedInt(value));
}
return Map.of(
"originalArgb8888", palette.originalArgb8888(),
"convertedRgb565", convertedRgb565
"rgba8888", palette.rgba8888()
);
}
private PackerPaletteV1 buildPalette(final Map<Integer, Integer> paletteIndexByRgb) {
final int[] originalArgb8888 = new int[paletteIndexByRgb.size()];
final short[] convertedRgb565 = new short[paletteIndexByRgb.size()];
for (final var entry : paletteIndexByRgb.entrySet()) {
final int rgb = entry.getKey();
final int index = entry.getValue() - 1;
final int argb = 0xFF000000 | rgb;
originalArgb8888[index] = argb;
convertedRgb565[index] = convertToRgb565(argb);
private PackerPaletteV1 buildPalette(final Map<Integer, Integer> paletteIndexByRgba) {
final int[] rgba8888 = new int[paletteIndexByRgba.size()];
for (final var entry : paletteIndexByRgba.entrySet()) {
final int rgba = entry.getKey();
final int index = entry.getValue();
rgba8888[index] = rgba;
}
return new PackerPaletteV1(originalArgb8888, convertedRgb565);
return new PackerPaletteV1(rgba8888);
}
private short convertToRgb565(final int argb) {
final int red = (argb >> 16) & 0xFF;
final int green = (argb >> 8) & 0xFF;
private int argbToRgba(final int argb) {
final int alpha = (argb >>> 24) & 0xFF;
final int red = (argb >>> 16) & 0xFF;
final int green = (argb >>> 8) & 0xFF;
final int blue = argb & 0xFF;
final int r5 = (red >> 3) & 0x1F;
final int g6 = (green >> 2) & 0x3F;
final int b5 = (blue >> 3) & 0x1F;
return (short) ((r5 << 11) | (g6 << 5) | b5);
return (red << 24) | (green << 16) | (blue << 8) | alpha;
}
private java.awt.image.BufferedImage readImage(

View File

@ -1161,22 +1161,19 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
Object paletteMetadata,
String artifactFile,
Map<Integer, Integer> declaredPaletteIndexByRgb) {
final int[] originalArgb8888 = originalPaletteColors(paletteMetadata);
if (originalArgb8888.length == 0) {
final int[] rgba8888 = paletteColors(paletteMetadata);
if (rgba8888.length == 0) {
throw new IllegalStateException("Selected tile artifact is missing palette metadata required for palette remapping: " + artifactFile);
}
final byte[] remapped = Arrays.copyOf(tile.paletteIndices(), tile.paletteIndices().length);
for (int index = 0; index < remapped.length; index += 1) {
final int paletteIndex = Byte.toUnsignedInt(remapped[index]);
if (paletteIndex == 0) {
continue;
}
if (paletteIndex > originalArgb8888.length) {
if (paletteIndex >= rgba8888.length) {
throw new IllegalStateException("Tile palette index exceeds discovered palette size for artifact: " + artifactFile);
}
final int originalArgb = originalArgb8888[paletteIndex - 1];
final Integer materialIndex = declaredPaletteIndexByRgb.get(rgb24(originalArgb));
if (materialIndex == null || materialIndex < 1 || materialIndex > 15) {
final int rgba = rgba8888[paletteIndex];
final Integer materialIndex = declaredPaletteIndexByRgb.get(rgba);
if (materialIndex == null || materialIndex < 0 || materialIndex > 15) {
throw new IllegalStateException("Unable to resolve canonical palette index for artifact color in " + artifactFile);
}
remapped[index] = (byte) (materialIndex & 0x0F);
@ -1189,29 +1186,29 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
if (!(palettesNode instanceof ArrayNode palettesArray) || palettesArray.isEmpty()) {
throw new IllegalStateException("Glyph packing requires at least one declared pipeline palette.");
}
final JsonNode paletteNode = palettesArray.get(0).path("palette").path("originalArgb8888");
final JsonNode paletteNode = palettesArray.get(0).path("palette").path("rgba8888");
if (!paletteNode.isArray() || paletteNode.isEmpty()) {
throw new IllegalStateException("Glyph packing requires originalArgb8888 entries in the declared pipeline palette.");
throw new IllegalStateException("Glyph packing requires rgba8888 entries in the declared pipeline palette.");
}
final LinkedHashMap<Integer, Integer> mapping = new LinkedHashMap<>();
for (int index = 0; index < paletteNode.size(); index += 1) {
final JsonNode colorNode = paletteNode.get(index);
if (!colorNode.canConvertToInt()) {
throw new IllegalStateException("Declared pipeline palette contains a non-integer originalArgb8888 entry.");
throw new IllegalStateException("Declared pipeline palette contains a non-integer rgba8888 entry.");
}
mapping.put(rgb24(colorNode.intValue()), index + 1);
mapping.put(colorNode.intValue(), index);
}
return Map.copyOf(mapping);
}
private int[] originalPaletteColors(Object paletteMetadata) {
private int[] paletteColors(Object paletteMetadata) {
if (paletteMetadata instanceof PackerPaletteV1 palette) {
return palette.originalArgb8888();
return palette.rgba8888();
}
if (!(paletteMetadata instanceof Map<?, ?> map)) {
return new int[0];
}
final Object colors = map.get("originalArgb8888");
final Object colors = map.get("rgba8888");
if (!(colors instanceof List<?> list)) {
return new int[0];
}
@ -1226,10 +1223,6 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
return values;
}
private int rgb24(int argb) {
return argb & 0x00FFFFFF;
}
private PackedAsset packSceneBank(PackerRuntimeAsset runtimeAsset, PackerAssetDeclaration declaration) {
final ScenePackRequest request = runtimeAsset.walkProjection().buildCandidateFiles().stream()
.map(file -> file.metadata().get("scenePackRequest"))

View File

@ -109,17 +109,12 @@ public final class PackerReadMessageMapper {
"paletteIndices", List.copyOf(paletteIndices));
}
if (value instanceof PackerPaletteV1 palette) {
final List<Integer> originalArgb8888 = new java.util.ArrayList<>(palette.originalArgb8888().length);
for (int argb : palette.originalArgb8888()) {
originalArgb8888.add(argb);
}
final List<Integer> convertedRgb565 = new java.util.ArrayList<>(palette.convertedRgb565().length);
for (short rgb565 : palette.convertedRgb565()) {
convertedRgb565.add(Short.toUnsignedInt(rgb565));
final List<Integer> rgba8888 = new java.util.ArrayList<>(palette.rgba8888().length);
for (int rgba : palette.rgba8888()) {
rgba8888.add(rgba);
}
return Map.of(
"originalArgb8888", List.copyOf(originalArgb8888),
"convertedRgb565", List.copyOf(convertedRgb565));
"rgba8888", List.copyOf(rgba8888));
}
if (value instanceof Map<?, ?> nestedMap) {
final java.util.LinkedHashMap<String, Object> normalized = new java.util.LinkedHashMap<>();

View File

@ -5,8 +5,10 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import p.packer.models.PackerAssetCacheEntry;
import p.packer.models.PackerFileCacheEntry;
import p.packer.models.PackerTileIndexedV1;
import p.packer.models.PackerDiagnostic;
import p.packer.models.PackerGlyphBankRequirements;
import p.packer.models.PackerPaletteV1;
import p.packer.models.PackerTileIndexedV1;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
@ -42,7 +44,7 @@ final class PackerGlyphBankWalkerTest {
null,
Map.of(
"tile", Map.of("width", 16, "height", 16),
"palette", Map.of("originalArgb8888", List.of(0xFFFFFFFF))),
"palette", Map.of("rgba8888", List.of(0xFFFFFFFF))),
List.of())));
final var result = walker.walk(assetRoot, new PackerGlyphBankRequirements(16), Optional.of(priorCache));
@ -52,6 +54,54 @@ final class PackerGlyphBankWalkerTest {
assertTrue(tile.paletteIndices().length > 0);
}
@Test
void preservesRgba8888PaletteDataAndUsesIndexZeroAsOrdinaryColor() throws Exception {
final Path assetRoot = Files.createDirectories(tempDir.resolve("rgba"));
final Path filePath = assetRoot.resolve("tile.png");
final BufferedImage image = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB);
image.setRGB(0, 0, 0x80FF0000);
image.setRGB(1, 0, 0x00000000);
image.setRGB(2, 0, 0xFFFF00FF);
for (int y = 0; y < 16; y++) {
for (int x = 3; x < 16; x++) {
image.setRGB(x, y, 0xFF00FF00);
}
}
ImageIO.write(image, "png", filePath.toFile());
final PackerGlyphBankWalker walker = new PackerGlyphBankWalker(MAPPER);
final var result = walker.walk(assetRoot, new PackerGlyphBankRequirements(16));
assertTrue(result.diagnostics().stream().noneMatch(diagnostic -> diagnostic.message().contains("partial alpha")));
final var metadata = result.probeResults().getFirst().metadata();
final PackerTileIndexedV1 tile = (PackerTileIndexedV1) metadata.get("tile");
final PackerPaletteV1 palette = (PackerPaletteV1) metadata.get("palette");
assertEquals(0, Byte.toUnsignedInt(tile.paletteIndices()[0]));
assertEquals(1, Byte.toUnsignedInt(tile.paletteIndices()[1]));
assertEquals(2, Byte.toUnsignedInt(tile.paletteIndices()[2]));
assertArrayEquals(new int[]{0xFF000080, 0x00000000, 0xFF00FFFF, 0x00FF00FF}, palette.rgba8888());
}
@Test
void acceptsSixteenRgbaColorsAndRejectsSeventeenth() throws Exception {
final Path validRoot = Files.createDirectories(tempDir.resolve("sixteen"));
writeColorCountTile(validRoot.resolve("tile.png"), 16);
final PackerGlyphBankWalker walker = new PackerGlyphBankWalker(MAPPER);
final var valid = walker.walk(validRoot, new PackerGlyphBankRequirements(16));
assertTrue(valid.diagnostics().stream().noneMatch(PackerDiagnostic::blocking));
final Path invalidRoot = Files.createDirectories(tempDir.resolve("seventeen"));
writeColorCountTile(invalidRoot.resolve("tile.png"), 17);
final var invalid = walker.walk(invalidRoot, new PackerGlyphBankRequirements(16));
assertTrue(invalid.probeResults().stream()
.flatMap(probeResult -> probeResult.diagnostics().stream())
.anyMatch(PackerDiagnostic::blocking));
}
private void writeTile(Path path) throws Exception {
final BufferedImage image = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB);
for (int y = 0; y < 16; y++) {
@ -61,4 +111,18 @@ final class PackerGlyphBankWalkerTest {
}
ImageIO.write(image, "png", path.toFile());
}
private void writeColorCountTile(Path path, int colorCount) throws Exception {
final BufferedImage image = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB);
for (int y = 0; y < 16; y++) {
for (int x = 0; x < 16; x++) {
final int index = Math.min(colorCount - 1, y * 16 + x);
final int red = (index * 17) & 0xFF;
final int green = (index * 31) & 0xFF;
final int blue = (index * 47) & 0xFF;
image.setRGB(x, y, 0xFF000000 | (red << 16) | (green << 8) | blue);
}
}
ImageIO.write(image, "png", path.toFile());
}
}