How can I build an accurate 15-bit Highcolor RGB palette

I want to recreate an accurate version of a 15-bit RGB color palette (PC Highcolor, SNES, Playstation 1), storing each color from the palette as a standard 6 digit hex code.

With each color component using 5 bits, this gives us available values of 0-31. Mapping that to a standard 0-255 color range needed for the hex code seems trivial, however when I do my palette seems to be subtly off from the examples I've found online.

When mapping the values I've tried both rounding and flooring to the closest match but neither quite match. My output when looping through the first few values of the red component gives me:

Rounding:

#000000, #080000, #100000, #190000, #210000, #290000, #310000, #3a0000, #420000...

Flooring:

#000000, #080000, #100000, #180000, #200000, #290000, #310000, #390000, #410000...

Whereas in the example palettes I've found I get:

#000000, #080000, #100000, #180000, #210000, #290000, #310000, #390000, #420000...

At first I considered that the example palette may have been wrong, or that the image compression used on the image may have effected the values, but it seems to be the same for all of the examples I've found.

While this might seem a trivial detail and the colors would likely be indistinguishable, I'm keen to understand if there was a hardware or software limitation on the original devices which causes these discrepancies when computing the original palette. I'm also wondering if it's actually a fence post problem in my code.

Update: Here is the Javascript code I wrote to generate a color cube based on bit depth. rgbToHex is just a util that does what it says on the tin :)

The differences posted above about round and floor refer to the int conversion done on the r, g, b values before passing to rgbToHex. From the article cited in my answer below, I now believe the issue is to do with data loss during bit depth conversions and that while the code below produces a "perfect" linear interpolated color cube, this wasn't possible on the original hardware.

const MAX_RGB = 255;
const BITS_PER_CHANNEL = 5;

const numPosts = Math.pow(2, BITS_PER_CHANNEL);
const numSpaces = numPosts - 1;

let rIndex, gIndex, bIndex;
const colors = [];

for (bIndex = 0; bIndex < numPosts; bIndex++) {
for (gIndex = 0; gIndex < numPosts; gIndex++) {
for (rIndex = 0; rIndex < numPosts; rIndex++) {
const r = (rIndex / numSpaces) * MAX_RGB;
const g = (gIndex / numSpaces) * MAX_RGB;
const b = (bIndex / numSpaces) * MAX_RGB;
const color = rgbToHex(Math.round(r), Math.round(g), Math.round(b));
colors.push(color);
}
}
}
$$$$


It took a little while to stumble across and my understanding of bit operations is limited, but this discrepancy appears to be due to the data loss suffered when converting values from 24-bit to 15-bit and back again. As described here.

The relevant part of that article describes the conversion algorithm like so (where color is an integer in the range 0 - 32767):

R = ((color       ) % 32) * 8
G = ((color /   32) % 32) * 8
B = ((color / 1024) % 32) * 8


However for white (32767), this will produce an RGB triplet of (248,248,248) NOT (255,255,255). This is because, to quote:

So the final output is (248, 248, 248). Uh-oh, 24-bit RGB white is (255, 255, 255) not (248, 248, 248). Apparently, what happened is there was a precision loss during the conversion. Think about it, if you convert a 24-bit value into a 15-bit you would have loss some precision. The three least significant bits are lost in each component. Thus, the extra precision that 24-bit color provides is lost and is unrecoverable.

This range is then stretched to fill the 255 range afterwards, which accounts for the original noted discrepancies:

A naïve approach would be to do a multiplication and division scaling:

R = (255 * R) / 31;
G = (255 * G) / 31;
B = (255 * B) / 31;


A faster approach is:

R = R + R / 32
G = G + G / 32
B = B + B / 32
`

This seems strange at first glance, but what happens is that you replicate the top three bits of each component into the bottom three bits (which, as stated above, are empty after conversion). This, if you think long enough about it, has the effect of stretching the color values into the full (0,0,0) -> (255,255,255) range.