Tool updates...

This was just supposed to be a new feature for the generic image tool (dithering support), but while working on that things kind of snowballed 😅

* Added dithering support to the generic image tool and the boot logo changer, when converting images to RGB565 format. This uses a Bayer 8x8 matrix, and the overall "strength" of the dither can be controlled - it defaults to what I feel is a sane value. Dithering can help reduce banding effects due to the low colour depth in RGB565.
* Made "fix" scaling mode more flexible in the generic image tool - now there's checkboxes beside the width and height dimensions - if you un-check one, the other dimension will be calculated automatically to keep the input's aspect ratio intact
* Improved downscaling quality in the generic image tool. While working on the dithering feature, I discovered the previous "gaussian resampling" downscaling method I was using introduced distortion in certain situations. I had a lot of fun playing around with possible replacements (I tried 10 new downscaling functions!), and finally settled on a hybrid function that mixes powers-of-two downscaling (with some custom mipmap style cross-blending) with Hermite interpolation. This new method is reasonably quick, gives clean results with no great distortion, and works well with alpha channels (doesn't introduce any dark fringing)
* Generic image tool now shows the dimensions of the output image
* Added a max width on the main tool page bodies, so that they don't get so wide on full-screen desktop browsers
* Fixed a few edge-case logic bugs here and there (e.g., when upscaling only a single dimensions with the generic image tool, or places where I thought I was copying objects, but was only creating references to them, etc.)
* Switched from using "var" declarations across all tool codebases to "let" or "const" instead
* Switched out the SVG alert icons to just use emoji instead
* Various other nips and tucks (fixed up my arbitrary 80-column comment wrapping on the tools that had previously just been eye-balled, fixed a few comment typos, etc.)
This commit is contained in:
vonmillhausen 2024-05-14 20:20:57 +01:00
parent 41e0ffabac
commit 806444d2a9
8 changed files with 1044 additions and 699 deletions

View File

@ -25,7 +25,7 @@
<script type="text/javascript"> <script type="text/javascript">
// Global variables... // Global variables...
var bisrvData; // Used to store the binary data from the bisrv.asd file let bisrvData; // Used to store the binary data from the bisrv.asd file
// Display our initial warning message // Display our initial warning message
setMessage("warning", "initialWarning", "<strong><em>WARNING:</em></strong> This tool will just blindly modify bytes <code>0x18c</code> to <code>0x18f</code> of whatever input file you provide; correct usage of this tool is therefore up to you, not me! 🤣"); setMessage("warning", "initialWarning", "<strong><em>WARNING:</em></strong> This tool will just blindly modify bytes <code>0x18c</code> to <code>0x18f</code> of whatever input file you provide; correct usage of this tool is therefore up to you, not me! 🤣");
@ -47,7 +47,7 @@
// If we're here then we should be good - read in our file and store its // If we're here then we should be good - read in our file and store its
// contents in bisrvData, then patch the CRC32 bytes... // contents in bisrvData, then patch the CRC32 bytes...
var frBisrv = new FileReader(); const frBisrv = new FileReader();
frBisrv.readAsArrayBuffer(file); frBisrv.readAsArrayBuffer(file);
frBisrv.onload = function(event) { frBisrv.onload = function(event) {
@ -64,7 +64,7 @@
function stepTwo() { function stepTwo() {
// Start building our HTML... // Start building our HTML...
var html = "<section id=\"downloadSection\"><h2>Step 2: Download Patched <code>bisrv.asd</code></h2><p>Click the download button for the patched <code>bisrv.asd</code> file; use it to replace the one in the <code>bios</code> folder on your SF2000's microSD card.</p><div id=\"downloadSectionMessages\"></div>"; let html = "<section id=\"downloadSection\"><h2>Step 2: Download Patched <code>bisrv.asd</code></h2><p>Click the download button for the patched <code>bisrv.asd</code> file; use it to replace the one in the <code>bios</code> folder on your SF2000's microSD card.</p><div id=\"downloadSectionMessages\"></div>";
// Add our download button... // Add our download button...
html += "<div class=\"controlContainer\"><div class=\"control\"><input id=\"downloadButton\" type=\"button\" value=\"Download\"></div></div>"; html += "<div class=\"controlContainer\"><div class=\"control\"><input id=\"downloadButton\" type=\"button\" value=\"Download\"></div></div>";
@ -74,13 +74,13 @@
document.getElementById("steps").insertAdjacentHTML("beforeend", html); document.getElementById("steps").insertAdjacentHTML("beforeend", html);
// Attach our event handler to our download button... // Attach our event handler to our download button...
var dButton = document.getElementById("downloadButton"); const dButton = document.getElementById("downloadButton");
dButton.addEventListener("click", function() { dButton.addEventListener("click", function() {
downloadToBrowser(bisrvData, "application/octet-stream", "bisrv.asd"); downloadToBrowser(bisrvData, "application/octet-stream", "bisrv.asd");
}); });
} }
</script> </script>
<hr> <hr>
<p><a rel="license" href="https://creativecommons.org/publicdomain/zero/1.0/">CC0</a>: public domain. Version 1.1, 20230626.1</p> <p><a rel="license" href="https://creativecommons.org/publicdomain/zero/1.0/">CC0</a>: public domain. Version 1.2, 20240514.1</p>
</body> </body>
</html> </html>

View File

@ -24,15 +24,17 @@
<script type="text/javascript"> <script type="text/javascript">
// Global variables... // Global variables...
var bisrvData; // Used to store the binary data from the bisrv.asd file let bisrvData; // Used to store the binary data from the bisrv.asd file
var logoOffset; // Will contain the offset of the boot logo within the bisrv.asd file let logoOffset; // Will contain the offset of the boot logo within the bisrv.asd file
var newLogoData; // Used to store the little-endian RGB565 binary data of the new boot logo let newLogoData; // Used to store raw ImageData of the new boot logo
let ditherEnabled = false; // Holds whether the user wants to enable dithering for RGB565 mode or not
let ditherStrength = 0.2; // Holds the selected dither strength value - 0.2 seems to be a good general balance
// This function is triggered when the person selects a file in Step 1... // This function is triggered when the person selects a file in Step 1...
function bisrvLoad(file) { function bisrvLoad(file) {
// Read in the contents of the selected file as array buffer... // Read in the contents of the selected file as array buffer...
var frBisrv = new FileReader(); const frBisrv = new FileReader();
frBisrv.readAsArrayBuffer(file); frBisrv.readAsArrayBuffer(file);
frBisrv.onload = function(event) { frBisrv.onload = function(event) {
@ -48,13 +50,14 @@
bisrvData = new Uint8Array(event.target.result); bisrvData = new Uint8Array(event.target.result);
// We'll do a hash-check against it... // We'll do a hash-check against it...
hashResult = getFirmwareHash(bisrvData); const hashResult = getFirmwareHash(bisrvData);
// The result could be either a Promise if it had a bisrv.asd-like structure and we got // The result could be either a Promise if it had a bisrv.asd-like
// a hash, or false otherwise... let's check! // structure and we got a hash, or false otherwise... let's check!
if (hashResult instanceof Promise) { if (hashResult instanceof Promise) {
// We got a Promise! Wait for it to finish so we get our bisrv.asd hash... // We got a Promise! Wait for it to finish so we get our bisrv.asd
// hash...
hashResult.then(function(dataHash) { hashResult.then(function(dataHash) {
// Check the hash against all the known good ones... // Check the hash against all the known good ones...
@ -103,14 +106,15 @@
break; break;
default: default:
// Huh... wasn't false so had bisrv.asd structure, but didn't return // Huh... wasn't false so had bisrv.asd structure, but didn't
// a known hash... a new BIOS version? Unknown anyway! // return a known hash... a new BIOS version? Unknown anyway!
console.log(dataHash); console.log(dataHash);
setMessage("error", "bisrvMessages", "ERROR: While the file you've selected does appear to be generally structured like the SF2000's <code>bisrv.asd</code> BIOS file, the specifics of your file don't match any known SF2000 BIOS version. As such, this tool cannot safely modify the selected file."); setMessage("error", "bisrvMessages", "ERROR: While the file you've selected does appear to be generally structured like the SF2000's <code>bisrv.asd</code> BIOS file, the specifics of your file don't match any known SF2000 BIOS version. As such, this tool cannot safely modify the selected file.");
return; return;
} }
// If we're here we've got a good file, so onwards to Step 2 (image selection)... // If we're here we've got a good file, so onwards to Step 2
// (image selection)...
stepTwo(); stepTwo();
}); });
} }
@ -132,20 +136,27 @@
// file is an image, and that it has the correct dimensions of 256x100 // file is an image, and that it has the correct dimensions of 256x100
// pixels; if it is, we convert the image data to an SF2000 format and // pixels; if it is, we convert the image data to an SF2000 format and
// move on to Step 3. First, let's start building our HTML... // move on to Step 3. First, let's start building our HTML...
var html = "<section id=\"imageSection\"><h2>Step 2: Select New Logo Image File</h2><p>Select the image file you want to use as the new boot logo. It must be 256 pixels wide and 100 pixels tall. Only PNG and JPEG image types are supported. For PNG files, transparency is ignored; also, you might want to make sure your PNG image has any embedded gamma or colour profile information removed first using a metadata scrubbing tool, otherwise the colour output from <i>this</i> tool might be incorrect. The image is displayed centred on a black background, so you may want to factor that into your design as well.</p><div id=\"imageMessages\"></div>"; let html = "<section id=\"imageSection\"><h2>Step 2: Select New Logo Image File</h2><p>Select the image file you want to use as the new boot logo. It must be 256 pixels wide and 100 pixels tall. Only PNG and JPEG image types are supported. For PNG files, transparency is handled by drawing the image on a black background; also, you might want to make sure your PNG image has any embedded gamma or colour profile information removed first using a metadata scrubbing tool, otherwise the colour output from <i>this</i> tool might be incorrect. On the SF2000, the boot logo is displayed centred on a black background, so you may want to factor that into your design as well.</p><div id=\"imageMessages\"></div>";
// Add our file chooser... // Add our file chooser and dithering controls...
html += "<div class=\"controlContainer\"><label class=\"control\"><input id=\"inputImage\" type=\"file\"></label></div>"; html += "<form id=\"imageForm\" class=\"controlContainer\"><div class=\"control\">";
html += "<label class=\"control\"><input id=\"inputImage\" type=\"file\"></label>";
html += "<div class=\"control\"><label for=\"ditherEnabled\">Dithering: <input type=\"checkbox\" id=\"ditherEnabled\" name=\"ditherEnabled\"" + (ditherEnabled == true ? " checked" : "") + "></label> <label for=\"ditherStrength\">Strength: <select id=\"ditherStrength\" name=\"ditherStrength\"><option" + (ditherStrength == 0.1 ? " selected" : "") + ">0.1</option><option" + (ditherStrength == 0.2 ? " selected" : "") + ">0.2</option><option" + (ditherStrength == 0.3 ? " selected" : "") + ">0.3</option><option" + (ditherStrength == 0.5 ? " selected" : "") + ">0.5</option><option" + (ditherStrength == 0.75 ? " selected" : "") + ">0.75</option><option" + (ditherStrength == 1.0 ? " selected" : "") + ">1</option><option" + (ditherStrength == 2.0 ? " selected" : "") + ">2</option></select></label></div>";
html += "</div></form>";
// Next we'll add our image preview...
html += "<div id=\"previewContainer\" class=\"controlContainer\" style=\"display: none;\"><div class=\"control\"><canvas id=\"processFilePreview\" width=\"256\" height=\"100\"></canvas></div></div>";
// Close our section... // Close our section...
html += "</section>"; html += "</section>";
// Finally, add a <hr> separator after the last step, and append the new step... // Finally, add a <hr> separator after the last step, and append the new
// step...
document.getElementById("steps").insertAdjacentHTML("beforeend", "<hr>"); document.getElementById("steps").insertAdjacentHTML("beforeend", "<hr>");
document.getElementById("steps").insertAdjacentHTML("beforeend", html); document.getElementById("steps").insertAdjacentHTML("beforeend", html);
// Attach our event handler to our new file input control... // Attach our event handler to our new file input control...
var userImageInput = document.getElementById("inputImage"); const userImageInput = document.getElementById("inputImage");
userImageInput.addEventListener("change", function() { userImageInput.addEventListener("change", function() {
// The user has chosen a new file; it should be either a PNG or JPEG // The user has chosen a new file; it should be either a PNG or JPEG
@ -154,17 +165,24 @@
// First let's clear any old messages... // First let's clear any old messages...
document.getElementById("imageMessages").innerHTML = ""; document.getElementById("imageMessages").innerHTML = "";
// ... and make sure our preview image is hidden...
document.getElementById("previewContainer").style.display = "none";
// ... and clear our newLogoData object ...
newLogoData = new ImageData(1, 1);
// And clear up any HTML already added after the current step... // And clear up any HTML already added after the current step...
while(document.getElementById("imageSection").nextSibling) { while(document.getElementById("imageSection").nextSibling) {
document.getElementById("imageSection").nextSibling.remove(); document.getElementById("imageSection").nextSibling.remove();
} }
// Now let's read in the file... // Now let's read in the file...
var frImage = new FileReader(); const frImage = new FileReader();
frImage.readAsDataURL(event.target.files[0]); frImage.readAsDataURL(event.target.files[0]);
frImage.onload = function(event) { frImage.onload = function(event) {
// The file is loaded; let's check to make sure we got a PNG or JPEG... // The file is loaded; let's check to make sure we got a PNG or
// JPEG...
if (!event.target.result.includes("data:image/png;") && !event.target.result.includes("data:image/jpeg;")) { if (!event.target.result.includes("data:image/png;") && !event.target.result.includes("data:image/jpeg;")) {
// Whoops! Doesn't look like the user provided a PNG or a JPEG! // Whoops! Doesn't look like the user provided a PNG or a JPEG!
@ -172,36 +190,60 @@
return; return;
} }
// Now we're going to load the file's data into an Image object, so we can // Now we're going to load the file's data into an Image object, so
// access the raw image data... // we can access the raw image data...
var img = new Image; const img = new Image;
img.src = event.target.result; img.src = event.target.result;
img.onload = function(event) { img.onload = function(event) {
// Check to make sure the image has the correct dimensions for a // Check to make sure the image has the correct dimensions for a
// boot logo... // boot logo...
if (img.width != 256 || img.height != 100) { if (img.width != 256 || img.height != 100) {
setMessage("error", "imageMessages", "ERROR: The selected image does not have dimensions of 256x100px!"); setMessage("error", "imageMessages", "ERROR: The selected image does not have dimensions of 256 x 100!");
return; return;
} }
// We need to scale the image to the internal resolution used by // If we're here, the image is good - store its data in our
// the SF2000 (512x200)... // global...
var imageData = scaleImage(imageToImageData(img), 512, 200, "Nearest Neighbour"); newLogoData = imageToImageData(img, false);
// Next we'll convert the image data to little-endian RGB565 // And finally, let's show our preview image...
// format for the SF2000... document.getElementById("previewContainer").style.display = "flex";
newLogoData = imageDataToRgb565(imageData); renderPreview();
// On to Step 3! // On to Step 3!
stepThree(); stepThree();
} }
} }
}); });
// And finally, attach an event handler to the overall form - we'll use
// this to get the dithering settings, and as the trigger for updating
// the preview image accordingly...
const imageForm = document.getElementById("imageForm");
imageForm.addEventListener("change", function() {
// Get the state of all of our form controls...
const formData = Object.fromEntries(new FormData(imageForm));
// Set our general globals from the current form state...
//if (formData.ditherEnabled !== undefined) {ditherEnabled = true;}
//else if (imageFormat == "RGB565") {ditherEnabled = false;}
ditherEnabled = formData.ditherEnabled ? true : false;
ditherStrength = formData.ditherStrength? parseFloat(formData.ditherStrength) : ditherStrength;
// Finally render an updated preview...
renderPreview();
});
} }
// This function is where we display our download button, which patches
// the provided bisrv.asd with the updated logo data, and sends it to the
// user's browser as a download...
function stepThree() { function stepThree() {
var html = "<section id=\"downloadSection\"><h2>Step 3: Download Updated <code>bisrv.asd</code></h2><p>Click the download button for the updated <code>bisrv.asd</code> file; use it to replace the one in the <code>bios</code> folder on your SF2000's microSD card.</p><div id=\"downloadMessages\"></div>";
// As usual, start building up our HTML...
let html = "<section id=\"downloadSection\"><h2>Step 3: Download Updated <code>bisrv.asd</code></h2><p>Click the download button for the updated <code>bisrv.asd</code> file; use it to replace the one in the <code>bios</code> folder on your SF2000's microSD card.</p><div id=\"downloadMessages\"></div>";
// Add our download button... // Add our download button...
html += "<div class=\"controlContainer\"><div class=\"control\"><input id=\"userDownload\" type=\"button\" value=\"Download\"></div></div>"; html += "<div class=\"controlContainer\"><div class=\"control\"><input id=\"userDownload\" type=\"button\" value=\"Download\"></div></div>";
@ -209,35 +251,59 @@
// Close our section... // Close our section...
html += "</section>"; html += "</section>";
// Finally, add a <hr> separator after the last step, and append the new step... // Finally, add a <hr> separator after the last step, and append the new
// step...
document.getElementById("steps").insertAdjacentHTML("beforeend", "<hr>"); document.getElementById("steps").insertAdjacentHTML("beforeend", "<hr>");
document.getElementById("steps").insertAdjacentHTML("beforeend", html); document.getElementById("steps").insertAdjacentHTML("beforeend", html);
// Let's add the event handler for our Download button... // Let's add the event handler for our Download button...
var dButton = document.getElementById("userDownload"); const dButton = document.getElementById("userDownload");
dButton.addEventListener("click", function() { dButton.addEventListener("click", function() {
// So, we should have the original bisrv.asd data in bisrvData; and we should have // So, we should have the original bisrv.asd data in bisrvData; and we
// the offset to the original logo data in logoOffset; and finally, we should have // should have the offset to the original logo data in logoOffset; and
// our new logo's binary data in newLogoData. All we need to do is replace the old // finally, we should have our new logo's binary data in newLogoData.
// data bytes with the new bytes, re-calculate the CRC32 bytes for the modified // All we need to do is convert the new logo to RGB565 format (using
// file and set them in the data, and send the data to the user's browser. Easy! // whatever dithering options are set), and then replace the old data
// bytes with the new bytes, re-calculate the CRC32 bytes for the
// modified file and set them in the data, and send the data to the
// user's browser. Easy!
// First, replace the logo data... // First, get the RGB565 version of the logo; this is a *bit* messy,
for (var i = 0; i < newLogoData.length; i++) { // as we take the user's raw input image, convert it to binary RGB565
bisrvData[logoOffset + i] = newLogoData[i]; // data, then convert it back to an ImageData object so we can upscale
// it with a 2x nearest neighbour upscale, and then convert the result
// *back* to an RGB565 binary array... dumb, but it works!
const convertedImage = imageDataToRgb565(scaleImage(rgb565ToImageData(imageDataToRgb565(newLogoData, ditherEnabled, ditherStrength), 256, 100), 512, 200, "Nearest Neighbour"));
// Now replace the old logo data in the bisrv.asd file...
for (let i = 0; i < convertedImage.length; i++) {
bisrvData[logoOffset + i] = convertedImage[i];
} }
// Next, we calculate a new CRC32 for the updated bisrv.asd and apply it... // Next, patch the CRC32 value in the bisrv.asd to account for the new
// logo data...
patchCRC32(bisrvData); patchCRC32(bisrvData);
// And finally, send the data to the user's browser as a file download... // And finally, send the updated bisrv.asd data to the user's browser
// as a file download...
downloadToBrowser(bisrvData, "application/octet-stream", "bisrv.asd"); downloadToBrowser(bisrvData, "application/octet-stream", "bisrv.asd");
}); });
} }
// This utility function renders a preview of the final image...
function renderPreview() {
if (newLogoData.data.length > 4) {
let convertedImageData = imageDataToRgb565(newLogoData, ditherEnabled, ditherStrength);
convertedImageData = rgb565ToImageData(convertedImageData, 256, 100);
const previewCanvas = document.getElementById("processFilePreview");
const previewContext = previewCanvas.getContext("2d");
previewContext.clearRect(0, 0, 256, 100);
previewContext.putImageData(convertedImageData, 0, 0);
}
}
</script> </script>
<hr> <hr>
<p><a rel="license" href="https://creativecommons.org/publicdomain/zero/1.0/">CC0</a>: public domain. Version 1.5, 20231019.1</p> <p><a rel="license" href="https://creativecommons.org/publicdomain/zero/1.0/">CC0</a>: public domain. Version 1.6, 20240514.1</p>
</body> </body>
</html> </html>

View File

@ -26,11 +26,11 @@
<script> <script>
// Global variables... // Global variables...
var mappingTableOffset; // Will contain the offset of the button mappings within the data file let mappingTableOffset; // Will contain the offset of the button mappings within the data file
var mappingConsoles; // Will contain a list of the specific game consoles we'll be setting up mappings for let mappingConsoles; // Will contain a list of the specific game consoles we'll be setting up mappings for
var firmwareVersion; // Will contain the firmware version if the user selects a bisrv.asd file let firmwareVersion; // Will contain the firmware version if the user selects a bisrv.asd file
var mappingData; // Used to store the binary data that will eventually be written to the downloadable file let mappingData; // Used to store the binary data that will eventually be written to the downloadable file
var fileName; // Will hold the name of the selected file, used for naming ROM .kmp files let fileName; // Will hold the name of the selected file, used for naming ROM .kmp files
// Utility function: getButtonMap(int index) // Utility function: getButtonMap(int index)
// ========================================= // =========================================
@ -52,9 +52,9 @@
// This function is called whenever a file is selected in Step 1... // This function is called whenever a file is selected in Step 1...
function fileLoad(file) { function fileLoad(file) {
// Create a FileReader object, and read in the selected file's contents as // Create a FileReader object, and read in the selected file's contents
// an array buffer... // as an array buffer...
var fr = new FileReader(); const fr = new FileReader();
fr.readAsArrayBuffer(file); fr.readAsArrayBuffer(file);
fr.onload = function(event) { fr.onload = function(event) {
@ -69,22 +69,25 @@
// We'll also reset the firmwareVersion global variable... // We'll also reset the firmwareVersion global variable...
firmwareVersion = null; firmwareVersion = null;
// Read the provided file's data from the buffer array into an unsigned 8-bit int array... // Read the provided file's data from the buffer array into an
var data = new Uint8Array(event.target.result); // unsigned 8-bit int array...
const data = new Uint8Array(event.target.result);
// We'll do a hash-check against it, even if it's not a bisrv.asd... // We'll do a hash-check against it, even if it's not a bisrv.asd...
hashResult = getFirmwareHash(data); const hashResult = getFirmwareHash(data);
// The result could be either a Promise if it had a bisrv.asd-like structure and we got // The result could be either a Promise if it had a bisrv.asd-like
// a hash, or false otherwise... let's check! // structure and we got a hash, or false otherwise... let's check!
if (hashResult instanceof Promise) { if (hashResult instanceof Promise) {
// We got a Promise! Wait for it to finish so we get our bisrv.asd hash... // We got a Promise! Wait for it to finish so we get our bisrv.asd
// hash...
hashResult.then(function(dataHash) { hashResult.then(function(dataHash) {
// If we have a newer bisrv.asd that stores button mappings in an external KeyMapInfo.kmp, // If we have a newer bisrv.asd that stores button mappings in an
// we'll need a Step 1b to load that file as well... // external KeyMapInfo.kmp, we'll need a Step 1b to load that file
var step1BRequired = false; // as well...
let step1BRequired = false;
// Check the hash against all the known good ones... // Check the hash against all the known good ones...
firmwareVersion = knownHash(dataHash); firmwareVersion = knownHash(dataHash);
@ -144,22 +147,23 @@
break; break;
default: default:
// Huh... wasn't false so had bisrv.asd structure, but didn't return // Huh... wasn't false so had bisrv.asd structure, but didn't
// a known hash... a new BIOS version? Unknown anyway! // return a known hash... a new BIOS version? Unknown anyway!
console.log(dataHash); console.log(dataHash);
setMessage("error", "fileMessages", "ERROR: While the file you've selected does appear to be generally structured like the SF2000's <code>bisrv.asd</code> BIOS file, the specifics of your file don't match any known SF2000 BIOS version. As such, this tool cannot safely modify the selected file."); setMessage("error", "fileMessages", "ERROR: While the file you've selected does appear to be generally structured like the SF2000's <code>bisrv.asd</code> BIOS file, the specifics of your file don't match any known SF2000 BIOS version. As such, this tool cannot safely modify the selected file.");
return; return;
break; break;
} }
// If we're here, then we got some kind bisrv.asd file we're happy with; we'll set // If we're here, then we got some kind bisrv.asd file we're happy
// mappingData to it's full contents... // with; we'll set mappingData to it's full contents...
mappingData = data; mappingData = data.slice();
// Keep a record of the input file's name as well... // Keep a record of the input file's name as well...
fileName = file.name; fileName = file.name;
// Check if we need a KeyMapInfo.kmp file to be provided as well... // Check if we need a KeyMapInfo.kmp file to be provided as
// well...
if (step1BRequired) { if (step1BRequired) {
// Yup, we're going to need a KeyMapInfo.kmp file as well... // Yup, we're going to need a KeyMapInfo.kmp file as well...
stepOneB(); stepOneB();
@ -172,55 +176,64 @@
}); });
} }
else { else {
// We got false, so whatever it was, it wasn't a bisrv.asd... let's check some other // We got false, so whatever it was, it wasn't a bisrv.asd... let's
// possibilities... // check some other possibilities...
if (data.length == 288) { if (data.length == 288) {
// That's the correct length for a KeyMapInfo.kmp file, however we must know the host // That's the correct length for a KeyMapInfo.kmp file, however we
// BIOS version before we can correctly process those files. Let the user know... // must know the host BIOS version before we can correctly process
// those files. Let the user know...
setMessage("error", "fileMessages", "ERROR: The file you've provided may be a <code>KeyMapInfo.kmp</code> button map file; however as the internal data structure of these files varies depending on the version of the host BIOS, you must select your device's <code>bisrv.asd</code> file first. You can find this file in the <code>bios</code> folder on your device's microSD card."); setMessage("error", "fileMessages", "ERROR: The file you've provided may be a <code>KeyMapInfo.kmp</code> button map file; however as the internal data structure of these files varies depending on the version of the host BIOS, you must select your device's <code>bisrv.asd</code> file first. You can find this file in the <code>bios</code> folder on your device's microSD card.");
return; return;
} }
// If we're still checking, next test the file extensions for the individual console's ROMs... // If we're still checking, next test the file extensions for the
// individual console's ROMs...
else if (/\.(zfb|zip)$/i.exec(file.name)) { else if (/\.(zfb|zip)$/i.exec(file.name)) {
// The file's name ends with .zfb or .zip - assume it's an arcade ROM! // The file's name ends with .zfb or .zip - assume it's an arcade
// ROM!
mappingConsoles = ["Arcade"]; mappingConsoles = ["Arcade"];
} }
else if (/\.(zgb|gba|agb|gbz)$/i.exec(file.name)) { else if (/\.(zgb|gba|agb|gbz)$/i.exec(file.name)) {
// The file's name ends with .zgb, .gba, .agb or .gbz - assume it's a Game Boy Advance ROM! // The file's name ends with .zgb, .gba, .agb or .gbz - assume
// it's a Game Boy Advance ROM!
mappingConsoles = ["Game Boy Advance"]; mappingConsoles = ["Game Boy Advance"];
} }
else if (/\.(gbc|gb|sgb)$/i.exec(file.name)) { else if (/\.(gbc|gb|sgb)$/i.exec(file.name)) {
// The file's name ends with .gbc, .gb or .sgb - assume it's a Game Boy or Game Boy Color ROM! // The file's name ends with .gbc, .gb or .sgb - assume it's a
// Game Boy or Game Boy Color ROM!
mappingConsoles = ["Game Boy, Game Boy Color"]; mappingConsoles = ["Game Boy, Game Boy Color"];
} }
else if (/\.(zsf|smc|fig|sfc|gd3|gd7|dx2|bsx|swc)$/i.exec(file.name)) { else if (/\.(zsf|smc|fig|sfc|gd3|gd7|dx2|bsx|swc)$/i.exec(file.name)) {
// The file's name ends with .zsf, .smc, .fig, .sfc, .gd3, .gd7, .dx2, .bsx or .swc - assume it's a SNES ROM! // The file's name ends with .zsf, .smc, .fig, .sfc, .gd3, .gd7,
// .dx2, .bsx or .swc - assume it's a SNES ROM!
mappingConsoles = ["SNES"]; mappingConsoles = ["SNES"];
} }
else if (/\.(zmd|bin|md|smd|gen|sms)$/i.exec(file.name)) { else if (/\.(zmd|bin|md|smd|gen|sms)$/i.exec(file.name)) {
// The file's name ends with .zmd, .bin, .md, .smd, .gen or .sms - assume it's a Genesis/Mega Drive or Master System ROM! // The file's name ends with .zmd, .bin, .md, .smd, .gen or .sms,
// assume it's a Genesis/Mega Drive or Master System ROM!
mappingConsoles = ["Genesis/Mega Drive, Master System"]; mappingConsoles = ["Genesis/Mega Drive, Master System"];
} }
else if (/\.(zfc|nes|nfc|fds|unf)$/i.exec(file.name)) { else if (/\.(zfc|nes|nfc|fds|unf)$/i.exec(file.name)) {
// The file's name ends with .zfc, .nes, .nfc, .fds or .unf - assume it's a NES ROM! // The file's name ends with .zfc, .nes, .nfc, .fds or .unf,
// assume it's a NES ROM!
mappingConsoles = ["NES"]; mappingConsoles = ["NES"];
} }
else { else {
// Oh dear, the provided file didn't match any of the above rules! Display an error // Oh dear, the provided file didn't match any of the above rules!
// to the user... // Display an error to the user...
setMessage("error", "fileMessages", "ERROR: The selected file does not appear to be a known <code>bisrv.asd</code> file, a <code>KeyMapInfo.kmp</code> file, or a game ROM with a known extension!"); setMessage("error", "fileMessages", "ERROR: The selected file does not appear to be a known <code>bisrv.asd</code> file, a <code>KeyMapInfo.kmp</code> file, or a game ROM with a known extension!");
return; return;
} }
// If we're here, then we got some kind of non-bisrv.asd file we're happy with. If // If we're here, then we got some kind of non-bisrv.asd file we're
// mappingConsoles only contains one entry, then it was a ROM file, and we'll want to // happy with. If mappingConsoles only contains one entry, then it
// initialise our mappingData array with 48 slots; otherwise, it was a KeyMapInfo.kmp // was a ROM file, and we'll want to initialise our mappingData
// and we'll set mappingData to it's full contents instead... // array with 48 slots; otherwise, it was a KeyMapInfo.kmp and we'll
// set mappingData to it's full contents instead...
if (mappingConsoles.length == 1) { if (mappingConsoles.length == 1) {
mappingData = new Uint8Array(48); mappingData = new Uint8Array(48);
} }
else { else {
mappingData = data; mappingData = data.slice();
} }
// In both cases, the mapping data begins at the very start of our data stream... // In both cases, the mapping data begins at the very start of our data stream...
@ -241,14 +254,14 @@
function stepOneB() { function stepOneB() {
// Build our HTML... // Build our HTML...
var html = "<section id=\"stepOneB\"><h2>Step 1b: Select <code>KeyMapInfo.kmp</code></h2><p>This version of the SF2000 BIOS reads its button mappings from an external file called <code>KeyMapInfo.kmp</code>, stored in the <code>Resources</code> folder on the microSD card. Please select your device's <code>KeyMapInfo.kmp</code> file now.</p><div id=\"stepOneBMessages\"></div><div class=\"controlContainer\"><label class=\"control\">Open <code>KeyMapInfo.kmp</code>: <input id=\"keyMapInfoSelector\" type=\"file\" accept=\".kmp\"></label></div></section>"; let html = "<section id=\"stepOneB\"><h2>Step 1b: Select <code>KeyMapInfo.kmp</code></h2><p>This version of the SF2000 BIOS reads its button mappings from an external file called <code>KeyMapInfo.kmp</code>, stored in the <code>Resources</code> folder on the microSD card. Please select your device's <code>KeyMapInfo.kmp</code> file now.</p><div id=\"stepOneBMessages\"></div><div class=\"controlContainer\"><label class=\"control\">Open <code>KeyMapInfo.kmp</code>: <input id=\"keyMapInfoSelector\" type=\"file\" accept=\".kmp\"></label></div></section>";
// Add a <hr> separator after the last step, and append the new step... // Add a <hr> separator after the last step, and append the new step...
document.getElementById("steps").insertAdjacentHTML("beforeend", "<hr>"); document.getElementById("steps").insertAdjacentHTML("beforeend", "<hr>");
document.getElementById("steps").insertAdjacentHTML("beforeend", html); document.getElementById("steps").insertAdjacentHTML("beforeend", html);
// Attach our event handler to our new file input control... // Attach our event handler to our new file input control...
var keyMapInfoInput = document.getElementById("keyMapInfoSelector"); const keyMapInfoInput = document.getElementById("keyMapInfoSelector");
keyMapInfoInput.addEventListener("change", function() { keyMapInfoInput.addEventListener("change", function() {
// The user has chosen a new file; it should be a KeyMapInfo.kmp file, // The user has chosen a new file; it should be a KeyMapInfo.kmp file,
@ -263,29 +276,29 @@
document.getElementById("stepOneBMessages").innerHTML = ""; document.getElementById("stepOneBMessages").innerHTML = "";
// Next, read in the contents of the user-provided file... // Next, read in the contents of the user-provided file...
var frKMI = new FileReader(); const frKMI = new FileReader();
fileName = event.target.files[0].name; fileName = event.target.files[0].name;
frKMI.readAsDataURL(event.target.files[0]); frKMI.readAsDataURL(event.target.files[0]);
frKMI.onload = function(event) { frKMI.onload = function(event) {
// Get the file's data and data type... // Get the file's data and data type...
var fileData = event.target.result; const fileData = event.target.result;
var dataType = fileData.substring(5, fileData.indexOf(";")); const dataType = fileData.substring(5, fileData.indexOf(";"));
// Check to make sure the data type is binary... // Check to make sure the data type is binary...
if (dataType === "application/octet-stream") { if (dataType === "application/octet-stream") {
// The user selected a file that appears to contain binary data; it's // The user selected a file that appears to contain binary data; it's
// a good candidate! Let's check its length next... // a good candidate! Let's check its length next...
var base64Data = fileData.substring(fileData.indexOf(",") + 1); const base64Data = fileData.substring(fileData.indexOf(",") + 1);
var binaryData = atob(base64Data); const binaryData = atob(base64Data);
if (binaryData.length == 288) { if (binaryData.length == 288) {
// It's the right length - let's assume it's a KeyMapInfo.kmp! // It's the right length - let's assume it's a KeyMapInfo.kmp!
// We'll assign it to our global mappingData variable, and // We'll assign it to our global mappingData variable, and
// we'll proceed to Step 2... // we'll proceed to Step 2...
mappingData = new Uint8Array(binaryData.length); mappingData = new Uint8Array(binaryData.length);
for (var i = 0; i < binaryData.length; i++) { for (let i = 0; i < binaryData.length; i++) {
mappingData[i] = binaryData.charCodeAt(i); mappingData[i] = binaryData.charCodeAt(i);
} }
stepTwo(); stepTwo();
@ -321,7 +334,7 @@
// mapped, and for each a selection box of the target console's buttons // mapped, and for each a selection box of the target console's buttons
// for the mapping. There'll also be a checkbox per button, which can // for the mapping. There'll also be a checkbox per button, which can
// be checked to enable "autofire" on that button. // be checked to enable "autofire" on that button.
var html = "<section id=\"mappingSection\"><h2>Step 2: Choose your button mappings</h2>"; let html = "<section id=\"mappingSection\"><h2>Step 2: Choose your button mappings</h2>";
// First, we need to update Step 2's instructions, depending on whether // First, we need to update Step 2's instructions, depending on whether
// or not the user supplied a bisrv.asd file or KeyMapInfo.kmp // or not the user supplied a bisrv.asd file or KeyMapInfo.kmp
@ -340,19 +353,19 @@
// Next we'll be looping through all of the consoles we'll be setting // Next we'll be looping through all of the consoles we'll be setting
// up mappings for... // up mappings for...
var presentationButtonOrder = ['A', 'B' ,'X', 'Y', 'L', 'R']; const presentationButtonOrder = ['A', 'B' ,'X', 'Y', 'L', 'R'];
for (var currentConsole = 0; currentConsole < mappingConsoles.length; currentConsole++) { for (let currentConsole = 0; currentConsole < mappingConsoles.length; currentConsole++) {
// This console's bank of mapping controls will be stored in a <div>, // This console's bank of mapping controls will be stored in a <div>,
// and we'll add a <h3> header for good measure as well... // and we'll add a <h3> header for good measure as well...
html += "<div class=\"control\"><h3>" + mappingConsoles[currentConsole] + "</h3>"; html += "<div class=\"control\"><h3>" + mappingConsoles[currentConsole] + "</h3>";
// Get the button mapping for this console... // Get the button mapping for this console...
var buttonMap = getButtonMap(currentConsole); const buttonMap = getButtonMap(currentConsole);
// We'll add two tables of control mappings to the <div>, one each // We'll add two tables of control mappings to the <div>, one each
// for Player 1 and Player 2... // for Player 1 and Player 2...
for (var player = 0; player < 2; player++) { for (let player = 0; player < 2; player++) {
// Start creating our table HTML... // Start creating our table HTML...
html += "<table><caption>Player " + (player + 1) + "</caption>"; html += "<table><caption>Player " + (player + 1) + "</caption>";
@ -361,21 +374,21 @@
// Loop through all the SF2000's buttons (well, the ones that can // Loop through all the SF2000's buttons (well, the ones that can
// be mapped, anyway)... // be mapped, anyway)...
for (var button = 0; button < 6; button++) { for (let button = 0; button < 6; button++) {
// By default, the SF2000 stores its button maps in XYLABR // By default, the SF2000 stores its button maps in XYLABR
// order... except for GBA under newer firmware versions where // order... except for GBA under newer firmware versions where
// the order is LRXABY for some reason. We specify the order the // the order is LRXABY for some reason. We specify the order the
// bytes are in here. If they do other weird stuff in the future, // bytes are in here. If they do other weird stuff in the future,
// it'll probably be here that needs to change! // it'll probably be here that needs to change!
var buttonByteOrder = ['X', 'Y' ,'L', 'A', 'B', 'R']; let buttonByteOrder = ['X', 'Y' ,'L', 'A', 'B', 'R'];
if (mappingConsoles[currentConsole] == "Game Boy Advance" && ["05.15", "05.22", "08.03", "10.07", "10.13"].includes(firmwareVersion)) { if (mappingConsoles[currentConsole] == "Game Boy Advance" && ["05.15", "05.22", "08.03", "10.07", "10.13"].includes(firmwareVersion)) {
buttonByteOrder = ['L', 'R', 'X', 'A', 'B', 'Y']; buttonByteOrder = ['L', 'R', 'X', 'A', 'B', 'Y'];
} }
// Calculate our offset within our mapping data for the current // Calculate our offset within our mapping data for the current
// button... // button...
var offset = mappingTableOffset + (currentConsole * 48) + (player * 24) + (buttonByteOrder.indexOf(presentationButtonOrder[button]) * 4); const offset = mappingTableOffset + (currentConsole * 48) + (player * 24) + (buttonByteOrder.indexOf(presentationButtonOrder[button]) * 4);
// Start creating the HTML data for this row in the table... // Start creating the HTML data for this row in the table...
html += "<tr>"; html += "<tr>";
@ -386,7 +399,7 @@
// Console button selection list... // Console button selection list...
html += "<td class=\"alignC\">"; html += "<td class=\"alignC\">";
html += "<select id=\"sel" + offset.toString(16) + "\">"; html += "<select id=\"sel" + offset.toString(16) + "\">";
for (var buttonTable in buttonMap) { for (let buttonTable in buttonMap) {
html += "<option "; html += "<option ";
if (mappingData[offset] == buttonMap[buttonTable]) { if (mappingData[offset] == buttonMap[buttonTable]) {
html += "selected"; html += "selected";
@ -437,7 +450,7 @@
} }
// Now let's start building our HTML... // Now let's start building our HTML...
var html = "<section id=\"saveSection\"><h2>Step 3: Save your mapping changes</h2>"; let html = "<section id=\"saveSection\"><h2>Step 3: Save your mapping changes</h2>";
// First up, instructions! These will depend on whether they provided a // First up, instructions! These will depend on whether they provided a
// bisrv.asd, a KeyMapInfo.kmp, or a game ROM... // bisrv.asd, a KeyMapInfo.kmp, or a game ROM...
@ -462,7 +475,7 @@
// They provided a ROM file! To make the instructions clearer, let's // They provided a ROM file! To make the instructions clearer, let's
// calculate the name of the keymap file we're generating... // calculate the name of the keymap file we're generating...
var kmpFileName = fileName.replace(/($|\.[^.]*$)/, function(m, p1) {return p1.toUpperCase() + '.kmp';}); const kmpFileName = fileName.replace(/($|\.[^.]*$)/, function(m, p1) {return p1.toUpperCase() + '.kmp';});
// Now the instructions themselves... // Now the instructions themselves...
html += "<p>Click the Download button below to download \"" + kmpFileName + "\", a game-specific keymap file for \"" + fileName + "\". Once downloaded, place it in the <code>save</code> subfolder of the folder where the ROM itself is stored. So for example, if \"" + fileName + "\" is in the <code>ROMS</code> folder on your SF2000's microSD card, place the \"" + kmpFileName + "\" file in <code>ROMS/save/</code>. If the <code>save</code> subfolder does not already exist, create it yourself first.</p>"; html += "<p>Click the Download button below to download \"" + kmpFileName + "\", a game-specific keymap file for \"" + fileName + "\". Once downloaded, place it in the <code>save</code> subfolder of the folder where the ROM itself is stored. So for example, if \"" + fileName + "\" is in the <code>ROMS</code> folder on your SF2000's microSD card, place the \"" + kmpFileName + "\" file in <code>ROMS/save/</code>. If the <code>save</code> subfolder does not already exist, create it yourself first.</p>";
@ -480,7 +493,7 @@
document.getElementById("steps").insertAdjacentHTML("beforeend", html); document.getElementById("steps").insertAdjacentHTML("beforeend", html);
// Let's add the event handler for our Download button... // Let's add the event handler for our Download button...
var dButton = document.getElementById("downloadButton"); const dButton = document.getElementById("downloadButton");
dButton.addEventListener("click", function() { dButton.addEventListener("click", function() {
// Here, we'll construct the file for the user to download (a // Here, we'll construct the file for the user to download (a
@ -491,24 +504,24 @@
// settings, and use those settings to build the binary data of our // settings, and use those settings to build the binary data of our
// button mapping. Loop through all of the consoles we're mapping // button mapping. Loop through all of the consoles we're mapping
// for... // for...
var presentationButtonOrder = ['A', 'B' ,'X', 'Y', 'L', 'R']; const presentationButtonOrder = ['A', 'B' ,'X', 'Y', 'L', 'R'];
for (var currentConsole = 0; currentConsole < mappingConsoles.length; currentConsole ++) { for (let currentConsole = 0; currentConsole < mappingConsoles.length; currentConsole ++) {
// Get the button mapping for this console... // Get the button mapping for this console...
var buttonMap = getButtonMap(currentConsole); const buttonMap = getButtonMap(currentConsole);
// For each player... // For each player...
for (var player = 0; player < 2; player++) { for (let player = 0; player < 2; player++) {
// ... and for each button... // ... and for each button...
for (var button = 0; button < 6; button++) { for (let button = 0; button < 6; button++) {
// By default, the SF2000 stores its button maps in XYLABR // By default, the SF2000 stores its button maps in XYLABR
// order... except for GBA under newer firmware versions where // order... except for GBA under newer firmware versions where
// the order is LRXABY for some reason. We specify the order // the order is LRXABY for some reason. We specify the order
// the bytes are in here. If they do other weird stuff in the // the bytes are in here. If they do other weird stuff in the
// future, it'll probably be here that needs to change! // future, it'll probably be here that needs to change!
var buttonByteOrder = ['X', 'Y' ,'L', 'A', 'B', 'R']; let buttonByteOrder = ['X', 'Y' ,'L', 'A', 'B', 'R'];
if (mappingConsoles[currentConsole] == "Game Boy Advance" && ["05.15", "05.22", "08.03", "10.07", "10.13"].includes(firmwareVersion)) { if (mappingConsoles[currentConsole] == "Game Boy Advance" && ["05.15", "05.22", "08.03", "10.07", "10.13"].includes(firmwareVersion)) {
buttonByteOrder = ['L', 'R', 'X', 'A', 'B', 'Y']; buttonByteOrder = ['L', 'R', 'X', 'A', 'B', 'Y'];
} }
@ -516,7 +529,7 @@
// Calculate the offset in our mapping data for the current // Calculate the offset in our mapping data for the current
// button, read the button settings from the HTML controls, and // button, read the button settings from the HTML controls, and
// assign the appropriate values to our binary mappingData... // assign the appropriate values to our binary mappingData...
var offset = mappingTableOffset + (currentConsole * 48) + (player * 24) + (buttonByteOrder.indexOf(presentationButtonOrder[button]) * 4); const offset = mappingTableOffset + (currentConsole * 48) + (player * 24) + (buttonByteOrder.indexOf(presentationButtonOrder[button]) * 4);
mappingData[offset] = buttonMap[document.getElementById("sel" + offset.toString(16)).value]; mappingData[offset] = buttonMap[document.getElementById("sel" + offset.toString(16)).value];
mappingData[offset + 2] = document.getElementById("cb" + offset.toString(16)).checked ? 1 : 0; mappingData[offset + 2] = document.getElementById("cb" + offset.toString(16)).checked ? 1 : 0;
} }
@ -534,7 +547,7 @@
// Next, let's determine the name of the file we're going to send to // Next, let's determine the name of the file we're going to send to
// the user's browser... // the user's browser...
var downloadFileName = fileName; let downloadFileName = fileName;
if (mappingConsoles.length == 1) { if (mappingConsoles.length == 1) {
downloadFileName = downloadFileName.replace(/($|\.[^.]*$)/, function(m, p1) {return p1.toUpperCase() + '.kmp';}); downloadFileName = downloadFileName.replace(/($|\.[^.]*$)/, function(m, p1) {return p1.toUpperCase() + '.kmp';});
} }
@ -546,6 +559,6 @@
} }
</script> </script>
<hr> <hr>
<p><a rel="license" href="https://creativecommons.org/publicdomain/zero/1.0/">CC0</a>: public domain. Version 1.5, 20231019.1</p> <p><a rel="license" href="https://creativecommons.org/publicdomain/zero/1.0/">CC0</a>: public domain. Version 1.6, 20240514.1</p>
</body> </body>
</html> </html>

View File

@ -24,13 +24,13 @@
<script type="text/javascript"> <script type="text/javascript">
// Global variables... // Global variables...
var bisrvData; // Used to store the binary data from the bisrv.asd file let bisrvData; // Used to store the binary data from the bisrv.asd file
// This function is triggered when the person selects a file... // This function is triggered when the person selects a file...
function bisrvLoad(file) { function bisrvLoad(file) {
// Read in the contents of the selected file as array buffer... // Read in the contents of the selected file as array buffer...
var frBisrv = new FileReader(); const frBisrv = new FileReader();
frBisrv.readAsArrayBuffer(file); frBisrv.readAsArrayBuffer(file);
frBisrv.onload = function(event) { frBisrv.onload = function(event) {
@ -41,7 +41,7 @@
bisrvData = new Uint8Array(event.target.result); bisrvData = new Uint8Array(event.target.result);
// We'll do a hash-check against it... // We'll do a hash-check against it...
hashResult = getFirmwareHash(bisrvData); const hashResult = getFirmwareHash(bisrvData);
// The result could be either a Promise if it had a bisrv.asd-like structure and we got // The result could be either a Promise if it had a bisrv.asd-like structure and we got
// a hash, or false otherwise... let's check! // a hash, or false otherwise... let's check!
@ -107,6 +107,6 @@
} }
</script> </script>
<hr> <hr>
<p><a rel="license" href="https://creativecommons.org/publicdomain/zero/1.0/">CC0</a>: public domain. Version 1.2, 20231019.1</p> <p><a rel="license" href="https://creativecommons.org/publicdomain/zero/1.0/">CC0</a>: public domain. Version 1.3, 20240514.1</p>
</body> </body>
</html> </html>

View File

@ -15,8 +15,8 @@
<h2>Step 1: Select Operating Mode</h2> <h2>Step 1: Select Operating Mode</h2>
<p>This tool can operate in two main modes - you can use it to convert <em>from</em> an SF2000 format <em>to</em> RGB888/RGBA, or you can use it to convert <em>to</em> an SF2000 format <em>from</em> RGB888/RGBA. Choose the mode you want, and follow the next instruction that appears.</p> <p>This tool can operate in two main modes - you can use it to convert <em>from</em> an SF2000 format <em>to</em> RGB888/RGBA, or you can use it to convert <em>to</em> an SF2000 format <em>from</em> RGB888/RGBA. Choose the mode you want, and follow the next instruction that appears.</p>
<div class="controlContainer"> <div class="controlContainer">
<label class="control"><input type="radio" id="radioFrom" name="toolMode" value="fromSF2000" autocomplete="off"> Convert <em>from</em> SF2000</label><br> <label for="radioFrom" class="control"><input type="radio" id="radioFrom" name="toolMode" value="fromSF2000" autocomplete="off"> Convert <em>from</em> SF2000</label><br>
<label class="control"><input type="radio" id="radioTo" name="toolMode" value="toSF2000" autocomplete="off"> Convert <em>to</em> SF2000</label> <label for="radioTo" class="control"><input type="radio" id="radioTo" name="toolMode" value="toSF2000" autocomplete="off"> Convert <em>to</em> SF2000</label>
</div> </div>
</section> </section>
</div> </div>
@ -25,24 +25,28 @@
// Global Variables // Global Variables
// ================ // ================
var sf2000ImageData; // Holds binary data for images coming from the SF2000 let sf2000ImageData; // Holds binary data for images coming from the SF2000
var userImageData; // Holds the original data of the PNG/JPEG coming from the user let userImageData; // Holds the original data of the PNG/JPEG coming from the user
var previewCanvasData; // Holds the binary data used for drawing to our preview canvases let previewCanvasData; // Holds the binary data used for drawing to our preview canvases
var outputImageWidth = 320; // The width the user wants to interpret the image from the SF2000 as, default to 320px let outputImageWidth = 320; // The width the user wants to interpret the image from the SF2000 as, default to 320px
var outputImageHeight = 240; // The height the user wants to interpret the image from the SF2000 as, default to 240px let outputImageHeight = 240; // The height the user wants to interpret the image from the SF2000 as, default to 240px
var imageFormat = "RGB565"; // The format the user wants to interpret the image coming from the SF2000 as, default to RGB565 let fileName; // Holds the name of the user-selected file
var fileName; // Holds the name of the user-selected file let imageFormat = "RGB565"; // The format the user wants to interpret the image coming from the SF2000 as, default to RGB565
var userScaleMode = "scale"; // The type of scaling the user wants for their image, default to preset scales let ditherEnabled = false; // Holds whether the user wants to enable dithering for RGB565 mode or not
var userScaleFactor = "1x"; // Holds the currently selected scaling factor, defaults to 1x let ditherStrength = 0.2; // Holds the selected dither strength value - 0.2 seems to be a good general balance
var userFitWidth = 320; // Holds the current fit-to width, default to 320px let userScaleMode = "scale"; // The type of scaling the user wants for their image, default to preset scales
var userFitHeight = 240; // Holds the current fit-to height, default to 240px let userScaleFactor = "1x"; // Holds the currently selected scaling factor, defaults to 1x
var userFilterType = "Nearest Neighbour"; // Holds the currently selected filter type, defaulting to Nearest Neighbour let fitToWidthEnabled = true; // Holds whether the "fit to width" checkbox is ticked or not
let userFitWidth = 320; // Holds the current fit-to width, default to 320px
let fitToHeightEnabled = true; // Holds whether the "fit to width" checkbox is ticked or not
let userFitHeight = 240; // Holds the current fit-to height, default to 240px
let userFilterType = "Nearest Neighbour"; // Holds the currently selected filter type, defaulting to Nearest Neighbour
// When the tool loads, add some event watchers for when the Step 1 radio // When the tool loads, add some event watchers for when the Step 1 radio
// buttons change; and depending on which mode the user selects, begin // buttons change; and depending on which mode the user selects, begin
// rewriting the rest of the page... // rewriting the rest of the page...
var modes = document.getElementsByName("toolMode"); const modes = document.getElementsByName("toolMode");
for (var i = 0; i < modes.length; i++) { for (let i = 0; i < modes.length; i++) {
modes[i].addEventListener("change", function() { modes[i].addEventListener("change", function() {
// Clear out any HTML that might already exist after Step 1, just so // Clear out any HTML that might already exist after Step 1, just so
@ -69,10 +73,10 @@
// Create the new section, add our heading and our instruction // Create the new section, add our heading and our instruction
// paragraph... // paragraph...
var html = "<section id=\"selectSF2000File\"><h2>Step 2: Select SF2000 Image File</h2><p>Select the SF2000 image file you want to convert from. If you need help choosing the image you want to convert, you can <a href=\"https://vonmillhausen.github.io/sf2000/#images-used\" target=\"_blank\" rel=\"noreferrer noopener\">find a reference to all of the used UI images in my overview of the SF2000 here</a>.</p><div id=\"step2Messages\"></div>"; let html = "<section id=\"selectSF2000File\"><h2>Step 2: Select SF2000 Image File</h2><p>Select the SF2000 image file you want to convert from. If you need help choosing the image you want to convert, you can <a href=\"https://vonmillhausen.github.io/sf2000/#images-used\" target=\"_blank\" rel=\"noreferrer noopener\">find a reference to all of the used UI images in my overview of the SF2000 here</a>.</p><div id=\"step2Messages\"></div>";
// Add our file chooser... // Add our file chooser...
html += "<div class=\"controlContainer\"><label class=\"control\"><input id=\"inputImage\" type=\"file\"></label></div>"; html += "<form class=\"controlContainer\"><label for=\"inputImage\" class=\"control\"><input id=\"inputImage\" name=\"inputImage\" type=\"file\"></label></form>";
// Close off our section... // Close off our section...
html += "</section>"; html += "</section>";
@ -83,7 +87,7 @@
document.getElementById("steps").insertAdjacentHTML("beforeend", html); document.getElementById("steps").insertAdjacentHTML("beforeend", html);
// Attach our event handler to our new file input control... // Attach our event handler to our new file input control...
var sf2000ImageInput = document.getElementById("inputImage"); const sf2000ImageInput = document.getElementById("inputImage");
sf2000ImageInput.addEventListener("change", function() { sf2000ImageInput.addEventListener("change", function() {
// The user has chosen a new file; it should be a binary blob we can // The user has chosen a new file; it should be a binary blob we can
@ -92,22 +96,22 @@
// Now let's check to make sure the data URI indicates // Now let's check to make sure the data URI indicates
// "application/octet-stream"... // "application/octet-stream"...
var frImage = new FileReader(); const frImage = new FileReader();
fileName = event.target.files[0].name; fileName = event.target.files[0].name;
frImage.readAsDataURL(event.target.files[0]); frImage.readAsDataURL(event.target.files[0]);
frImage.onload = function(event) { frImage.onload = function(event) {
var fileData = event.target.result; const fileData = event.target.result;
var dataType = fileData.substring(5, fileData.indexOf(";")); const dataType = fileData.substring(5, fileData.indexOf(";"));
if (dataType === "application/octet-stream") { if (dataType === "application/octet-stream") {
// The user selected a file that appears to contain binary data; // The user selected a file that appears to contain binary data;
// it's a good candidate for image interpretation so! Extract the // it's a good candidate for image interpretation so! Extract the
// binary data, store it in our sf2000ImageData global, and start // binary data, store it in our sf2000ImageData global, and start
// setting up Step 3... // setting up Step 3...
var base64Data = fileData.substring(fileData.indexOf(",") + 1); const base64Data = fileData.substring(fileData.indexOf(",") + 1);
var binaryData = atob(base64Data); const binaryData = atob(base64Data);
sf2000ImageData = new Uint8Array(binaryData.length); sf2000ImageData = new Uint8Array(binaryData.length);
for (var i = 0; i < binaryData.length; i++) { for (let i = 0; i < binaryData.length; i++) {
sf2000ImageData[i] = binaryData.charCodeAt(i); sf2000ImageData[i] = binaryData.charCodeAt(i);
} }
@ -137,10 +141,10 @@
function setupStepTwo_To() { function setupStepTwo_To() {
// Start our new section, add our header and our instructions... // Start our new section, add our header and our instructions...
var html = "<section id=\"selectImageFile\"><h2>Step 2: Select RGB888/RGBA Image File</h2><p>Select the image file you want to convert to an SF2000 image format. You can select either a PNG or JPEG format image. Transparency is respected for PNGs, but only if outputting to the BGRA output format.</p><div id=\"step2Messages\"></div>"; let html = "<section id=\"selectImageFile\"><h2>Step 2: Select RGB888/RGBA Image File</h2><p>Select the image file you want to convert to an SF2000 image format. You can select either a PNG or JPEG format image. Transparency is respected for PNGs, but only if outputting to the BGRA output format.</p><div id=\"step2Messages\"></div>";
// Add our file chooser... // Add our file chooser...
html += "<div class=\"controlContainer\"><label class=\"control\"><input id=\"inputImage\" type=\"file\"></label></div>"; html += "<div class=\"controlContainer\"><label for=\"inputImage\" class=\"control\"><input id=\"inputImage\" type=\"file\" accept=\".jpg,.jpeg,.png\"></label></div>";
// Close our section... // Close our section...
html += "</section>"; html += "</section>";
@ -151,17 +155,16 @@
document.getElementById("steps").insertAdjacentHTML("beforeend", html); document.getElementById("steps").insertAdjacentHTML("beforeend", html);
// Attach our event handler to our new file input control... // Attach our event handler to our new file input control...
var userImageInput = document.getElementById("inputImage"); const userImageInput = document.getElementById("inputImage");
userImageInput.addEventListener("change", function() { userImageInput.addEventListener("change", function() {
// The user has chosen a new file; it should be either a PNG or JPEG // The user has chosen a new file; it should be either a PNG or JPEG
// image for converting to an SF2000 binary image format... // image for converting to an SF2000 binary image format. First let's
// clear any old messages...
// First let's clear any old messages...
document.getElementById("step2Messages").innerHTML = ""; document.getElementById("step2Messages").innerHTML = "";
// Now let's read in the file... // Now let's read in the file...
var frImage = new FileReader(); const frImage = new FileReader();
fileName = event.target.files[0].name; fileName = event.target.files[0].name;
frImage.readAsDataURL(event.target.files[0]); frImage.readAsDataURL(event.target.files[0]);
frImage.onload = function(event) { frImage.onload = function(event) {
@ -173,7 +176,6 @@
// Whoops! Doesn't look like the user provided a PNG or a JPEG! // Whoops! Doesn't look like the user provided a PNG or a JPEG!
setMessage("error", "step2Messages", "ERROR: The selected file does not appear to be either a PNG or JPEG image; please make sure you're selecting an appropriate image file."); setMessage("error", "step2Messages", "ERROR: The selected file does not appear to be either a PNG or JPEG image; please make sure you're selecting an appropriate image file.");
return; return;
} }
// Now we're going to load the file's data into an Image object, so // Now we're going to load the file's data into an Image object, so
@ -335,15 +337,15 @@
// Create the new section, add our heading and our instruction // Create the new section, add our heading and our instruction
// paragraph... // paragraph...
var html = "<section id=\"processSF2000File\"><h2>Step 3: Set Data Interpretation Options</h2><p>As the image files on the SF2000 are raw binary blobs, there's no information stored in them to say what their width or height is, what format the pixel information is stored in, etc.. Therefore, it is up to <em>you</em> to specify those details yourself, which you can do using the options below.</p><p>A preview of the image will appear below the options, reflecting the current options; the preview has a white dash outline so you can better judge where the bounds of transparent images are. Below the preview image is a \"Download\" button you can use to download the image as a PNG.</p><p>Depending on the size of the file you selected, this tool may recognise it as a \"known\" file from the SF2000, and if so the options below will have been set automatically for you. If not, or if the options are automatically set incorrectly, here's a brief description of what each option does:</p><dl><dt>Image Format:</dt><dd>Specifies the data format for each pixel in the image. There are two image formats currently used by the SF2000 - RGB565 (used for most of the \"basic\" UI elements, including main backgrounds, etc.), and BGRA (used for anything that requires transparency, such as the system logos on the main menu, etc.).</dd><dt>Width:</dt><dd>Specifies the width of the image in pixels.</dd><dt>Height:</dt><dd>Specifies the height of the image in pixels.</dd></dl>"; let html = "<section id=\"processSF2000File\"><h2>Step 3: Set Data Interpretation Options</h2><p>As the image files on the SF2000 are raw binary blobs, there's no information stored in them to say what their width or height is, what format the pixel information is stored in, etc.. Therefore, it is up to <em>you</em> to specify those details yourself, which you can do using the options below.</p><p>A preview of the image will appear below the options, reflecting the current options; the preview has a white dash outline so you can better judge where the bounds of transparent images are. Below the preview image is a \"Download\" button you can use to download the image as a PNG.</p><p>Depending on the size of the file you selected, this tool may recognise it as a \"known\" file from the SF2000, and if so the options below will have been set automatically for you. If not, or if the options are automatically set incorrectly, here's a brief description of what each option does:</p><dl><dt>Image Format:</dt><dd>Specifies the data format for each pixel in the image. There are two image formats currently used by the SF2000 - RGB565 (used for most of the \"basic\" UI elements, including main backgrounds, etc.), and BGRA (used for anything that requires transparency, such as the system logos on the main menu, etc.).</dd><dt>Width:</dt><dd>Specifies the width of the image in pixels.</dd><dt>Height:</dt><dd>Specifies the height of the image in pixels.</dd></dl>";
// Next, let's add our image controls; a select list for the image // Next, let's add our image controls; a select list for the image
// format, and number inputs for the interpreted width and height... // format, and number inputs for the interpreted width and height...
html += "<div class=\"controlContainer\">"; html += "<form id=\"fromSF2000ImageSettings\"class=\"controlContainer\">";
html += "<div class=\"control\"><label>Image Format: <select id=\"imageFormat\"><option" + (imageFormat == "RGB565" ? " selected" : "") + ">RGB565</option><option" + (imageFormat == "BGRA" ? " selected" : "") + ">BGRA</option></select></label></div>"; html += "<div class=\"control\"><label for=\"imageFormat\">Image Format: <select id=\"imageFormat\" name=\"imageFormat\"><option" + (imageFormat == "RGB565" ? " selected" : "") + ">RGB565</option><option" + (imageFormat == "BGRA" ? " selected" : "") + ">BGRA</option></select></label></div>";
html += "<div class=\"control\"><label>Width: <input id=\"outputImageWidth\" type=\"number\" min=\"1\" step=\"1\" value=\"" + outputImageWidth + "\"></label></div>"; html += "<div class=\"control\"><label for=\"outputImageWidth\">Width: <input id=\"outputImageWidth\" name=\"outputImageWidth\" type=\"number\" min=\"1\" step=\"1\" value=\"" + outputImageWidth + "\"></label></div>";
html += "<div class=\"control\"><label>Height: <input id=\"outputImageHeight\" type=\"number\" min=\"1\" step=\"1\" value=\"" + outputImageHeight + "\"></label></div>"; html += "<div class=\"control\"><label for=\"outputImageHeight\">Height: <input id=\"outputImageHeight\" name=\"outputImageHeight\" type=\"number\" min=\"1\" step=\"1\" value=\"" + outputImageHeight + "\"></label></div>";
html += "</div>"; html += "</form>";
// Next, we'll add the image preview... // Next, we'll add the image preview...
html += "<div class=\"controlContainer\"><div class=\"control\"><canvas id=\"processFilePreview\"></canvas></div></div>"; html += "<div class=\"controlContainer\"><div class=\"control\"><canvas id=\"processFilePreview\"></canvas></div></div>";
@ -359,18 +361,12 @@
document.getElementById("steps").insertAdjacentHTML("beforeend", "<hr>"); document.getElementById("steps").insertAdjacentHTML("beforeend", "<hr>");
document.getElementById("steps").insertAdjacentHTML("beforeend", html); document.getElementById("steps").insertAdjacentHTML("beforeend", html);
// Now, add our interactivity by attaching our event handlers; first up, // Now, attach some event handlers to our form controls; these will
// our format selector... // handle some of the logic for how I want the controls to behave. First
var ifField = document.getElementById("imageFormat"); // up, our width and height fields - I have some limitations on the
ifField.addEventListener("change", function() { // types of things I want typed in them, and how I want them to be
// Set our global imageFormat to the new value, and update our // changed...
// preview... const whFields = [document.getElementById("outputImageWidth"), document.getElementById("outputImageHeight")];
imageFormat = this.value;
convertFromSF2000AndRender();
});
// Next our width and height fields...
var whFields = [document.getElementById("outputImageWidth"), document.getElementById("outputImageHeight")];
whFields.forEach(field => { whFields.forEach(field => {
// When a key is pressed, only allow the events through under certain // When a key is pressed, only allow the events through under certain
@ -390,25 +386,39 @@
if (this.value === "" || this.value === "0") { if (this.value === "" || this.value === "0") {
this.value = "1"; this.value = "1";
} }
if (this.id == "outputImageWidth") {
outputImageWidth = this.value;
}
else {
outputImageHeight = this.value;
}
convertFromSF2000AndRender();
}); });
}); });
// And attach the event handler for the download button... // Next, attach the event handler for the download button...
var downloadButton = document.getElementById("sf2000Download"); const downloadButton = document.getElementById("sf2000Download");
downloadButton.addEventListener("click", function() { downloadButton.addEventListener("click", function() {
var canvas = document.getElementById("processFilePreview"); const canvas = document.getElementById("processFilePreview");
canvas.toBlob(function(blob) { canvas.toBlob(function(blob) {
downloadToBrowser(blob, "image/png", fileName + ".png"); downloadToBrowser(blob, "image/png", fileName + ".png");
}); });
}); });
// That's the end of the specific UI event handlers; the final handler
// we'll set up is a general event handler for the entire form itself.
// Any time *any* of the controls in the form change, that event will
// bubble-up and trigger this handler, where we'll get the current state
// of the form's controls, and use it to re-convert and re-render the
// image using the current settings...
const fromSF2000Form = document.getElementById("fromSF2000ImageSettings");
fromSF2000Form.addEventListener("change", function() {
// Get the state of all of the form's controls...
const formData = Object.fromEntries(new FormData(fromSF2000Form));
// Set our general globals from the current form state...
imageFormat = formData.imageFormat;
outputImageWidth = formData.outputImageWidth;
outputImageHeight = formData.outputImageHeight;
// And re-convert and re-render based on those globals...
convertFromSF2000AndRender();
});
// We're nearly ready to wrap up Step 3; all that's left is to perform // We're nearly ready to wrap up Step 3; all that's left is to perform
// our initial conversion and rendering... // our initial conversion and rendering...
convertFromSF2000AndRender(); convertFromSF2000AndRender();
@ -423,7 +433,7 @@
// from a known image, we already have our width and height this time, // from a known image, we already have our width and height this time,
// and so we can just get on to rendering the HTML. Create our new // and so we can just get on to rendering the HTML. Create our new
// section, add its heading and instruction paragraphs... // section, add its heading and instruction paragraphs...
html = "<section id=\"processUserFile\"><h2>Step 3: Set Conversion Options</h2><p>The options below control how your image is converted for use on the SF2000. A preview of the image will appear below the options, reflecting their current settings; the preview has a white dash outline so you can better judge where the bounds of transparent images are. Below the preview image is a \"Download\" button you can use to download the image as binary blob for the SF2000. If you name your input image like <code>name.extension.extension</code> (e.g., <code>c1eac.pal.png</code>), then the download will automatically be named like <code>name.extension</code> (e.g., <code>c1eac.pal</code>); this may help to speed up your workflow.</p><p>Here's a brief description of what each option does:</p><dl><dt>Image Format:</dt><dd>Specifies the data format for the output image. There are two image formats currently used by the SF2000 - RGB565 (used for most of the \"basic\" UI elements, including main backgrounds, etc.), and BGRA (used for anything that requires transparency, such as the system logos on the main menu, etc.). If you're not sure which format to choose, <a href=\"https://vonmillhausen.github.io/sf2000/#images-used\" target=\"_blank\" rel=\"noreferrer noopener\">refer to my list of images used by the SF2000</a>.</dd><dt>Scaling:</dt><dd>One of two available scaling modes for your input image; the \"Scaling\" option lets you scale your image maintaining aspect ratio amongst several common sizes (e.g., 1x scaling to maintain the input image size, 2x scaling to double it, etc.)</dd><dt>Fit to:</dt><dd>The other available scaling mode; this will scale your image to the specified width and height, <em>without</em> maintaining aspect ratio. This will allow you to scale an image to any dimension you want, but it'll be up to you to do your own aspect ratio calculations.</dd><dt>Filter Type:</dt><dd>When scaling an image by anything other than 1x, specifies the type of image filtering to use. \"Nearest Neighbour\" will give sharp pixel scaling but only at integer upscales; any other scale will appear aliased. \"Bilinear\" will give a fuzzier scale, but it works better for non-integer scale factors and for downscaling.</dd></dl>"; let html = "<section id=\"processUserFile\"><h2>Step 3: Set Conversion Options</h2><p>The options below control how your image is converted for use on the SF2000. A preview of the image will appear below the options, reflecting their current settings; the preview has a white dash outline so you can better judge where the bounds of transparent images are. Below the preview image is a \"Download\" button you can use to download the image as binary blob for the SF2000. If you name your input image like <code>name.extension.extension</code> (e.g., <code>c1eac.pal.png</code>), then the download will automatically be named like <code>name.extension</code> (e.g., <code>c1eac.pal</code>); this may help to speed up your workflow.</p><p>Here's a brief description of what each option does:</p><dl><dt>Image Format:</dt><dd>Specifies the data format for the output image. There are two image formats currently used by the SF2000 - RGB565 (used for most of the \"basic\" UI elements, including main backgrounds, etc.), and BGRA (used for anything that requires transparency, such as the system logos on the main menu, etc.). If you're not sure which format to choose, <a href=\"https://vonmillhausen.github.io/sf2000/#images-used\" target=\"_blank\" rel=\"noreferrer noopener\">refer to my list of images used by the SF2000</a>.</dd><dt>Scaling:</dt><dd>One of two available scaling modes for your input image; the \"Scaling\" option lets you scale your image maintaining aspect ratio amongst several common sizes (e.g., 1x scaling to maintain the input image size, 2x scaling to double it, etc.)</dd><dt>Fit to:</dt><dd>The other available scaling mode; this will scale your image to the specified width and height, <em>without</em> maintaining aspect ratio. This will allow you to scale an image to any dimension you want, but it'll be up to you to do your own aspect ratio calculations.</dd><dt>Filter Type:</dt><dd>When scaling an image by anything other than 1x, specifies the type of image filtering to use. \"Nearest Neighbour\" will give sharp pixel scaling but only at integer upscales; any other scale will appear aliased. \"Bilinear\" will give a fuzzier scale, but it works better for non-integer scale factors and for downscaling.</dd></dl>";
// Now let's add our image controls; there's a few of them! We have a // Now let's add our image controls; there's a few of them! We have a
// select box for choosing the image format, we have two different // select box for choosing the image format, we have two different
@ -431,21 +441,24 @@
// scales, and one for user-defined output size), and we have a select // scales, and one for user-defined output size), and we have a select
// box for choosing nearest-neighbour or hybrid gaussian/bilinear // box for choosing nearest-neighbour or hybrid gaussian/bilinear
// image filtering... // image filtering...
html += "<div class=\"controlContainer\">"; html += "<form id=\"toSF2000ImageSettings\" class=\"controlContainer\">";
// Image format... // Image format and dithering options...
html += "<div class=\"control\"><label>Image Format: <select id=\"imageFormat\"><option" + (imageFormat == "RGB565" ? " selected" : "") + ">RGB565</option><option" + (imageFormat == "BGRA" ? " selected" : "") + ">BGRA</option></select></label></div>"; html += "<div class=\"control\">";
html += "<div class=\"control\"><label for=\"imageFormat\">Image Format: <select id=\"imageFormat\" name=\"imageFormat\"><option" + (imageFormat == "RGB565" ? " selected" : "") + ">RGB565</option><option" + (imageFormat == "BGRA" ? " selected" : "") + ">BGRA</option></select></label></div>";
html += "<div class=\"control\"><label for=\"ditherEnabled\">Dithering: <input type=\"checkbox\" id=\"ditherEnabled\" name=\"ditherEnabled\"" + (ditherEnabled == true ? " checked" : "") + (imageFormat == "BGRA" ? " disabled" : "") + "></label> <label for=\"ditherStrength\">Strength: <select id=\"ditherStrength\" name=\"ditherStrength\"" + (imageFormat == "BGRA" ? " disabled" : "") + "><option" + (ditherStrength == 0.1 ? " selected" : "") + ">0.1</option><option" + (ditherStrength == 0.2 ? " selected" : "") + ">0.2</option><option" + (ditherStrength == 0.3 ? " selected" : "") + ">0.3</option><option" + (ditherStrength == 0.5 ? " selected" : "") + ">0.5</option><option" + (ditherStrength == 0.75 ? " selected" : "") + ">0.75</option><option" + (ditherStrength == 1.0 ? " selected" : "") + ">1</option><option" + (ditherStrength == 2.0 ? " selected" : "") + ">2</option></select></label></div>";
html += "</div>";
// Scaling options... // Scaling options...
html += "<div class=\"control\">"; html += "<div class=\"control\">";
html += "<div class=\"control\"><label><input type=\"radio\" name=\"scaleMode\" value=\"scale\"" + (userScaleMode == "scale" ? " checked" : "") + "> Scaling: </label><select id=\"scaleFactor\"" + (userScaleMode == "fit" ? " disabled" : "") + "><option" + (userScaleFactor == "0.5x" ? " selected" : "") + ">0.5x</option><option" + (userScaleFactor == "1x" ? " selected" : "") + ">1x</option><option" + (userScaleFactor == "2x" ? " selected" : "") + ">2x</option><option" + (userScaleFactor == "3x" ? " selected" : "") + ">3x</option><option" + (userScaleFactor == "4x" ? " selected" : "") + ">4x</option></select></div>"; html += "<div class=\"control\"><label for=\"scaleModeScale\"><input type=\"radio\" id=\"scaleModeScale\" name=\"scaleMode\" value=\"scale\"" + (userScaleMode == "scale" ? " checked" : "") + "> Scaling: </label><select id=\"scaleFactor\" name=\"scaleFactor\"" + (userScaleMode == "fit" ? " disabled" : "") + "><option" + (userScaleFactor == "0.5x" ? " selected" : "") + ">0.5x</option><option" + (userScaleFactor == "1x" ? " selected" : "") + ">1x</option><option" + (userScaleFactor == "2x" ? " selected" : "") + ">2x</option><option" + (userScaleFactor == "3x" ? " selected" : "") + ">3x</option><option" + (userScaleFactor == "4x" ? " selected" : "") + ">4x</option></select></div>";
html += " OR "; html += " OR ";
html += "<div class=\"control\"><label><input type=\"radio\" name=\"scaleMode\" value=\"fit\"" + (userScaleMode == "fit" ? " checked" : "") + "> Fit to: </label><label>width <input id=\"userFitWidth\" type=\"number\" min=\"1\" step=\"1\" value=\""+ userFitWidth + "\"" + (userScaleMode == "scale" ? " disabled" : "") + "></label> and <label>height <input id=\"userFitHeight\" type=\"number\" min=\"1\" step=\"1\" value=\""+ userFitHeight + "\"" + (userScaleMode == "scale" ? " disabled" : "") + "></label></div>"; html += "<div class=\"control\"><label for=\"scaleModeFit\"><input type=\"radio\" id=\"scaleModeFit\" name=\"scaleMode\" value=\"fit\"" + (userScaleMode == "fit" ? " checked" : "") + "> Fit to: </label><label for=\"fitToWidthEnabled\"><input type=\"checkbox\" id=\"fitToWidthEnabled\" name=\"fitToWidthEnabled\"" + (fitToWidthEnabled == true ? " checked" : "") + (userScaleMode == "scale" ? " disabled" : "") + "> width </label><input id=\"userFitWidth\" name=\"userFitWidth\" type=\"number\" min=\"1\" step=\"1\" value=\""+ userFitWidth + "\"" + (userScaleMode == "scale" ? " disabled" : "") + "> and <label for=\"fitToHeightEnabled\"><input type=\"checkbox\" id=\"fitToHeightEnabled\" name=\"fitToHeightEnabled\"" + (fitToHeightEnabled == true ? " checked" : "") + (userScaleMode == "scale" ? " disabled" : "") + "> height </label><input id=\"userFitHeight\" name=\"userFitHeight\" type=\"number\" min=\"1\" step=\"1\" value=\""+ userFitHeight + "\"" + (userScaleMode == "scale" ? " disabled" : "") + "></div>";
html += "</div>"; html += "</div>";
// Filter type... // Filter type...
html += "<div class=\"control\"><label>Filter Type: <select id=\"userFilterType\"><option" + (userFilterType == "Nearest Neighbour" ? " selected" : "") + ">Nearest Neighbour</option><option" + (userFilterType == "Bilinear" ? " selected" : "") + ">Bilinear</option></select></label></div>"; html += "<div class=\"control\"><label for=\"userFilterType\">Filter Type: <select id=\"userFilterType\" name=\"userFilterType\"><option" + (userFilterType == "Nearest Neighbour" ? " selected" : "") + ">Nearest Neighbour</option><option" + (userFilterType == "Bilinear" ? " selected" : "") + ">Bilinear</option></select></label></div>";
html += "</div>"; html += "</form>";
// Next we'll add our image preview... // Next we'll add our image preview...
html += "<div class=\"controlContainer\"><div class=\"control\"><canvas id=\"processFilePreview\"></canvas></div></div>"; html += "<div class=\"controlContainer\"><div class=\"control alignC\"><span id=\"previewDimensions\"></span><br><canvas id=\"processFilePreview\"></canvas></div></div>";
// ... and our Download button... // ... and our Download button...
html += "<div class=\"controlContainer\"><div class=\"control\"><input id=\"userDownload\" type=\"button\" value=\"Download\"></div></div>"; html += "<div class=\"controlContainer\"><div class=\"control\"><input id=\"userDownload\" type=\"button\" value=\"Download\"></div></div>";
@ -458,76 +471,80 @@
document.getElementById("steps").insertAdjacentHTML("beforeend", "<hr>"); document.getElementById("steps").insertAdjacentHTML("beforeend", "<hr>");
document.getElementById("steps").insertAdjacentHTML("beforeend", html); document.getElementById("steps").insertAdjacentHTML("beforeend", html);
// Now we'll attach event listeners for all of the interactive elements // Now, attach some event handlers to our form controls; these will
// in this step. We'll start with our image format select box... // handle some of the logic for how I want the controls to behave.
var ifField = document.getElementById("imageFormat"); // First, let's enable/disable dithering options depending on whether or
ifField.addEventListener("change", function() { // not image format is set to RGB565...
const formatSelect = document.getElementById("imageFormat");
formatSelect.addEventListener("change", function() {
switch (this.value){
case "RGB565":
document.getElementById("ditherEnabled").removeAttribute("disabled");
document.getElementById("ditherStrength").removeAttribute("disabled");
break;
// Update our global variable with the new value, convert the user's case "BGRA":
// image using the new format and render a preview... document.getElementById("ditherEnabled").setAttribute("disabled", "");
imageFormat = this.value; document.getElementById("ditherStrength").setAttribute("disabled", "");
let newWH = calculateNewSize(userImageData); break;
convertImageToSF2000(scaleImage(userImageData, newWH.width, newWH.height, userFilterType)); }
convertFromSF2000AndRender();
}); });
// The scaling mode radio buttons; these let the user choose between // Now let's do the scaling mode radio buttons; these let the user
// fixed-ratio scaling, or fixed width/height scaling... // choose between fixed-ratio scaling, or fixed width/height scaling...
var modes = document.getElementsByName("scaleMode"); const modes = document.getElementsByName("scaleMode");
for (var i = 0; i < modes.length; i++) { for (let i = 0; i < modes.length; i++) {
modes[i].addEventListener("change", function() { modes[i].addEventListener("change", function() {
// Update our global variable with the new selection...
userScaleMode = this.value;
// Check what they chose... // Check what they chose...
if (userScaleMode == "scale") { if (this.value == "scale") {
// They chose fixed-ratio scaling, so enable the ratio selector, // They chose fixed-ratio scaling, so enable the ratio selector,
// and disable the fixed-width/height number inputs... // and disable the fixed-width/height controls...
document.getElementById("scaleFactor").removeAttribute("disabled"); document.getElementById("scaleFactor").removeAttribute("disabled");
document.getElementById("fitToWidthEnabled").setAttribute("disabled", "");
document.getElementById("userFitWidth").setAttribute("disabled", ""); document.getElementById("userFitWidth").setAttribute("disabled", "");
document.getElementById("fitToHeightEnabled").setAttribute("disabled", "");
document.getElementById("userFitHeight").setAttribute("disabled", ""); document.getElementById("userFitHeight").setAttribute("disabled", "");
} }
else if (userScaleMode == "fit") { else if (this.value == "fit") {
// They chose fixed width/height scaling, so enable our // They chose fixed width/height scaling, so enable our
// width/height number inputs, and disable the ratio-scaling // width/height number inputs, and disable the ratio-scaling
// selection box... // selection box...
document.getElementById("fitToWidthEnabled").removeAttribute("disabled");
document.getElementById("userFitWidth").removeAttribute("disabled"); document.getElementById("userFitWidth").removeAttribute("disabled");
document.getElementById("fitToHeightEnabled").removeAttribute("disabled");
document.getElementById("userFitHeight").removeAttribute("disabled"); document.getElementById("userFitHeight").removeAttribute("disabled");
document.getElementById("scaleFactor").setAttribute("disabled", ""); document.getElementById("scaleFactor").setAttribute("disabled", "");
} }
// We'll also want to re-convert the user's image data and render
// a new preview...
let newWH = calculateNewSize(userImageData);
convertImageToSF2000(scaleImage(userImageData, newWH.width, newWH.height, userFilterType));
convertFromSF2000AndRender();
}); });
} }
// The fixed-ratio scaling selector... // Next, the fixed-width/height checkboxes; these have a little twist
var sField = document.getElementById("scaleFactor"); // whereby we don't want to allow them both to be unticked at the same
sField.addEventListener("change", function() { // time - so we catch click events, and ignore them if they would cause
// both checkboxes to be unticked...
// Update our global variable with the newly chosen scaling factor, const widthCheckbox = document.getElementById("fitToWidthEnabled");
// and then re-convert the user's image data and render a new widthCheckbox.addEventListener("click", function() {
// preview... const heightCheckbox = document.getElementById("fitToHeightEnabled");
userScaleFactor = this.value; if (heightCheckbox.checked == false) {
let newWH = calculateNewSize(userImageData); event.preventDefault();
convertImageToSF2000(scaleImage(userImageData, newWH.width, newWH.height, userFilterType)); }
convertFromSF2000AndRender(); });
const heightCheckbox = document.getElementById("fitToHeightEnabled");
heightCheckbox.addEventListener("click", function() {
const widthCheckbox = document.getElementById("fitToWidthEnabled");
if (widthCheckbox.checked == false) {
event.preventDefault();
}
}); });
// And the fixed-width/height number inputs... // And now the fixed-width/height number inputs...
var whFields = [document.getElementById("userFitWidth"), document.getElementById("userFitHeight")]; const whFields = [document.getElementById("userFitWidth"), document.getElementById("userFitHeight")];
whFields.forEach(field => { whFields.forEach(field => {
// These get two event listeners; the first is designed to prevent the // These get two event listeners; the first is designed to prevent the
// user from entering anything other than positive integers... // user from entering anything other than positive integers...
field.addEventListener("keydown", function(event) { field.addEventListener("keydown", function(event) {
// Only allow integer entry and things like arrow keys, etc! // Only allow integer entry and things like arrow keys, etc!
if (event.key.length > 1 || /\d/.test(event.key)) { if (event.key.length > 1 || /\d/.test(event.key)) {
return; return;
@ -535,95 +552,129 @@
event.preventDefault(); event.preventDefault();
}); });
// ... and this updates our global variables, re-converts the user's // ... and the second catches when if the value is blank or set to 0,
// image data and renders a new preview image when the numbers are // and if so sets it to 1 instead...
// changed...
field.addEventListener("change", function() { field.addEventListener("change", function() {
if (this.value === "" || this.value === "0") { if (this.value === "" || this.value === "0") {
this.value = "1"; this.value = "1";
} }
if (this.id == "userFitWidth") {
userFitWidth = this.value;
}
else {
userFitHeight = this.value;
}
let newWH = calculateNewSize(userImageData);
convertImageToSF2000(scaleImage(userImageData, newWH.width, newWH.height, userFilterType));
convertFromSF2000AndRender();
}); });
}); });
// Next, the event listener for our filter type selection...
var ftField = document.getElementById("userFilterType");
ftField.addEventListener("change", function() {
// Update our global variable with the new filter type, re-convert
// the user's image data, and update our preview...
userFilterType = this.value;
let newWH = calculateNewSize(userImageData);
convertImageToSF2000(scaleImage(userImageData, newWH.width, newWH.height, userFilterType));
convertFromSF2000AndRender();
});
// And last but not least, our Download button... // And last but not least, our Download button...
var dButton = document.getElementById("userDownload"); const dButton = document.getElementById("userDownload");
dButton.addEventListener("click", function() { dButton.addEventListener("click", function() {
// For the file-name we're going to do something semi-fancy; if the // For the file-name we're going to do something semi-fancy; if the
// user's original image file name had *two* extensions (e.g., // user's original image file name had *two* extensions (e.g.,
// c1eac.pal.png), then we'll strip off the second extension and // c1eac.pal.png), then we'll strip off the second extension and just
// just save the file with the remainder of the name (e.g., // save the file with the remainder of the name (e.g., c1eac.pal) -
// c1eac.pal) - this will speed up their workflow if their source // this will speed up their workflow if their source image came from a
// image came from a design tool that exports images named after // design tool that exports images named after layers/art-boards.
// layers/artboards. Otherwise, we'll just use the name of the // Otherwise, we'll just use the name of the file they provided with
// file they provided with ".bin" appended to the end... // ".bin" appended to the end...
var downloadName = fileName + ".bin"; let downloadName = fileName + ".bin";
var match = fileName.match(/(.+)\.([^.]+)\.([^.]+)/); const match = fileName.match(/(.+)\.([^.]+)\.([^.]+)/);
if (match) { if (match) {
// It's got a double extension - so let's just strip off the // It's got a double extension - so let's just strip off the second
// second one... // one...
downloadName = match[1] + "." + match[2]; downloadName = match[1] + "." + match[2];
} }
downloadToBrowser(sf2000ImageData, "application/octet-stream", downloadName); downloadToBrowser(sf2000ImageData, "application/octet-stream", downloadName);
}); });
// Great, that's all the UI handling done. The final event we'll set up
// is on the overall control strip itself, to watch for changes to any
// of the controls. When any of them change, this event will trigger,
// and we'll get the new control state and use their current values to
// render an updated image...
const toSF2000Form = document.getElementById("toSF2000ImageSettings");
toSF2000Form.addEventListener("change", function() {
// Get the state of all of our form controls...
const formData = Object.fromEntries(new FormData(toSF2000Form));
// Set our general globals from the current form state...
imageFormat = formData.imageFormat;
if (formData.ditherEnabled !== undefined) {ditherEnabled = true;}
else if (imageFormat == "RGB565") {ditherEnabled = false;}
ditherEnabled = formData.ditherEnabled ? true : ditherEnabled;
ditherStrength = formData.ditherStrength? parseFloat(formData.ditherStrength) : ditherStrength;
userScaleMode = formData.scaleMode;
userScaleFactor = formData.scaleFactor;
if (formData.fitToWidthEnabled !== undefined) {fitToWidthEnabled = true;}
else if (userScaleMode == "fit") {fitToWidthEnabled = false;}
if (formData.fitToHeightEnabled !== undefined) {fitToHeightEnabled = true;}
else if (userScaleMode == "fit") {fitToHeightEnabled = false;}
userFitWidth = formData.userFitWidth ? formData.userFitWidth : userFitWidth;
userFitHeight = formData.userFitHeight ? formData.userFitHeight : userFitHeight;
userFilterType = formData.userFilterType;
// Finally render an updated preview...
calculateOutputDimensions();
convertImageToSF2000(scaleImage(userImageData, outputImageWidth, outputImageHeight, userFilterType), ditherEnabled, ditherStrength);
convertFromSF2000AndRender();
});
// The last thing to do here in Step 3 is to perform our initial // The last thing to do here in Step 3 is to perform our initial
// image data conversion and preview rendering... so let's do it! // image data conversion and preview rendering... so let's do it!
let newWH = calculateNewSize(userImageData); calculateOutputDimensions();
convertImageToSF2000(scaleImage(userImageData, newWH.width, newWH.height, userFilterType)); convertImageToSF2000(scaleImage(userImageData, outputImageWidth, outputImageHeight, userFilterType), ditherEnabled, ditherStrength);
convertFromSF2000AndRender(); convertFromSF2000AndRender();
} }
// This function takes in an ImageData object, and given our global // This function takes our userImageData global, and given our other
// variables for the image conversion options, returns an object // global variables for the image conversion options, sets
// containing the new width and height of the image based on those // outputImageWidth and outputImageHeight based on calculated dimensions
// options... // from the options...
function calculateNewSize(imageData) { function calculateOutputDimensions() {
var newWidth = userFitWidth;
var newHeight = userFitHeight; // Default to just being the same size as the original image...
if (userScaleMode == "scale") { outputImageWidth = userImageData.width;
newWidth = Math.round(imageData.width * Number(userScaleFactor.slice(0, -1))); outputImageHeight = userImageData.height;
newHeight = Math.round(imageData.height * Number(userScaleFactor.slice(0, -1)));
// Check to see which scaling mode the user has selected...
switch (userScaleMode){
case "scale":
// They've selected "scale" mode! So take the value from the
// selection box, strip off the "x" character at the end, and use it
// as a multiplier for the original image dimensions, rounding to
// the nearest whole number...
outputImageWidth = Math.round(outputImageWidth * Number(userScaleFactor.slice(0, -1)));
outputImageHeight = Math.round(outputImageHeight * Number(userScaleFactor.slice(0, -1)));
break;
case "fit":
// They've selected "fit to width/height" mode! This is a little
// more involved than "scale", as the final width/height will depend
// on which combination of the width/height fitting checkboxes are
// currently checked...
if (fitToWidthEnabled){
outputImageWidth = Number(userFitWidth);
if (fitToHeightEnabled) {
outputImageHeight = Number(userFitHeight);
}
else {
outputImageHeight = Math.round(userImageData.height * (outputImageWidth / userImageData.width));
}
}
else if (fitToHeightEnabled){
outputImageHeight = Number(userFitHeight);
outputImageWidth = Math.round(userImageData.width * (outputImageHeight / userImageData.height));
}
break;
} }
// Update the global variables used for rendering SF2000 image data with
// our new output width and height...
outputImageWidth = newWidth;
outputImageHeight = newHeight;
return {width: newWidth, height: newHeight};
} }
// This function takes in an ImageData object, and uses our standard // This function takes in an ImageData object, and uses our standard
// library conversion functions to convert it to either RGB565 or BGRA // library conversion functions to convert it to either RGB565 or BGRA
// format, and sets the sf2000ImageData global to the resulting // format, and sets the sf2000ImageData global to the resulting
// Uint8Array object... // Uint8Array object...
function convertImageToSF2000(sourceData) { function convertImageToSF2000(sourceData, dither, ditherStrength) {
if (imageFormat == "RGB565") { if (imageFormat == "RGB565") {
sf2000ImageData = imageDataToRgb565(sourceData); sf2000ImageData = imageDataToRgb565(sourceData, dither, ditherStrength);
} }
else if (imageFormat == "BGRA") { else if (imageFormat == "BGRA") {
sf2000ImageData = imageDataToBgra(sourceData); sf2000ImageData = imageDataToBgra(sourceData);
@ -641,15 +692,20 @@
else if (imageFormat == "BGRA") { else if (imageFormat == "BGRA") {
previewCanvasData = bgraToImageData(sf2000ImageData, outputImageWidth, outputImageHeight); previewCanvasData = bgraToImageData(sf2000ImageData, outputImageWidth, outputImageHeight);
} }
var canvas = document.getElementById("processFilePreview"); const canvas = document.getElementById("processFilePreview");
var context = canvas.getContext("2d"); const context = canvas.getContext("2d");
canvas.width = previewCanvasData.width; canvas.width = previewCanvasData.width;
canvas.height = previewCanvasData.height; canvas.height = previewCanvasData.height;
context.clearRect(0, 0, canvas.width, canvas.height); context.clearRect(0, 0, canvas.width, canvas.height);
context.putImageData(previewCanvasData, 0, 0); context.putImageData(previewCanvasData, 0, 0);
// Just as a utility, let's also show the output image's width and
// height; we'll also set this as a title on the canvas...
document.getElementById("previewDimensions").innerText = previewCanvasData.width + " x " + previewCanvasData.height;
canvas.setAttribute("title", previewCanvasData.width + " x " + previewCanvasData.height);
} }
</script> </script>
<hr> <hr>
<p><a rel="license" href="https://creativecommons.org/publicdomain/zero/1.0/">CC0</a>: public domain. Version 1.1, 20230626.1</p> <p><a rel="license" href="https://creativecommons.org/publicdomain/zero/1.0/">CC0</a>: public domain. Version 1.2, 20240514.1</p>
</body> </body>
</html> </html>

File diff suppressed because one or more lines are too long

View File

@ -11,6 +11,10 @@
Just like the tools themselves, this file should be considered CC0 Public Just like the tools themselves, this file should be considered CC0 Public
Domain (https://creativecommons.org/publicdomain/zero/1.0/) Domain (https://creativecommons.org/publicdomain/zero/1.0/)
Version 1.1: Added a max-width to the body element, and auto margins to center
things again (just stops the page being too wide on full-screen desktop
browsers).
Version 1.0: Initial version Version 1.0: Initial version
*/ */
@ -44,6 +48,8 @@ body {
background-color: var(--background); background-color: var(--background);
color: var(--text); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, helvetica, Cantarell, Ubuntu, roboto, noto, arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, helvetica, Cantarell, Ubuntu, roboto, noto, arial, sans-serif;
max-width: 80em;
margin: 0 auto;
} }
a, a:visited, a:hover, a:active { color: inherit; } a, a:visited, a:hover, a:active { color: inherit; }

View File

@ -12,6 +12,17 @@
Just like the tools themselves, this file should be considered CC0 Public Just like the tools themselves, this file should be considered CC0 Public
Domain (https://creativecommons.org/publicdomain/zero/1.0/) Domain (https://creativecommons.org/publicdomain/zero/1.0/)
Version 1.7: Added ordered dithering support to imageDataToRgb565(). Also
swapped out the Gaussian resampling function in scaleImage() for a higher
quality algorithm based on a semi-custom hybrid between a looped 50%
bilinear downsampling scheme and a Hermite interpolation resampler. Added a
"keepAlpha" argument to imageToImageData() (defaults to true) - if false,
the provided image is drawn on a black background, removing any alpha
channel; this was added as a utility for the boot logo changer. Also
generally tidied up the code here and there (changed vars to lets/consts,
replaced the SVG icons with simple emoji, fixed a few small logic edge
cases, etc.)
Version 1.6: Added support for the (hopefully not broken) October 13th BIOS in Version 1.6: Added support for the (hopefully not broken) October 13th BIOS in
getFirmwareHash() and knownHash() getFirmwareHash() and knownHash()
@ -44,17 +55,17 @@
// 0x18f with an updated CRC32 calculated on bytes 512 to the end of the // 0x18f with an updated CRC32 calculated on bytes 512 to the end of the
// array. Credit to `bnister` for this code! // array. Credit to `bnister` for this code!
function patchCRC32(data) { function patchCRC32(data) {
var c; let c;
var tabCRC32 = new Int32Array(256); const tabCRC32 = new Int32Array(256);
for (var i = 0; i < 256; i++) { for (let i = 0; i < 256; i++) {
c = i << 24; c = i << 24;
for (var j = 0; j < 8; j++) { for (let j = 0; j < 8; j++) {
c = c & (1 << 31) ? c << 1 ^ 0x4c11db7 : c << 1; c = c & (1 << 31) ? c << 1 ^ 0x4c11db7 : c << 1;
} }
tabCRC32[i] = c; tabCRC32[i] = c;
} }
c = ~0; c = ~0;
for (var i = 512; i < data.length; i++) { for (let i = 512; i < data.length; i++) {
c = c << 8 ^ tabCRC32[c >>> 24 ^ data[i]]; c = c << 8 ^ tabCRC32[c >>> 24 ^ data[i]];
} }
data[0x18c] = c & 255; data[0x18c] = c & 255;
@ -71,7 +82,7 @@ function getFirmwareHash(data) {
// we're going to be manipulating that data before generating our hash, but we // we're going to be manipulating that data before generating our hash, but we
// don't want to modify the original object at all... so we'll create a copy, // don't want to modify the original object at all... so we'll create a copy,
// and work only on the copy... // and work only on the copy...
var dataCopy = data.slice(); const dataCopy = data.slice();
// Only really worthwhile doing this for big bisrv.asd files... // Only really worthwhile doing this for big bisrv.asd files...
if (dataCopy.length > 12600000) { if (dataCopy.length > 12600000) {
@ -87,9 +98,9 @@ function getFirmwareHash(data) {
// level indicator. These unfortunately can't be searched for - they're just // level indicator. These unfortunately can't be searched for - they're just
// in specific known locations for specific firmware versions... // in specific known locations for specific firmware versions...
// Location: Approximately 0x35A8F8 (about 25% of the way through the file) // Location: Approximately 0x35A8F8 (about 25% of the way through the file)
var prePowerCurve = findSequence([0x11, 0x05, 0x00, 0x02, 0x24], dataCopy); const prePowerCurve = findSequence([0x11, 0x05, 0x00, 0x02, 0x24], dataCopy);
if (prePowerCurve > -1) { if (prePowerCurve > -1) {
var powerCurveFirstByteLocation = prePowerCurve + 5; const powerCurveFirstByteLocation = prePowerCurve + 5;
switch (powerCurveFirstByteLocation) { switch (powerCurveFirstByteLocation) {
case 0x35A8F8: case 0x35A8F8:
// Seems to match mid-March layout... // Seems to match mid-March layout...
@ -156,11 +167,11 @@ function getFirmwareHash(data) {
// Next identify the emulator button mappings (if they exist), and blank // Next identify the emulator button mappings (if they exist), and blank
// them out too... // them out too...
// Location: Approximately 0x8D6200 (about 75% of the way through the file) // Location: Approximately 0x8D6200 (about 75% of the way through the file)
var preButtonMapOffset = findSequence([0x00, 0x00, 0x00, 0x71, 0xDB, 0x8E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], dataCopy); const preButtonMapOffset = findSequence([0x00, 0x00, 0x00, 0x71, 0xDB, 0x8E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], dataCopy);
if (preButtonMapOffset > -1) { if (preButtonMapOffset > -1) {
var postButtonMapOffset = findSequence([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00], dataCopy, preButtonMapOffset); const postButtonMapOffset = findSequence([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00], dataCopy, preButtonMapOffset);
if (postButtonMapOffset > -1) { if (postButtonMapOffset > -1) {
for (var i = preButtonMapOffset + 16; i < postButtonMapOffset; i++) { for (let i = preButtonMapOffset + 16; i < postButtonMapOffset; i++) {
dataCopy[i] = 0x00; dataCopy[i] = 0x00;
} }
} }
@ -174,10 +185,10 @@ function getFirmwareHash(data) {
// Next identify the boot logo position, and blank it out too... // Next identify the boot logo position, and blank it out too...
// Location: Approximately 0x9B3520 (about 80% of the way through the file) // Location: Approximately 0x9B3520 (about 80% of the way through the file)
var badExceptionOffset = findSequence([0x62, 0x61, 0x64, 0x5F, 0x65, 0x78, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6F, 0x6E, 0x00, 0x00, 0x00], dataCopy); const badExceptionOffset = findSequence([0x62, 0x61, 0x64, 0x5F, 0x65, 0x78, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6F, 0x6E, 0x00, 0x00, 0x00], dataCopy);
if (badExceptionOffset > -1) { if (badExceptionOffset > -1) {
var bootLogoStart = badExceptionOffset + 16; const bootLogoStart = badExceptionOffset + 16;
for (var i = bootLogoStart; i < (bootLogoStart + 204800); i++) { for (let i = bootLogoStart; i < (bootLogoStart + 204800); i++) {
dataCopy[i] = 0x00; dataCopy[i] = 0x00;
} }
} }
@ -189,10 +200,10 @@ function getFirmwareHash(data) {
// CPU cycles, in case folks want to patch those bytes to correct SNES // CPU cycles, in case folks want to patch those bytes to correct SNES
// first-launch issues on newer firmwares... // first-launch issues on newer firmwares...
// Location: Approximately 0xC0A170 (about 99% of the way through the file) // Location: Approximately 0xC0A170 (about 99% of the way through the file)
var preSNESBytes = findSequence([0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x80], dataCopy); const preSNESBytes = findSequence([0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x80], dataCopy);
if (preSNESBytes > -1) { if (preSNESBytes > -1) {
var snesAudioBitrateBytes = preSNESBytes + 8; const snesAudioBitrateBytes = preSNESBytes + 8;
var snesCPUCyclesBytes = snesAudioBitrateBytes + 8; const snesCPUCyclesBytes = snesAudioBitrateBytes + 8;
dataCopy[snesAudioBitrateBytes] = 0x00; dataCopy[snesAudioBitrateBytes] = 0x00;
dataCopy[snesAudioBitrateBytes + 1] = 0x00; dataCopy[snesAudioBitrateBytes + 1] = 0x00;
dataCopy[snesCPUCyclesBytes] = 0x00; dataCopy[snesCPUCyclesBytes] = 0x00;
@ -208,9 +219,8 @@ function getFirmwareHash(data) {
// against some known values... // against some known values...
return crypto.subtle.digest("SHA-256", dataCopy.buffer) return crypto.subtle.digest("SHA-256", dataCopy.buffer)
.then(function(digest) { .then(function(digest) {
var array = Array.from(new Uint8Array(digest)); const array = Array.from(new Uint8Array(digest));
var hash = array.map(byte => ("00" + byte.toString(16)).slice(-2)).join(""); return array.map(byte => ("00" + byte.toString(16)).slice(-2)).join("");
return hash;
}) })
.catch(function(error) { .catch(function(error) {
return false; return false;
@ -224,26 +234,21 @@ function getFirmwareHash(data) {
// This function searches for array needle in array haystack starting at offset // This function searches for array needle in array haystack starting at offset
// and returns the zero-based index of the first match found, or -1 if not // and returns the zero-based index of the first match found, or -1 if not
// found... // found...
function findSequence(needle, haystack, offset) { function findSequence(needle, haystack, offset = 0) {
// If offset is not provided, default to 0...
offset = offset || 0;
// Loop through the haystack array starting from the offset... // Loop through the haystack array starting from the offset...
for (var i = offset; i < haystack.length - needle.length + 1; i++) { for (let i = offset; i < haystack.length - needle.length + 1; i++) {
// Assume a match until proven otherwise... // Assume a match until proven otherwise...
var match = true; let match = true;
// Loop through the needle array and compare each byte... // Loop through the needle array and compare each byte...
for (var j = 0; j < needle.length; j++) { for (let j = 0; j < needle.length; j++) {
if (haystack[i + j] !== needle[j]) { if (haystack[i + j] !== needle[j]) {
// Mismatch found, break the inner loop and continue the outer loop... // Mismatch found, break the inner loop and continue the outer loop...
match = false; match = false;
break; break;
} }
} }
// If match is still true after the inner loop, we have found a match; // If match is still true after the inner loop, we have found a match;
@ -261,7 +266,7 @@ function findSequence(needle, haystack, offset) {
// download... // download...
function downloadToBrowser(data, type, name) { function downloadToBrowser(data, type, name) {
// Send the data to the user's browser as a file download... // Send the data to the user's browser as a file download...
var link = document.createElement("a"); const link = document.createElement("a");
link.href = window.URL.createObjectURL(new Blob([data], {type: type})); link.href = window.URL.createObjectURL(new Blob([data], {type: type}));
link.download = name; link.download = name;
link.style.display = "none"; link.style.display = "none";
@ -304,33 +309,103 @@ function knownHash(hash) {
} }
// Takes in an ImageData object, and returns a Uint8Array object containing the // Takes in an ImageData object, and returns a Uint8Array object containing the
// data in little-endian RGB565 format... // data in little-endian RGB565 format. Optionally supports applying ordered
function imageDataToRgb565(input) { // dithering to the input...
function imageDataToRgb565(input, dither = false, ditherStrength = 0.2) {
// Pre-define a Bayer 8x8 matrix for ordered dithering (if enabled); also,
// this matrix was shamelessly yoinked from the Wikipedia example!
const bayerMatrix = [
[ 0, 32, 8, 40, 2, 34, 10, 42],
[48, 16, 56, 24, 50, 18, 58, 26],
[12, 44, 4, 36, 14, 46, 6, 38],
[60, 28, 52, 20, 62, 30, 54, 22],
[ 3, 35, 11, 43, 1, 33, 9, 41],
[51, 19, 59, 27, 49, 17, 57, 25],
[15, 47, 7, 39, 13, 45, 5, 37],
[63, 31, 55, 23, 61, 29, 53, 21]
];
// Loop through the image data, and convert it to little-endian RGB565. First, // Loop through the image data, and convert it to little-endian RGB565. First,
// we'll store the raw RGB565-converted integers in an array, one entry per // we'll store the raw RGB565-converted integers in an array, one entry per
// pixel... // pixel...
var intArray = []; const intArray = [];
var pixelCount = 0; for (let i = 0; i < input.data.length; i += 4){
for (var i = 0; i < input.data.length; i += 4){
// Read in the raw source RGB colours from the image data stream... // Read in the raw source RGB colours from the image data stream...
var red = input.data[i]; let red = input.data[i];
var green = input.data[i+1]; let green = input.data[i+1];
var blue = input.data[i+2]; let blue = input.data[i+2];
// Use some shifting and masking to get a big-endian version of the RGB565 // Check if we're going to dither or not...
// colour and store it in our array before moving on... if (dither) {
intArray[pixelCount] = ((red & 248)<<8) + ((green & 252)<<3) + (blue>>3);
pixelCount++; // We are! The ordered dither algorithm is kinda messy, but essentially we
// just end up slightly brightening/darkening our source colour pixels,
// using the matrix defined above - this essentially adds a kind of
// "noise" to the image, which stops banding being as apparent when
// dropping down to RGB565. The first thing we need to do is calculate our
// X and Y coordinates within the Bayer matrix for the current source
// image pixel...
const bayerX = (i / 4) % input.width % 8;
const bayerY = (Math.floor(i / 4 / input.width)) % 8;
// The Wikipedia Bayer matrix was designed to work with colour values that
// range from 0 to 63... which is great for our RGB565 green colour
// channel, but not for red or blue - so we scale the matrix values to
// range from 0 to 31 for those two colour channels. Also, we want to both
// lighten *and* darken our source input values, so to do that we subtract
// roughly half the *possible* maximum from each value - 16 for red and
// blue (half of 31, rounded up), and 32 for green (half of 63, rounded
// up); note I'm using actual values of 18 and 36 instead of 16 and 32, as
// I found that the Wikipedia matrix tended towards lightening more than
// darkening, so I'm compensating by offsetting downwards a little more...
const bayerValueRedBlue = (bayerMatrix[bayerY][bayerX] / 63 * 31) - 18;
const bayerValueGreen = bayerMatrix[bayerY][bayerX] - 36;
// Now we apply the ordered dithering itself; basically this "adds" the
// Bayer matrix values to red, green and blue (which might lighten or
// darken the pixel, depending on the specific value), and then uses that
// value as a percentage of 255 (the highest possible value) to scale our
// *actual* RGB565 output values for the pixel (which is a maximum of 31
// for red and blue, and 63 for green). We also scale the whole effect by
// ditherStrength, so that we can adjust how strong or weak the overall
// dithering noise is (too strong and its distracting, too weak and it
// mightn't effectively cover up banding in the output image). We scale
// the green colour channel half as much as red and blue, as otherwise at
// high dither strengths the image would take on an increasingly green
// color cast...
red = Math.round(31 * (Math.min(255, Math.max(0, red + (bayerValueRedBlue * ditherStrength))) / 255));
green = Math.round(63 * (Math.min(255, Math.max(0, green + (bayerValueGreen * ditherStrength * 0.5))) / 255));
blue = Math.round(31 * (Math.min(255, Math.max(0, blue + (bayerValueRedBlue * ditherStrength))) / 255));
// As a result of the multiplying above, it's possible our red, green and
// blue values are now outside of the 0-31/63 ranges that are allowed for
// our RGB565 output - so we need to clamp the values, just in case...
red = Math.min(31, Math.max(0, red));
green = Math.min(63, Math.max(0, green));
blue = Math.min(31, Math.max(0, blue));
// And finally, we take our values and convert them to an int representing
// the RGB565 value, that will eventually be stuffed into our output
// Uint8Array object...
intArray[i / 4] = (red << 11) + (green << 5) + blue;
}
else {
// We're not dithering, so all we need to do is use some shifting and
// masking to get a big-endian version of the RGB565 colour and store it
// in our array before moving on...
intArray[i / 4] = ((red & 248)<<8) + ((green & 252)<<3) + (blue>>3);
}
} }
// Create a data buffer and a data view; we'll use the view to convert our int // Create a data buffer and a data view; we'll use the view to convert our int
// array data to little-endian format (the "true" below) to be stored in the // array data to little-endian format (the "true" below) to be stored in the
// buffer... // buffer...
var buffer = new ArrayBuffer(intArray.length * 2); const buffer = new ArrayBuffer(intArray.length * 2);
var dataView = new DataView(buffer); const dataView = new DataView(buffer);
for (var i = 0; i < intArray.length; i++) { for (let i = 0; i < intArray.length; i++) {
dataView.setInt16(i * 2, intArray[i], true); dataView.setInt16(i * 2, intArray[i], true);
} }
@ -344,8 +419,8 @@ function imageDataToBgra(input) {
// This is pretty simple - we just loop through the input data (which is in // This is pretty simple - we just loop through the input data (which is in
// RGBA format), and swap the Red and Blue channels to output BGRA instead... // RGBA format), and swap the Red and Blue channels to output BGRA instead...
output = new Uint8Array(input.data.length); const output = new Uint8Array(input.data.length);
for (var i = 0; i < input.data.length; i += 4) { for (let i = 0; i < input.data.length; i += 4) {
output[i] = input.data[i + 2]; output[i] = input.data[i + 2];
output[i + 1] = input.data[i + 1]; output[i + 1] = input.data[i + 1];
output[i + 2] = input.data[i]; output[i + 2] = input.data[i];
@ -360,25 +435,25 @@ function rgb565ToImageData(input, width, height) {
// Create an output ImageData object of the specified dimensions; it'll // Create an output ImageData object of the specified dimensions; it'll
// default to transparent black, but we'll fill it with our input data... // default to transparent black, but we'll fill it with our input data...
output = new ImageData(width, height); const output = new ImageData(width, height);
outputIndex = 0; let outputIndex = 0;
for (var i = 0; i < input.length; i += 2) { for (let i = 0; i < input.length; i += 2) {
// Check to make sure we haven't run out of space in our output buffer... // Check to make sure we haven't run out of space in our output buffer...
if (outputIndex < output.data.length) { if (outputIndex < output.data.length) {
// Read in two bytes, representing one RGB565 pixel in little-endian // Read in two bytes, representing one RGB565 pixel in little-endian
// format... // format...
var byte1 = input[i]; const byte1 = input[i];
var byte2 = input[i + 1]; const byte2 = input[i + 1];
// Extract the red, green and blue components from them. The first five // Extract the red, green and blue components from them. The first five
// bits of byte2 are red, the first three bits of byte1 and the last // bits of byte2 are red, the first three bits of byte1 and the last
// three bits of byte 2 are green, and the last five bits of byte1 are // three bits of byte 2 are green, and the last five bits of byte1 are
// blue... // blue...
var red = (byte2 & 0b11111000) >> 3; let red = (byte2 & 0b11111000) >> 3;
var green = ((byte1 & 0b11100000) >> 5) | ((byte2 & 0b00000111) << 3); let green = ((byte1 & 0b11100000) >> 5) | ((byte2 & 0b00000111) << 3);
var blue = byte1 & 0b00011111; let blue = byte1 & 0b00011111;
// These values are in 5-bit/6-bit ranges; we need to scale them to 8-bit // These values are in 5-bit/6-bit ranges; we need to scale them to 8-bit
// ranges for the colours to look right... // ranges for the colours to look right...
@ -422,9 +497,9 @@ function bgraToImageData(input, width, height) {
// Create an output ImageData object of the specified dimensions; it'll // Create an output ImageData object of the specified dimensions; it'll
// default to transparent black, but we'll fill it with our input data... // default to transparent black, but we'll fill it with our input data...
output = new ImageData(width, height); const output = new ImageData(width, height);
outputIndex = 0; let outputIndex = 0;
for (var i = 0; i < input.length; i += 4) { for (let i = 0; i < input.length; i += 4) {
// Check to make sure we haven't run out of space in our output buffer... // Check to make sure we haven't run out of space in our output buffer...
if (outputIndex < output.data.length) { if (outputIndex < output.data.length) {
@ -461,193 +536,302 @@ function bgraToImageData(input, width, height) {
// This function takes in a Javascript Image object, and outputs it as an // This function takes in a Javascript Image object, and outputs it as an
// ImageData object instead... // ImageData object instead...
function imageToImageData(image) { function imageToImageData(image, keepAlpha = true) {
// Create a virtual canvas, and load it up with our image file... // Create a virtual canvas, and load it up with our image file...
var canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
var context = canvas.getContext("2d"); const context = canvas.getContext("2d");
canvas.width = image.width; canvas.width = image.width;
canvas.height = image.height; canvas.height = image.height;
// Draw our image to the canvas, which will allow us to get data about the // Draw our image to the canvas, which will allow us to get data about the
// image... // image. If the user doesn't want to keep the alpha channel, draw the image
// on a black background...
if (!keepAlpha) {
context.fillStyle = "black";
context.fillRect(0, 0, image.width, image.height);
}
context.drawImage(image, 0, 0, image.width, image.height); context.drawImage(image, 0, 0, image.width, image.height);
var imageData = context.getImageData(0, 0, image.width, image.height); const imageData = context.getImageData(0, 0, image.width, image.height);
// Return the ImageData object... // Return the ImageData object...
return imageData; return imageData;
} }
// This function takes in an ImageData object, and returns a new ImageData // This function takes in an ImageData object "input", and returns a new
// object containing a scaled version of the original image. The new image's // ImageData object containing a scaled version of the original image resized to
// width, height and scaling method are also specified... // "newWidth" and "newHeight". Two different scaling methods are supported:
function scaleImage(input, newWidth, newHeight, method) { // "Nearest Neighbour", and "Bilinear", although specifically when downscaling,
// "Bilinear" is just a friendly name for two different scaling
// filters/techniques that are used in a hybrid approach - halve-to-target, and
// Hermite interpolation...
function scaleImage(input, newWidth, newHeight, method, downscaleFilter = "hermite") {
// Utility function which takes in an ImageData object, and returns // Utility function which takes in an ImageData object, and returns a scaled
// a (partially) upscaled version using bilinear filtering... // version using bilinear filtering...
function _bilinear(imageData, newWidth, newHeight) { function _bilinear(imageData, newWidth, newHeight) {
var canvas = document.createElement("canvas");
var context = canvas.getContext("2d");
canvas.width = imageData.width;
canvas.height = imageData.height;
context.putImageData(imageData, 0, 0);
var outCanvas = document.createElement("canvas"); // Just in case, let's check to see if imageData's dimensions are already at
var outContext = outCanvas.getContext("2d"); // the target width and height - if they are, there's no need to do anything
outContext.imageSmoothingEnabled = true; // to it, and we can just return it as-is...
outContext.imageSmoothingQuality = "high"; if (imageData.width == newWidth && imageData.height == newHeight) {
outCanvas.width = newWidth; return imageData;
outCanvas.height = newHeight;
outContext.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, newWidth, newHeight);
return outContext.getImageData(0, 0, newWidth, newHeight);
}
// Utility function which takes in an ImageData object, and returns
// a (partially) downscaled version using gaussian resampling...
// [Side note: I asked Bing AI for this function; ain't technology grand!]
function _gaussian(imageData, newWidth, newHeight) {
// Get the original width and height of the image data
let oldWidth = imageData.width;
let oldHeight = imageData.height;
// Get the pixel data array of the image data
let oldData = imageData.data;
// Create a new pixel data array for the scaled image data
let newData = new Uint8ClampedArray(newWidth * newHeight * 4);
// Calculate the scaling factor along each axis
let scaleX = newWidth / oldWidth;
let scaleY = newHeight / oldHeight;
// Calculate the radius of the Gaussian kernel based on the scaling factor
let radiusX = Math.ceil(1 / scaleX);
let radiusY = Math.ceil(1 / scaleY);
// Calculate the size of the Gaussian kernel along each axis
let sizeX = radiusX * 2 + 1;
let sizeY = radiusY * 2 + 1;
// Create a Gaussian kernel array
let kernel = new Float32Array(sizeX * sizeY);
// Calculate the standard deviation of the Gaussian distribution based on
// the radius
let sigmaX = radiusX / 3;
let sigmaY = radiusY / 3;
// Calculate the inverse of the variance of the Gaussian distribution along
// each axis
let invVarX = 1 / (2 * sigmaX * sigmaX);
let invVarY = 1 / (2 * sigmaY * sigmaY);
// Calculate the normalization factor for the Gaussian kernel
let norm = Math.sqrt(2 * Math.PI * sigmaX * sigmaY);
// Loop through each element in the Gaussian kernel array
for (let ky = -radiusY; ky <= radiusY; ky++) {
for (let kx = -radiusX; kx <= radiusX; kx++) {
// Calculate the index of the element in the Gaussian kernel array
let k = (ky + radiusY) * sizeX + (kx + radiusX);
// Calculate the value of the element using the Gaussian formula
kernel[k] = Math.exp(-(kx * kx) * invVarX - (ky * ky) * invVarY) / norm;
}
} }
// Loop through each pixel in the new image data // If we're here, then nope - we have some scaling to do! Create a canvas
for (let y = 0; y < newHeight; y++) { // object and draw our input ImageData object to it...
for (let x = 0; x < newWidth; x++) { const inputCanvas = document.createElement("canvas");
// Calculate the corresponding coordinates in the old image data const inputContext = inputCanvas.getContext("2d");
let oldX = x / scaleX; inputCanvas.width = imageData.width;
let oldY = y / scaleY; inputCanvas.height = imageData.height;
inputContext.putImageData(imageData, 0, 0);
// Initialize the RGBA values of the pixel to zero // Create another canvas object with the target dimensions, and draw the
let r7 = 0; // inputCanvas to it at target dimensions; this utilises the browser's
let g7 = 0; // native bilinear filtering...
let b7 = 0; const outputCanvas = document.createElement("canvas");
let a7 = 0; const outputContext = outputCanvas.getContext("2d");
outputContext.imageSmoothingEnabled = true;
outputContext.imageSmoothingQuality = "high";
outputCanvas.width = newWidth;
outputCanvas.height = newHeight;
outputContext.drawImage(inputCanvas, 0, 0, inputCanvas.width, inputCanvas.height, 0, 0, newWidth, newHeight);
// Initialize the sum of the kernel values to zero // Return an ImageData object pulled from the output canvas...
let sum = 0; return outputContext.getImageData(0, 0, newWidth, newHeight);
}
// Loop through each element in the Gaussian kernel array // Utility function which takes in an ImageData object, and returns a
for (let ky = -radiusY; ky <= radiusY; ky++) { // downscaled version using a technique that involves downscaling the input's
for (let kx = -radiusX; kx <= radiusX; kx++) { // width and height repeatedly by half until doing so again would take the
// Calculate the index of the element in the Gaussian kernel array // image below the target dimensions. The result is then blended with the next
let k = (ky + radiusY) * sizeX + (kx + radiusX); // scale down based on how close the target dimensions are to the current or
// next scale - this essentially gives an effect like mip-mapping in 3D games.
// This method is fast, doesn't introduce image distortion, and works great
// with alpha channels... but can give "soft" images if the target image
// dimensions are just under native scale (or a 50% downscale threshold), and
// can give a "harsh" image if the target dimensions are just above a
// downscale threshold. I try to mitigate the latter by blending in an
// additional downscale when we get close to a downscale threshold. This
// function and sub-functions are based on the following JSFiddle code, but
// with my own modifications: https://jsfiddle.net/1b68eLdr/93089/
function _halveToTarget(imageData, newWidth, newHeight) {
// Get the value of the element in the Gaussian kernel array // This sub-function takes two ImageData objects (assumed to be of identical
let w = kernel[k]; // dimensions) and blends their RGBA data together based on the passed-in
// amount (between 0 and 1 inclusive) of how much of imageDataOne should be
// in the blend. An amount of 1 means the result will be purely
// imageDataOne, while an amount of 0 means the result will be purely
// imageDataTwo. Numbers between 0 and 1 will give an appropriate mix of the
// two. The result of the blend is then returned as an ImageData object...
function blendImageDatas(imageDataOne, imageDataTwo, amountImageDataOne){
const blendedData = new ImageData(imageDataOne.width, imageDataOne.height);
const amountImageDataTwo = 1 - amountImageDataOne;
for (let i = 0; i < imageDataOne.data.length; i += 4) {
blendedData.data[i ] = (imageDataOne.data[i ] * amountImageDataOne) + (imageDataTwo.data[i ] * amountImageDataTwo);
blendedData.data[i + 1] = (imageDataOne.data[i + 1] * amountImageDataOne) + (imageDataTwo.data[i + 1] * amountImageDataTwo);
blendedData.data[i + 2] = (imageDataOne.data[i + 2] * amountImageDataOne) + (imageDataTwo.data[i + 2] * amountImageDataTwo);
blendedData.data[i + 3] = (imageDataOne.data[i + 3] * amountImageDataOne) + (imageDataTwo.data[i + 3] * amountImageDataTwo);
}
return blendedData;
}
// Calculate the coordinates of the pixel in the old image data that // First, let's just copy imageData, and work on the copy instead - just in
// corresponds to this element // case we don't want to modify the input directly (it's passed by reference
let x1 = Math.round(oldX + kx); // because it's an object - thanks JavaScript!)
let y1 = Math.round(oldY + ky); let inputData = new ImageData(imageData.width, imageData.height);
inputData.data.set(imageData.data);
// Clamp the coordinates to the valid range // Now let's reduce the input's width and height (independently) until doing
x1 = Math.max(0, Math.min(x1, oldWidth - 1)); // so again would take the input's dimensions below the target dimensions;
y1 = Math.max(0, Math.min(y1, oldHeight - 1)); // we'll use plain-old browser bilinear filtering to do this...
while (newWidth <= Math.round(inputData.width * 0.5)) {
inputData = _bilinear(inputData, Math.round(inputData.width * 0.5), inputData.height);
}
while (newHeight <= Math.round(inputData.height * 0.5)) {
inputData = _bilinear(inputData, inputData.width, Math.round(inputData.height * 0.5));
}
// Get the index of the pixel in the old pixel data array // Now, inputData either exactly matches the dimensions of newWidth AND
let i1 = (y1 * oldWidth + x1) * 4; // newHeight (in which case we're done), OR one or more of inputData's
// dimensions is greater than newWidth/newHeight. In that case, we want to
// generate the next level down for the dimensions that't aren't equal, and
// then blend between inputData (which is too detailed) and the next level
// down (which is too soft) based on how far away from the next level down
// we are - this essentially gives an effect like mip-mapping, and will help
// to ensure that transitions across 50% downscale thresholds aren't too
// jarring...
if (newWidth < inputData.width || newHeight < inputData.height) {
// Get the RGBA values of the pixel in the old pixel data array // OK, one or more of inputData's dimensions is greater than
let r1 = oldData[i1]; // newWidth/newHeight, so we're going to be doing some blending. First we
let g1 = oldData[i1 + 1]; // need to generate the next scale of image downwards - just like before,
let b1 = oldData[i1 + 2]; // we'll do each dimension separately...
let a1 = oldData[i1 + 3]; let blendData = new ImageData(inputData.width, inputData.height);
blendData.data.set(inputData.data);
if (newWidth < inputData.width) {
blendData = _bilinear(blendData, Math.round(blendData.width * 0.5), blendData.height);
}
if (newHeight < inputData.height) {
blendData = _bilinear(blendData, blendData.width, Math.round(blendData.height * 0.5));
}
// Multiply the RGBA values by the kernel value and add them to the // Now we have the next level of downscale, we need to work out where
// pixel values // our target width and height lie between inputData's width and height,
r7 += r1 * w; // and blendData's width and height - that'll tell us what proportion of
g7 += g1 * w; // each image should be in the final blend between the two. Once we've
b7 += b1 * w; // worked that out, we blend inputData with blendData accordingly
a7 += a1 * w; // (blendData gets upscaled to inputData's resolution first, to make the
// blending less complicated)...
let axisBeingBlended = 0;
let widthFactor = 0;
let heightFactor = 0;
if (inputData.width - blendData.width != 0) {
widthFactor = (newWidth - blendData.width) / (inputData.width - blendData.width);
axisBeingBlended += 1;
}
if (inputData.height - blendData.height != 0) {
heightFactor = (newHeight - blendData.height) / (inputData.height - blendData.height);
axisBeingBlended += 1;
}
inputData = blendImageDatas(inputData, _bilinear(blendData, inputData.width, inputData.height), (widthFactor + heightFactor) / axisBeingBlended);
}
// Add the kernel value to the sum // Finally, return imageData as scaled to the final desired width and height
sum += w; // (it may not currently exactly those dimensions)...
return _bilinear(inputData, newWidth, newHeight);
}
// Utility function that takes in an ImageData object, and returns a
// downscaled version using a technique called Hermite interpolation. This
// method is a little slow, produces very high-quality output and works great
// with alpha channels... but it can introduce a little image distortion with
// high-frequency inputs, as well as some light aliasing on very fine details.
// To mitigate this, I've modified it to use a hybrid approach - I scale the
// image image to twice the target width and height using _halveToTarget
// (defined above), even if this means upscaling the input image first, and
// *then* I downscale the result using Hermite interpolation. The result is a
// bit slower again (as the input gets rescaled several times), but produced
// excellent results with zero distortion across all of my test images, even
// those with huge downscale ratios and very fine details (starfields). The
// core of this function is based on the following JSFiddle code, but again
// with some modifications by myself: https://jsfiddle.net/9g9Nv/442/
function _hermite(imageData, newWidth, newHeight){
// So the very first thing we do is rescale the input to twice the desired
// width and height using _halveToTarget and bilinear filtering (which may
// involve upscaling the input), before we use Hermite interpolation to
// downscale to the final target resolution - this hybrid approach appears
// to give excellent results for a slight speed penalty. We just call our
// main scaleImage() function, and force it to use "halveToTarget" instead
// of the default "hermite" method (don't want to be stuck in an infinite
// loop!). We'll do all our work on a copy of the input, just in case
// we don't want to modify that in the calling code...
let inputData = new ImageData(imageData.width, imageData.height);
inputData.data.set(imageData.data);
inputData = scaleImage(inputData, newWidth * 2, newHeight * 2, "Bilinear", "halveToTarget");
// OK, now it's on to the main Hermite interpolation; thanks to the original
// author of the JSFiddle! I've just cleaned up their code a little to fit
// within my use-case here. I'm not a "maths" person, and so the
// underpinnings of Hermite interpolation go over my head (I'm talking about
// the Wikipedia article here)... but from reading the code below, what I
// believe is happening is that it loops through the input data, and breaks
// it up into little rectangular chunks, where each chunk will become one
// pixel in the output image. It goes through each pixel of the chunk, and
// accumulates a weighted version of its RGBA data into a buffer (the gr_X
// variables). Once it's finished going through the chunk, it takes the
// accumulation buffer and uses the total weight to calculate a final RGBA
// for the matching pixel in the output buffer and stores it. The bit that's
// over my head is why the weighting is calculated the way it is... I'll
// leave that as an exercise to the more mathematically inclined, and just
// say silent thanks to the JSFiddle author again! I've renamed their
// original variables to match my own understanding as best I can, in case
// it helps to parse it a bit better...
const ratioWidth = inputData.width / newWidth;
const ratioWidthHalf = Math.ceil(ratioWidth / 2);
const ratioHeight = inputData.height / newHeight;
const ratioHeightHalf = Math.ceil(ratioHeight / 2);
const output = new ImageData(newWidth, newHeight);
// Loop through our desired output (as we're calculating the final value
// of each output pixel directly)...
for (let outputY = 0; outputY < newHeight; outputY++) {
for (let outputX = 0; outputX < newWidth; outputX++) {
let currentWeight = 0;
let totalWeightRGB = 0;
let totalWeightAlpha = 0;
let accumulatorRed = 0;
let accumulatorGreen = 0;
let accumulatorBlue = 0;
let accumulatorAlpha = 0;
const center_y = (outputY + 0.5) * ratioHeight;
// Calculate the borders of the "chunk" of input that'll be weighted
// down to a single output pixel...
const inputChunkLeftEdge = Math.floor(outputX * ratioWidth);
const inputChunkRightEdge = Math.min(Math.ceil((outputX + 1) * ratioWidth), inputData.width);
const inputChunkTopEdge = Math.floor(outputY * ratioHeight);
const inputChunkBottomEdge = Math.min(Math.ceil((outputY + 1) * ratioHeight), inputData.height);
// Now loop through the input rows within that chunk...
for (let inputY = inputChunkTopEdge; inputY < inputChunkBottomEdge; inputY++) {
// These three lines I'm not sure about, to be honest...
const dy = Math.abs(center_y - (inputY + 0.5)) / ratioHeightHalf;
const center_x = (outputX + 0.5) * ratioWidth;
const w0 = dy * dy;
// And loop through the input columns within those rows...
for (let inputX = inputChunkLeftEdge; inputX < inputChunkRightEdge; inputX++) {
// Again, these lines are similar to the three above; I know "w" is
// used in the Hermite weighting calculation...
const dx = Math.abs(center_x - (inputX + 0.5)) / ratioWidthHalf;
const w = Math.sqrt(w0 + dx * dx);
if (w >= 1) {
continue;
}
// This line is where the Hermite weighting is calculated...
currentWeight = 2 * w * w * w - 3 * w * w + 1;
// Now we use the weighting to fractions of the source pixel RGBA
// data in our accumulators; we also add the weight of the current
// pixel to a weight accumulator...
const pos_x = 4 * (inputX + inputY * inputData.width);
accumulatorAlpha += currentWeight * inputData.data[pos_x + 3];
totalWeightAlpha += currentWeight;
if (inputData.data[pos_x + 3] < 255) {
currentWeight = currentWeight * inputData.data[pos_x + 3] / 250;
}
accumulatorRed += currentWeight * inputData.data[pos_x];
accumulatorGreen += currentWeight * inputData.data[pos_x + 1];
accumulatorBlue += currentWeight * inputData.data[pos_x + 2];
totalWeightRGB += currentWeight;
} }
} }
// Divide the RGBA values by the sum to get an average value // Now that we've finished accumulating the weighted RGBA data for that
r7 /= sum; // entire source "chunk", we divide it by the total weight and store the
g7 /= sum; // result in our output ImageData object...
b7 /= sum; const pixelIndex = (outputX + outputY * newWidth) * 4;
a7 /= sum; output.data[pixelIndex ] = accumulatorRed / totalWeightRGB;
output.data[pixelIndex + 1] = accumulatorGreen / totalWeightRGB;
// Round the RGBA values to integers output.data[pixelIndex + 2] = accumulatorBlue / totalWeightRGB;
r7 = Math.round(r7); output.data[pixelIndex + 3] = accumulatorAlpha / totalWeightAlpha;
g7 = Math.round(g7);
b7 = Math.round(b7);
a7 = Math.round(a7);
// Get the index of the pixel in the new pixel data array
let j = (y * newWidth + x) * 4;
// Set the RGBA values of the pixel in the new pixel data array
newData[j] = r7;
newData[j + 1] = g7;
newData[j + 2] = b7;
newData[j + 3] = a7;
} }
} }
// Create and return a new ImageData object with the new pixel data array // And that's it - output now contains the downscaled image, so return it!
// and dimensions return output;
return new ImageData(newData, newWidth, newHeight);
} }
// Get the original width and height...
var width = input.width;
var height = input.height;
// Before we consider doing *any* scaling, let's check to make sure the new // Before we consider doing *any* scaling, let's check to make sure the new
// dimensions are different from the old ones; if they're not, there's no // dimensions are different from the old ones; if they're not, there's no
// point in doing any scaling! // point in doing any scaling!
if (width == newWidth && height == newHeight) { if (input.width == newWidth && input.height == newHeight) {
return input; return input;
} }
@ -659,43 +843,33 @@ function scaleImage(input, newWidth, newHeight, method) {
// If the method is "Nearest Neighbour"... // If the method is "Nearest Neighbour"...
case "Nearest Neighbour": case "Nearest Neighbour":
// Create a new canvas element to draw the scaled image (we'll use the // Create a new ImageData object to store the scaled pixel data...
// canvas to get our output ImageData object)... const outputData = new ImageData(newWidth, newHeight);
var canvas = document.createElement("canvas");
var context = canvas.getContext("2d");
// Set the canvas size to the new dimensions...
canvas.width = newWidth;
canvas.height = newHeight;
// Create a new image data object to store the scaled pixel data...
var scaledData = context.createImageData(newWidth, newHeight);
// Loop through each pixel of the new image... // Loop through each pixel of the new image...
for (var y = 0; y < newHeight; y++) { for (let outputY = 0; outputY < newHeight; outputY++) {
for (var x = 0; x < newWidth; x++) { for (let outputX = 0; outputX < newWidth; outputX++) {
// Calculate the index of the new pixel in the scaled data array... // Calculate the index of the new pixel in the output data...
var index = (y * newWidth + x) * 4; const outputIndex = (outputY * newWidth + outputX) * 4;
// Calculate the x and y coordinates of the corresponding pixel in // Calculate the x and y coordinates of the corresponding pixel in
// the original image... // the original image, and it's index within input's data...
var x2 = Math.floor(x * width / newWidth); const inputX = Math.floor(outputX * input.width / newWidth);
var y2 = Math.floor(y * height / newHeight); const inputY = Math.floor(outputY * input.height / newHeight);
const inputIndex = (inputY * input.width + inputX) * 4;
// Calculate the index of the original pixel in the data array... // Copy the color values from the input pixel to the output pixel...
var index2 = (y2 * width + x2) * 4; outputData.data[outputIndex] = input.data[inputIndex]; // Red
outputData.data[outputIndex + 1] = input.data[inputIndex + 1]; // Green
// Copy the color values from the original pixel to the new pixel... outputData.data[outputIndex + 2] = input.data[inputIndex + 2]; // Blue
scaledData.data[index] = input.data[index2]; // Red outputData.data[outputIndex + 3] = input.data[inputIndex + 3]; // Alpha
scaledData.data[index + 1] = input.data[index2 + 1]; // Green
scaledData.data[index + 2] = input.data[index2 + 2]; // Blue
scaledData.data[index + 3] = input.data[index2 + 3]; // Alpha
} }
} }
// Finally, return the scaled ImageData object... // Finally, return the scaled ImageData object...
return scaledData; return outputData;
break;
// If the method is "Bilinear"... // If the method is "Bilinear"...
case "Bilinear": case "Bilinear":
@ -704,66 +878,73 @@ function scaleImage(input, newWidth, newHeight, method) {
// you're *upscaling* an image, but if you're *downscaling* an image // you're *upscaling* an image, but if you're *downscaling* an image
// by more than half the original's width/height, then true bilinear // by more than half the original's width/height, then true bilinear
// filtering creates just as much of an aliased mess as nearest // filtering creates just as much of an aliased mess as nearest
// neighbour filtering. Most image editing apps therefore cheat and // neighbour filtering once you get down to downscaling by more than half
// use a resampling algorithm when downscaling, and bilinear filtering // the dimensions of the original image. Most image editing apps therefore
// when upscaling... so that's what we're going to do here too! We'll // cheat and use a resampling algorithm when downscaling, and bilinear
// use gaussian resampling for any image axis that's being downscaled, // filtering when upscaling... so that's what we're going to do here too!
// and bilinear for any axis that's being upscaled; this should give // We'll use a hybrid of bilinear and Hermite interpolation for any image
// the user a result that's much closer to what they'd expect to see... // axis that's being downscaled, and bilinear for any axis that's being
// upscaled; this should give the user a result that's much closer to what
// they'd expect to see from a graphics app like Photoshop or similar...
let upscaling = false, downscaling = false;
if (newWidth > input.width || newHeight > input.height) { upscaling = true; }
if (newWidth < input.width || newHeight < input.height) { downscaling = true; }
// Let's see which kind of scaling scenario we're in... // Now we'll process the image differently depending on whether or not
if (newWidth > width && newHeight > height) { // we're only upscaling, only downscaling, or doing a mix of upscaling and
// downscaling. In the case of a mix, we do the upscaling part first, and
// All dimensions being upscaled, so we'll use bilinear filtering for // the downscaling part second, as it'll give a slightly sharper result...
// everything... if (upscaling && !downscaling) {
// Upscale only...
return _bilinear(input, newWidth, newHeight); return _bilinear(input, newWidth, newHeight);
} }
else if (newWidth < width && newHeight < height) { else if (downscaling && !upscaling) {
// Downscale only - run the input through either our halve-to-target or
// All dimensions being downscaled, so we'll use gaussian resampling // Hermite filter...
// for everything... switch (downscaleFilter){
return _gaussian(input, newWidth, newHeight); case "hermite":
return _hermite(input, newWidth, newHeight);
break;
case "halveToTarget":
return _halveToTarget(input, newWidth, newHeight);
break;
}
} }
else { else {
// Both upscaling and downscaling... do the upscale first, then send the
// It's a mix! // result back to this function again for downscaling...
if (newWidth < width) { let partiallyScaled;
if (newWidth > input.width) {
// Gaussian for width, bilinear for height... // Upscale width
let partial = _gaussian(input, newWidth, height); partiallyScaled = _bilinear(input, newWidth, input.height);
return _bilinear(partial, newWidth, newHeight);
} }
else if (newHeight < height) { else {
// Upscale height
// Gaussian for height, bilinear for width... partiallyScaled = _bilinear(input, input.width, newHeight);
let partial = _gaussian(input, width, newHeight);
return _bilinear(partial, newWidth, newHeight);
} }
// Downscale the rest...
return scaleImage(partiallyScaled, newWidth, newHeight, method, downscaleFilter);
} }
break; break;
} }
} }
// This utility function is used for adding info, warning and error messages to // This utility function is used for adding info, warning and error messages to
// the appropriate spots in my tools. It uses custom-modified versions of the // the appropriate spots in my tools...
// SVG Famfamfam Silk icon set via https://github.com/frhun/silk-icon-scalable
function setMessage(type, divID, text) { function setMessage(type, divID, text) {
var icon; let icon = "";
switch(type) { switch(type) {
case "info": case "info":
icon = '<img class="icon" alt="An icon of a blue circle with a white lowercase letter I, indicating an informative message" aria-hidden="true" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHhtbG5zOnhsaW5rPSdodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rJyB2aWV3Qm94PScwIDAgNjQgNjQnPjxkZWZzPjxsaW5lYXJHcmFkaWVudCBpZD0nYSc+PHN0b3Agb2Zmc2V0PScwJyBzdG9wLWNvbG9yPScjNDk5MWQyJyBzdG9wLW9wYWNpdHk9Jy45Jy8+PHN0b3Agb2Zmc2V0PScxJyBzdG9wLWNvbG9yPScjMmU0NjgxJyBzdG9wLW9wYWNpdHk9Jy45NicvPjwvbGluZWFyR3JhZGllbnQ+PGxpbmVhckdyYWRpZW50IHhsaW5rOmhyZWY9JyNhJyBpZD0nYycgeDE9JzEyJyB4Mj0nNTInIHkxPScxMicgeTI9JzUyJyBncmFkaWVudFVuaXRzPSd1c2VyU3BhY2VPblVzZScvPjxsaW5lYXJHcmFkaWVudCBpZD0nYic+PHN0b3Agb2Zmc2V0PScwJyBzdG9wLWNvbG9yPScjN2RhOWQ2Jy8+PHN0b3Agb2Zmc2V0PScxJyBzdG9wLWNvbG9yPScjNWU4NWIzJy8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgeGxpbms6aHJlZj0nI2InIGlkPSdkJyB4MT0nMTYnIHgyPSc0OCcgeTE9JzE2JyB5Mj0nNDgnIGdyYWRpZW50VW5pdHM9J3VzZXJTcGFjZU9uVXNlJy8+PC9kZWZzPjxnIHBhaW50LW9yZGVyPSdtYXJrZXJzIHN0cm9rZSBmaWxsJz48Y2lyY2xlIGN4PSczMicgY3k9JzMyJyByPScyOCcgZmlsbD0ndXJsKCNjKScvPjxjaXJjbGUgY3g9JzMyJyBjeT0nMzInIHI9JzI0JyBmaWxsPScjYWVjNWUzJy8+PGNpcmNsZSBjeD0nMzInIGN5PSczMicgcj0nMjAnIGZpbGw9J3VybCgjZCknLz48cGF0aCBmaWxsPScjZmZmJyBkPSdNMjYgNDB2NGgxMnYtNGgtM1YyOGgtOHY0aDJ2OHpNMzUgMTloLTZ2Nmg2eicvPjwvZz48L3N2Zz4=">'; icon = "";
break; break;
case "warning": case "warning":
icon = '<img class="icon" alt="An icon of a yellow triangle with a dark orange exclamation point, indicating a warning or notice" aria-hidden="true" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHhtbG5zOnhsaW5rPSdodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rJyB2aWV3Qm94PScwIDAgNjQgNjQnPjxkZWZzPjxsaW5lYXJHcmFkaWVudCBpZD0nYSc+PHN0b3Agb2Zmc2V0PScwJyBzdG9wLWNvbG9yPScjZTliYjNhJy8+PHN0b3Agb2Zmc2V0PScxJyBzdG9wLWNvbG9yPScjYzE2ODAzJy8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgeGxpbms6aHJlZj0nI2EnIGlkPSdiJyB4MT0nMjAnIHgyPSc1NicgeTE9JzEyJyB5Mj0nNjAnIGdyYWRpZW50VW5pdHM9J3VzZXJTcGFjZU9uVXNlJy8+PC9kZWZzPjxwYXRoIGZpbGw9JyNmZmYnIHN0cm9rZT0ndXJsKCNiKScgc3Ryb2tlLWxpbmVqb2luPSdyb3VuZCcgc3Ryb2tlLXdpZHRoPSc4JyBkPSdNNCA1Nmg1NkwzMiA4WicgcGFpbnQtb3JkZXI9J3N0cm9rZSBtYXJrZXJzIGZpbGwnLz48cGF0aCBmaWxsPScjZjVkODUyJyBkPSdNMTIgNTJoNDBMMzIgMThaJyBwYWludC1vcmRlcj0nbWFya2VycyBmaWxsIHN0cm9rZScvPjxwYXRoIGZpbGw9JyNjNTg3MTEnIGQ9J00yOSA0M3Y2aDZ2LTZ6TTI5IDQwaDZWMjZoLTZ6JyBwYWludC1vcmRlcj0nc3Ryb2tlIG1hcmtlcnMgZmlsbCcvPjwvc3ZnPg==">'; icon = "⚠️";
break; break;
case "error": case "error":
icon = '<img class="icon" alt="An icon of a red circle with a white exclamation point, indicating an error has occurred" aria-hidden="true" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHhtbG5zOnhsaW5rPSdodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rJyB2aWV3Qm94PScwIDAgNjQgNjQnPjxkZWZzPjxsaW5lYXJHcmFkaWVudCBpZD0nYic+PHN0b3Agb2Zmc2V0PScwJyBzdG9wLWNvbG9yPScjZjU4MTYyJy8+PHN0b3Agb2Zmc2V0PScxJyBzdG9wLWNvbG9yPScjZTI1ZjUzJy8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9J2EnPjxzdG9wIG9mZnNldD0nMCcgc3RvcC1jb2xvcj0nI2VjODE2YycvPjxzdG9wIG9mZnNldD0nMScgc3RvcC1jb2xvcj0nI2JmNDMyOScvPjwvbGluZWFyR3JhZGllbnQ+PGxpbmVhckdyYWRpZW50IHhsaW5rOmhyZWY9JyNhJyBpZD0nYycgeDE9JzEyJyB4Mj0nNTInIHkxPScxMicgeTI9JzUyJyBncmFkaWVudFVuaXRzPSd1c2VyU3BhY2VPblVzZScvPjxsaW5lYXJHcmFkaWVudCB4bGluazpocmVmPScjYicgaWQ9J2QnIHgxPScyMCcgeDI9JzQ4JyB5MT0nMTYnIHkyPSc0NCcgZ3JhZGllbnRVbml0cz0ndXNlclNwYWNlT25Vc2UnLz48L2RlZnM+PGNpcmNsZSBjeD0nMzInIGN5PSczMicgcj0nMjgnIGZpbGw9J3VybCgjYyknIHBhaW50LW9yZGVyPSdtYXJrZXJzIHN0cm9rZSBmaWxsJy8+PGNpcmNsZSBjeD0nMzInIGN5PSczMicgcj0nMjQnIGZpbGw9JyNmOWQzY2MnIHBhaW50LW9yZGVyPSdtYXJrZXJzIHN0cm9rZSBmaWxsJy8+PGNpcmNsZSBjeD0nMzInIGN5PSczMicgcj0nMjAnIGZpbGw9J3VybCgjZCknIHBhaW50LW9yZGVyPSdtYXJrZXJzIHN0cm9rZSBmaWxsJy8+PHBhdGggZmlsbD0nI2ZmZicgZD0nTTI5IDE2djIwaDZWMTZaTTI5IDQwdjZoNnYtNnonIHBhaW50LW9yZGVyPSdmaWxsIG1hcmtlcnMgc3Ryb2tlJy8+PC9zdmc+">'; icon = "🛑";
break; break;
} }
document.getElementById(divID).innerHTML = "<p class=\"" + type + "\">" + icon + " " + text + "</p>"; document.getElementById(divID).innerHTML = "<p class=\"" + type + "\">" + icon + " " + text + "</p>";
return;
} }