Fetch character icons from SI files via HTTP Range requests

Replace static webp character icons with runtime extraction from
INFOMAIN.SI, extending the bitmap manifest to support multiple SI files.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
Christian Semmler 2026-02-08 16:40:35 -08:00
parent 38ffd73236
commit 8734df9fee
12 changed files with 81 additions and 40 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 918 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1018 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 964 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1016 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 876 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 986 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 980 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 844 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 946 B

View File

@ -145,14 +145,24 @@ const TEXTURES = [
['rctail4', 'Scripts/Build/RACECAR.SI', 134, 4236, '614cb9aa532ee85c119cc1432e6d65e9'], ['rctail4', 'Scripts/Build/RACECAR.SI', 134, 4236, '614cb9aa532ee85c119cc1432e6d65e9'],
]; ];
// [name, objectId, size, md5] — all in ISLE.SI // [name, siFile, objectId, size, md5]
const BITMAPS = [ const BITMAPS = [
['globe1', 1130, 5824, '12554d2a7d38bdc0e6bc1709f8404293'], ['globe1', 'Scripts/Isle/ISLE.SI', 1130, 5824, '12554d2a7d38bdc0e6bc1709f8404293'],
['globe2', 1131, 5824, 'b0b39a4b959b4bf1605a6c695d9e3dd0'], ['globe2', 'Scripts/Isle/ISLE.SI', 1131, 5824, 'b0b39a4b959b4bf1605a6c695d9e3dd0'],
['globe3', 1132, 5824, '71672bff19044f7df059c87a0759d950'], ['globe3', 'Scripts/Isle/ISLE.SI', 1132, 5824, '71672bff19044f7df059c87a0759d950'],
['globe4', 1133, 5824, 'f5421e06ae9997d9cbfc774942f097d4'], ['globe4', 'Scripts/Isle/ISLE.SI', 1133, 5824, 'f5421e06ae9997d9cbfc774942f097d4'],
['globe5', 1134, 5824, '47974b0577cab1c3775175eab074e5b5'], ['globe5', 'Scripts/Isle/ISLE.SI', 1134, 5824, '47974b0577cab1c3775175eab074e5b5'],
['globe6', 1135, 5824, '65237421063fa993167ed4af9be9180c'], ['globe6', 'Scripts/Isle/ISLE.SI', 1135, 5824, '65237421063fa993167ed4af9be9180c'],
['pepper', 'Scripts/Infocntr/INFOMAIN.SI', 80, 2904, '4143f58632135089b7bc695bff406077'],
['pepper-selected', 'Scripts/Infocntr/INFOMAIN.SI', 81, 2904, '01f57a3a32f7aea6bc48a78a8c95ee1b'],
['mama', 'Scripts/Infocntr/INFOMAIN.SI', 76, 2904, 'ad68ae8fe78c368cac026ae09f1ad8c4'],
['mama-selected', 'Scripts/Infocntr/INFOMAIN.SI', 77, 2904, '72f041d1080b20f713d39f7ae00d966d'],
['papa', 'Scripts/Infocntr/INFOMAIN.SI', 78, 2904, '0cd5ef1c6d68198862c102b36b2f04fe'],
['papa-selected', 'Scripts/Infocntr/INFOMAIN.SI', 79, 2904, 'ec7d3d87d796dd824dfd5110911d4aa4'],
['nick', 'Scripts/Infocntr/INFOMAIN.SI', 82, 2904, 'c29ecdce0ffae81a71b973b8af26c26c'],
['nick-selected', 'Scripts/Infocntr/INFOMAIN.SI', 83, 2904, '8db4e63632c6f90e543832e6c92c89fa'],
['laura', 'Scripts/Infocntr/INFOMAIN.SI', 84, 2904, '99abd3415870285d487da20882f3bbf3'],
['laura-selected', 'Scripts/Infocntr/INFOMAIN.SI', 85, 2904, 'f56c2efb4f744d306d5a3d4ac8d332ca'],
]; ];
const MXCH_SIGNATURE = Buffer.from('MxCh'); const MXCH_SIGNATURE = Buffer.from('MxCh');
@ -300,20 +310,30 @@ async function main() {
} }
console.log(` ${texFound}/${TEXTURES.length} textures found\n`); console.log(` ${texFound}/${TEXTURES.length} textures found\n`);
// --- Bitmaps (in ISLE.SI) --- // --- Bitmaps (across SI files) ---
const bmpObjectIds = new Set(BITMAPS.map(([, objectId]) => objectId)); const bmpBySI = new Map();
const bmpRanges = findMxChByObjectId(isleSI, bmpObjectIds); for (const entry of BITMAPS) {
const siFile = entry[1];
if (!bmpBySI.has(siFile)) bmpBySI.set(siFile, []);
bmpBySI.get(siFile).push(entry);
}
let bmpFound = 0; let bmpFound = 0;
for (const [name, objectId, size, expectedMd5] of BITMAPS) { for (const [siFile, entries] of bmpBySI) {
const result = verifyRanges(isleSI, bmpRanges.get(objectId), size, expectedMd5); const siBuf = await loadSI(siFile);
if (result) { const objectIds = new Set(entries.map(([, , objectId]) => objectId));
manifest.bitmaps[name] = formatResult('Scripts/Isle/ISLE.SI', result); const bmpRanges = findMxChByObjectId(siBuf, objectIds);
bmpFound++;
found++; for (const [name, , objectId, size, expectedMd5] of entries) {
} else { const result = verifyRanges(siBuf, bmpRanges.get(objectId), size, expectedMd5);
console.error(` FAILED: ${name} (objectId ${objectId})`); if (result) {
failed++; manifest.bitmaps[name] = formatResult(siFile, result);
bmpFound++;
found++;
} else {
console.error(` FAILED: ${name} (objectId ${objectId})`);
failed++;
}
} }
} }
console.log(` ${bmpFound}/${BITMAPS.length} bitmaps found\n`); console.log(` ${bmpFound}/${BITMAPS.length} bitmaps found\n`);

View File

@ -1,5 +1,5 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import BackButton from './BackButton.svelte'; import BackButton from './BackButton.svelte';
import Carousel from './Carousel.svelte'; import Carousel from './Carousel.svelte';
import MissionScoresEditor from './save-editor/MissionScoresEditor.svelte'; import MissionScoresEditor from './save-editor/MissionScoresEditor.svelte';
@ -7,6 +7,7 @@
import LightPositionEditor from './save-editor/LightPositionEditor.svelte'; import LightPositionEditor from './save-editor/LightPositionEditor.svelte';
import VehicleEditor from './save-editor/VehicleEditor.svelte'; import VehicleEditor from './save-editor/VehicleEditor.svelte';
import ActorEditor from './save-editor/ActorEditor.svelte'; import ActorEditor from './save-editor/ActorEditor.svelte';
import { fetchBitmapAsURL } from '../core/assetLoader.js';
import { saveEditorState, currentPage } from '../stores.js'; import { saveEditorState, currentPage } from '../stores.js';
import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js'; import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js';
import { Actor, ActorNames } from '../core/savegame/constants.js'; import { Actor, ActorNames } from '../core/savegame/constants.js';
@ -43,20 +44,36 @@
let currentAct = 0; let currentAct = 0;
let actorId = 1; let actorId = 1;
// Character icons mapping // Character icons — loaded from SI file bitmaps
const characterIcons = { const iconNames = ['pepper', 'mama', 'papa', 'nick', 'laura'];
[Actor.PEPPER]: { normal: 'images/pepper.webp', selected: 'images/pepper-selected.webp' }, let characterIcons = {};
[Actor.MAMA]: { normal: 'images/mama.webp', selected: 'images/mama-selected.webp' }, let iconUrls = [];
[Actor.PAPA]: { normal: 'images/papa.webp', selected: 'images/papa-selected.webp' },
[Actor.NICK]: { normal: 'images/nick.webp', selected: 'images/nick-selected.webp' },
[Actor.LAURA]: { normal: 'images/laura.webp', selected: 'images/laura-selected.webp' }
};
// Carousel state (bound from Carousel component) // Carousel state (bound from Carousel component)
let carouselHasDragged = false; let carouselHasDragged = false;
onMount(async () => { onMount(async () => {
await loadSlots(); await loadSlots();
// Load character icons from SI file in background
const urls = await Promise.all(iconNames.flatMap(name => [
fetchBitmapAsURL(name),
fetchBitmapAsURL(`${name}-selected`)
]));
iconUrls = urls;
characterIcons = {
[Actor.PEPPER]: { normal: urls[0], selected: urls[1] },
[Actor.MAMA]: { normal: urls[2], selected: urls[3] },
[Actor.PAPA]: { normal: urls[4], selected: urls[5] },
[Actor.NICK]: { normal: urls[6], selected: urls[7] },
[Actor.LAURA]: { normal: urls[8], selected: urls[9] }
};
});
onDestroy(() => {
for (const url of iconUrls) {
if (url) URL.revokeObjectURL(url);
}
}); });
async function loadSlots() { async function loadSlots() {
@ -272,12 +289,14 @@
class:selected={selectedSlot === slot.slotNumber} class:selected={selectedSlot === slot.slotNumber}
onclick={() => handleSlotSelect(slot.slotNumber)} onclick={() => handleSlotSelect(slot.slotNumber)}
> >
<img {#if characterIcons[slot.header?.actorId]?.selected}
src={characterIcons[slot.header?.actorId]?.selected || 'images/pepper-selected.webp'} <img
alt={ActorNames[slot.header?.actorId] || 'Character'} src={characterIcons[slot.header?.actorId].selected}
class="slot-character-icon" alt={ActorNames[slot.header?.actorId] || 'Character'}
draggable="false" class="slot-character-icon"
/> draggable="false"
/>
{/if}
<span class="slot-name">{slot.playerName}</span> <span class="slot-name">{slot.playerName}</span>
</button> </button>
{/each} {/each}
@ -358,12 +377,14 @@
onclick={() => handleActorSelect(actor.id)} onclick={() => handleActorSelect(actor.id)}
title={actor.name} title={actor.name}
> >
<img {#if characterIcons[actor.id]}
src={actorId === actor.id <img
? characterIcons[actor.id].selected src={actorId === actor.id
: characterIcons[actor.id].normal} ? characterIcons[actor.id].selected
alt={actor.name} : characterIcons[actor.id].normal}
/> alt={actor.name}
/>
{/if}
</button> </button>
{/each} {/each}
</div> </div>