Files
ToastconcernandClaude Opus 5.5 f7345a5ac3 Frame Studio and Frame Home: cross-platform builds
Frame Studio now builds for Windows (x64, x86, ARM64), Linux (x64, x86, ARM64)
and macOS (Apple Silicon, Intel):
- studio/platform.h with Win32 and POSIX implementations (dialogs through
  tinyfiledialogs on Linux/macOS), a GLFW window beside the Win32 one, and a
  platform-neutral frame pacer
- MoltenVK support in the renderer (portability enumeration) and bundled in
  the macOS app
- 32-bit builds: large-address-aware, LZMA packing within a 32-bit address space
- unaligned file reads in the loaders go through loadUnaligned (no UB)
- scripts/build-studio.sh, studio/build_windows.bat [x64|x86|arm64],
  cmake/llvm-mingw.cmake, and a GitHub Actions workflow building every target

Frame Home installs without Studio: frame/install-frame-home.{sh,ps1} and
"Install Frame Home.bat" (packaged as FrameHome-installer.zip).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:03:25 -05:00

443 lines
23 KiB
C++

#pragma once
#include "core/types.h"
#include <cstring>
#include <cstdlib>
#include <vector>
#include <string>
#include "smolv.h"
#include "loaders/spirv_rehydrate.h"
// RENDSHAD FlatBuffer parser — extracts SPIRV bytecode from shader assets
// Format: "SHAD" magic at +4, root FlatBuffer with vector of shader stages
// Each stage: stage type + SPIRV byte blob
struct SpirvBlob {
u32 stageType = 0;
std::vector<u32> code;
std::string srcName; // shader file this blob came from (for same-program pairing)
bool smolv = false; // decoded from SMOL-V (stripped: names restored once every stage is in)
};
// v210+ homes (renderer_module skybox, community surfaces) ship each stage SMOL-V-encoded:
// a lossless SPIR-V re-encoding whose first u32 is 'SMOL' (bytes "LOMS"), not 0x07230203.
static constexpr u32 kSmolvMagic = 0x534D4F4Cu;
inline bool isSmolvBlob(const u8* p, size_t n) {
u32 m = 0;
if (n < 24) return false;
memcpy(&m, p, 4);
return m == kSmolvMagic;
}
// Decodes one SMOL-V stage blob to SPIR-V words. Fails (and leaves `code` empty) on a
// malformed blob or a decode that does not yield a SPIR-V module.
inline bool decodeSmolvStage(const u8* p, size_t n, std::vector<u32>& code) {
code.clear();
if (!isSmolvBlob(p, n)) return false;
size_t sz = smolv::GetDecodedBufferSize(p, n);
if (sz < 20 || (sz % 4) != 0 || sz > (64u << 20)) return false;
code.resize(sz / 4);
if (!smolv::Decode(p, n, code.data(), sz) || code[0] != 0x07230203u) { code.clear(); return false; }
return true;
}
// Robust primary extractor: scan the whole RENDSHAD blob for SPIR-V modules by magic
// number and split them. A surface shader file packs MANY (vert,frag) variant modules
// back-to-back; the FlatBuffer walk below only reliably finds one of them for some envs
// (e.g. Haven's isotropictiled returned a single blob), so we scan for ALL of them. Each
// module's stage comes from its OpEntryPoint execution model.
inline bool scanSpirvModules(const std::vector<u8>& data, std::vector<SpirvBlob>& out) {
static const u8 magic[4] = {0x03, 0x02, 0x23, 0x07};
std::vector<size_t> pos;
for (size_t p = 0; p + 20 <= data.size(); ++p) {
if (memcmp(data.data() + p, magic, 4) != 0) continue;
u32 version = loadUnaligned<u32>(data.data() + p + 4);
u32 bound = loadUnaligned<u32>(data.data() + p + 12);
// Accept any SPIR-V 1.x version (0x0001xxYY) with a sane id bound.
if ((version >> 16) == 1 && bound > 0 && bound < 1000000) pos.push_back(p);
}
for (size_t k = 0; k < pos.size(); ++k) {
size_t start = pos[k];
size_t maxEnd = (k + 1 < pos.size()) ? pos[k+1] : data.size();
// CRITICAL: the bytes between this module and the next magic include FlatBuffer
// structure (vector padding, offsets, vtables) that is NOT part of the SPIR-V.
// Including it produces an invalid module that creates OK but is REJECTED at
// pipeline creation (VK_ERROR_UNKNOWN). Walk the instruction stream to find the
// module's exact end: stop at the first word that is not a valid instruction
// header (wordCount==0 or an implausible opcode).
const u8* w = data.data() + start; // (a word at a time: the module may start on any byte)
size_t maxW = (maxEnd - start) / 4;
size_t iw = 5; // after the 5-word header
while (iw < maxW) {
u32 word = loadUnaligned<u32>(w + iw * 4), op = word & 0xFFFF, wcnt = word >> 16;
if (wcnt == 0 || op > 600 || iw + wcnt > maxW) break;
iw += wcnt;
}
u32 byteSize = (u32)(iw * 4);
if (byteSize < 200) continue;
SpirvBlob blob;
u32 wc = byteSize / 4;
blob.code.resize(wc);
memcpy(blob.code.data(), data.data() + start, byteSize);
blob.stageType = 0xFFFFFFFFu;
for (size_t wi = 5; wi + 1 < wc && wi < 60; ) {
u32 word = blob.code[wi], op = word & 0xFFFF, w = word >> 16;
if (w == 0) break;
if (op == 15) { blob.stageType = blob.code[wi+1]; break; } // OpEntryPoint exec model
wi += w;
}
// Only keep vertex(0) / fragment(4) modules.
if (blob.stageType == 0 || blob.stageType == 4) out.push_back(std::move(blob));
}
return !out.empty();
}
// FAITHFUL variant selection (reversed from the RENDSHAD format, NOT heuristics): the SHAD FlatBuffer
// holds a `passes` vector [ "forward", "forward_debug", ... ] and a `stages` vector (each stage = a
// SPIR-V byte blob). For COLOUR rendering libshell uses the **forward** pass; "forward_debug" is the
// in-engine texture-visualiser variant (its frag is the LARGEST, which a "pick largest" heuristic wrongly
// grabbed -> wrong/dark output). Stages are laid out 2-per-pass in pass order (vert, frag), so the
// forward pass's modules are stages[2*fwdIdx] (vert) + stages[2*fwdIdx+1] (frag). Extract ONLY those.
inline bool parseRendShadForward(const std::vector<u8>& data, std::vector<SpirvBlob>& out, bool* outTransparent=nullptr,
bool* outAlphaToCoverage=nullptr) {
const u8* d = data.data(); const size_t N = data.size();
auto rd16 = [&](size_t o)->u16{ return (o+2<=N)? loadUnaligned<u16>(d+o):0; };
auto rdi32= [&](size_t o)->i32{ return (o+4<=N)? loadUnaligned<i32>(d+o):0; };
auto rd32 = [&](size_t o)->u32{ return (o+4<=N)? loadUnaligned<u32>(d+o):0; };
auto vtField = [&](size_t tbl, u32 fi)->size_t{
if (tbl+4>N) return 0; i32 so=rdi32(tbl); long long vt=(long long)tbl-so;
if (vt<0||(size_t)vt+4>N) return 0; u16 vs=rd16((size_t)vt); size_t sl=(size_t)vt+4+fi*2;
if (sl+2>(size_t)vt+vs||sl+2>N) return 0; u16 fo=rd16(sl); return fo? tbl+fo:0; };
auto vtNFields = [&](size_t tbl)->u32{
if (tbl+4>N) return 0; i32 so=rdi32(tbl); long long vt=(long long)tbl-so;
if (vt<0||(size_t)vt+4>N) return 0; u16 vs=rd16((size_t)vt); return vs>=4?(vs-4)/2:0; };
auto strAt = [&](size_t p)->std::string{
if (!p||p+4>N) return ""; size_t s=p+rd32(p); u32 ln=rd32(s);
if (ln==0||ln>256||s+4+ln>N) return ""; return std::string((const char*)(d+s+4), ln); };
size_t root = rd32(0); if (root+4>N) return false;
u32 nRoot = vtNFields(root);
// Find the passes vector (vector-of-tables whose element[0] has a string field "forward"...) and the
// stages vector (vector-of-tables whose element has a >500-byte byte-vector = SPIR-V).
size_t passesBase=0; u32 nPasses=0; int fwdIdx=-1;
size_t stagesBase=0; u32 nStages=0;
for (u32 fi=0; fi<nRoot; ++fi) {
size_t fp=vtField(root,fi); if(!fp) continue; u32 uoff=rd32(fp); if(!uoff) continue;
size_t vec=fp+uoff; if(vec+4>N) continue; u32 cnt=rd32(vec); if(cnt==0||cnt>64) continue;
size_t base=vec+4; if(base+(size_t)cnt*4>N) continue;
// element 0
size_t e0=base+rd32(base);
// passes? element has a string field naming a pass
for (u32 ef=0; ef<vtNFields(e0) && ef<4; ++ef) {
std::string s=strAt(vtField(e0,ef));
if (s=="forward"||s=="forward_debug"||s.rfind("forward",0)==0) {
passesBase=base; nPasses=cnt;
// Prefer "forwardSkinned" when present — SKINNED surfaces carry forwardSkinned/forward/_debug
// passes and skinned meshes draw forwardSkinned; loading plain "forward" gave the preview a
// frag the mesh never runs (and missed shadergen's edits, which now land on forwardSkinned).
int fwdPlain=-1, fwdSkin=-1;
for (u32 pi=0; pi<cnt; ++pi){ size_t pe=base+pi*4; size_t pt=pe+rd32(pe);
for (u32 pf=0; pf<vtNFields(pt)&&pf<4; ++pf){ std::string pn=strAt(vtField(pt,pf));
if (pn=="forward"){ fwdPlain=(int)pi; break; }
else if (pn=="forwardSkinned"){ fwdSkin=(int)pi; break; } } }
fwdIdx = (fwdSkin>=0) ? fwdSkin : fwdPlain;
if (std::getenv("HSR_SHADDBG"))
fprintf(stderr, " [PASSPICK] nPasses=%u fwdPlain=%d fwdSkin=%d -> fwdIdx=%d\n", cnt, fwdPlain, fwdSkin, fwdIdx);
break;
}
}
// stages? element has a byte vector that IS a shader module (SPIR-V or SMOL-V). Keep the FIRST match:
// later root fields can also hold large blobs (isotropictiled has 16 fields), and letting them overwrite
// the real stage vector made nStages != 2*nPasses -> the whole program was dropped.
for (u32 ef=0; nStages==0 && ef<vtNFields(e0) && ef<4; ++ef) {
size_t sp=vtField(e0,ef); if(!sp) continue; u32 so=rd32(sp); size_t sv=sp+so;
if (sv+8<=N){ u32 bc=rd32(sv);
if (bc>=24 && bc<2000000 && sv+4+bc<=N && (rd32(sv+4)==0x07230203u || isSmolvBlob(d+sv+4, bc))) { stagesBase=base; nStages=cnt; break; } }
}
}
if (fwdIdx<0 || nStages==0 || nPasses==0) return false;
// Each pass names its own modules: field 7 = vertex module index, field 8 = fragment module index (u16;
// an absent field is FlatBuffers' default 0). Simple surfaces happen to use 2*i / 2*i+1, but multi-pass ones
// (31 passes sharing 61 modules in Cascadia's ibl.surface) do not.
u32 vsIdx = (u32)(2*fwdIdx), fsIdx = (u32)(2*fwdIdx+1);
{
size_t pe = passesBase + (size_t)fwdIdx*4; size_t pt = pe + rd32(pe);
if (vtNFields(pt) > 8) {
size_t vf = vtField(pt, 7), ff = vtField(pt, 8);
vsIdx = vf ? rd16(vf) : 0;
fsIdx = ff ? rd16(ff) : 0;
}
}
if (vsIdx >= nStages || fsIdx >= nStages) return false;
RendShadReflection refl; const bool haveRefl = parseRendShadReflection(data, refl);
auto stageSpirv = [&](u32 si, SpirvBlob& blob)->bool{
if (si>=nStages) return false; size_t se=stagesBase+si*4; size_t st=se+rd32(se);
// Pick the candidate whose data sits LATEST in the file (largest offset): shadergen's edited
// modules (cutout/tint/flipbook) are APPENDED at EOF with the slot repointed — taking the FIRST
// spirv-looking field returned the ORIGINAL un-edited frag, so the preview rendered cooked
// CUTOUT foliage as SOLID QUADS ("flat / transparency incorrectly added") while the device
// (which follows the repointed slot) discarded correctly. Preview must read the same module.
size_t bestStart=0; u32 bestBc=0; bool bestSmolv=false;
for (u32 ef=0; ef<vtNFields(st)&&ef<6; ++ef){ size_t sp=vtField(st,ef); if(!sp) continue;
u32 so=rd32(sp); size_t sv=sp+so; if(sv+4>N) continue; u32 bc=rd32(sv);
if (sv+4+bc>N) continue;
size_t start=sv+4;
bool smol = isSmolvBlob(d+start, bc);
if (!smol && !(bc>500 && (bc%4)==0 && rd32(start)==0x07230203u)) continue; // SPIR-V magic
if (start > bestStart){ bestStart=start; bestBc=bc; bestSmolv=smol; }
}
if (!bestStart) return false;
if (bestSmolv) { if (!decodeSmolvStage(d+bestStart, bestBc, blob.code)) return false; }
else { blob.code.resize(bestBc/4); memcpy(blob.code.data(), d+bestStart, bestBc); }
blob.stageType=0xFFFFFFFFu;
for (size_t wi=5; wi+1<blob.code.size() && wi<60;){ u32 w=blob.code[wi],op=w&0xFFFF,wc=w>>16;
if(wc==0)break; if(op==15){blob.stageType=blob.code[wi+1];break;} wi+=wc; }
if (std::getenv("HSR_SHADDBG")) {
int kills=0; for (size_t wi=5; wi+1<blob.code.size();){ u32 w=blob.code[wi],op=w&0xFFFF,wc=w>>16; if(!wc)break; if(op==252)kills++; wi+=wc; }
fprintf(stderr, " [STAGEPICK] si=%u data@%zu len=%u exec=%u OpKill=%d\n", si, bestStart, bestBc, blob.stageType, kills);
}
return true;
};
// FAITHFUL transparent-pass detection (data-proven on calming, NOT a name heuristic): in the
// RENDSHAD forward pass table, field 4 is PRESENT (=0xFFFFFFFF) for every OPAQUE forward shader
// (isotropictiled*, unlit*, lightmap, rgbmasked, billboard, vegetation…) but OMITTED (default) for
// the special blended-pass shaders — animatedfog + mattepaintingalpha (the "white fog"/"broken matte").
// So f4-omitted = this surface's forward pass is alpha-blended. (Other transparents — wateroverlay/
// waterflipbook/unlitalpha — share the OPAQUE pass-state; their blend comes from the MATL render-queue,
// still handled by the name rules in scene_loader. This only ADDS blend, never removes it.)
{
size_t pe2 = passesBase + (size_t)fwdIdx*4; size_t pt2 = pe2 + rd32(pe2);
// Forward-pass field 2 is the blend state: PRESENT (0xFFFFFFFF) on every opaque surface, OMITTED on every
// blended one. Matches HSR Studio's sortClass for every pipeline of Calming (fog, matte, water flipbook /
// overlay, unlitalpha) and the converted Cascadia (clouds, backdrop, flame, petals, water, iblblend,
// shellenvblend). Field 4 is NOT blend: animvegepacked and unlitdoublesidedskinned omit it and are opaque
// (the old field-4 rule drew foliage alpha-blended: unsorted, no depth writes).
if (outTransparent) *outTransparent = (vtField(pt2,2) == 0);
// Fields 4 AND 5 omitted on an opaque pass = cut-out foliage drawn with alpha-to-coverage (HSR Studio's
// alphaToCoverage: Calming's animvegepacked; desert-terrace's palmtreefoliage). Field 4 alone
// (unlitdoublesidedskinned) is not.
if (outAlphaToCoverage) *outAlphaToCoverage = vtField(pt2,2) != 0 && vtField(pt2,4) == 0 && vtField(pt2,5) == 0;
}
if (std::getenv("HSR_SHADDBG")) { // dump the forward pass table fields (find the blend/pipeline-state field)
size_t pe2 = passesBase + (size_t)fwdIdx*4; size_t pt2 = pe2 + rd32(pe2);
u32 nf = vtNFields(pt2);
fprintf(stderr, " [SHADDBG] fwd pass tbl @%zu nFields=%u:", pt2, nf);
for (u32 ff=0; ff<nf; ++ff) {
size_t fp = vtField(pt2, ff);
if (!fp) { fprintf(stderr, " f%u=-", ff); continue; }
std::string s = strAt(fp); u32 vv = rd32(fp);
if (!s.empty()) fprintf(stderr, " f%u='%s'", ff, s.c_str());
else {
// maybe a sub-table (offset to a vtable within bounds): dump its scalar fields
size_t sub = fp + vv; u32 snf = (vv && sub<N) ? vtNFields(sub) : 0;
if (snf>0 && snf<=16) { fprintf(stderr, " f%u={", ff);
for (u32 sf=0; sf<snf; ++sf){ size_t sfp=vtField(sub,sf); fprintf(stderr,"%s%u", sf?",":"", sfp?rd32(sfp):0u); }
fprintf(stderr, "}"); }
else fprintf(stderr, " f%u=%u", ff, vv);
}
}
fprintf(stderr, "\n");
}
SpirvBlob v,f;
if (!stageSpirv(vsIdx, v) || !stageSpirv(fsIdx, f)) return false;
if (haveRefl) { // v210 stripped modules: restore OpName/OpMemberName
spirvSet2ImageBindings(v.code, refl.imageBindings);
spirvSet2ImageBindings(f.code, refl.imageBindings);
rehydrateSpirvNames(v.code, refl);
rehydrateSpirvNames(f.code, refl);
}
// order may be (vert,frag) or (frag,vert) — keep both, tagged by their own exec model
out.push_back(std::move(v)); out.push_back(std::move(f));
return out.size()==2;
}
inline bool parseRendShad(const std::vector<u8>& data, std::vector<SpirvBlob>& out) {
if (data.size() < 48) return false;
if (memcmp(data.data() + 4, "SHAD", 4) != 0) return false;
// FAITHFUL: select the `forward` (colour) pass variant from the RENDSHAD passes table.
if (!std::getenv("HSR_ALLVARIANTS") && parseRendShadForward(data, out)) return true;
out.clear();
// Fallback: scan for all SPIR-V modules (handles multi-variant surface shaders).
if (scanSpirvModules(data, out)) return true;
// Legacy FlatBuffer walk. Offsets are file data: positions in u64, every read bounds-checked (a bad
// read yields 0, which the walk treats as "field absent").
const u64 SZ = data.size();
auto rd16 = [&](u64 o) -> u16 { if (o + 2 > SZ) return 0; u16 v; memcpy(&v, data.data() + o, 2); return v; };
auto rd32 = [&](u64 o) -> u32 { if (o + 4 > SZ) return 0; u32 v; memcpy(&v, data.data() + o, 4); return v; };
auto vtOf = [&](u64 tablePos) -> u64 { // vtable position, or SZ (unreadable) when out of range
if (tablePos + 4 > SZ) return SZ;
const int64_t vt = (int64_t)tablePos - (int64_t)(i32)rd32(tablePos);
return vt < 0 || (u64)vt + 4 > SZ ? SZ : (u64)vt;
};
u32 rootOff = rd32(0);
auto fieldFo = [&](u32 tablePos, u32 fi) -> u16 {
const u64 vt = vtOf(tablePos);
if (vt + 4 > SZ) return 0;
u16 vtSize = rd16(vt);
u64 idx = vt + 4 + (u64)fi * 2;
if (idx + 2 > vt + vtSize) return 0;
return rd16(idx);
};
auto followVec = [&](u32 tablePos, u32 fi, u32& baseOut, u32& countOut) -> bool {
u16 fo = fieldFo(tablePos, fi);
if (!fo) return false;
u64 fieldSlot = (u64)tablePos + fo;
if (fieldSlot + 4 > SZ) return false;
u32 uoff = rd32(fieldSlot);
if (!uoff) return false;
u64 vecPos = fieldSlot + uoff;
if (vecPos + 4 > SZ) return false;
countOut = rd32(vecPos);
baseOut = (u32)(vecPos + 4);
return true;
};
auto followElem = [&](u32 elemBase, u32 i, u32& tableOut) -> bool {
u64 slot = (u64)elemBase + (u64)i * 4;
if (slot + 4 > SZ) return false;
u32 uoff = rd32(slot);
if (!uoff || slot + uoff + 4 > SZ) return false;
tableOut = (u32)(slot + uoff);
return true;
};
if (SZ > 0xFFFFFFF0u) return false; // offsets below are u32
const u64 rootVt = vtOf(rootOff);
u16 rootVtSize = rd16(rootVt);
u32 nRootFields = rootVtSize >= 4 ? (rootVtSize - 4) / 2 : 0;
// Try each field that might contain shader stages
// Based on libshell decomp: shader stages are in a vector field
for (u32 fi = 0; fi < nRootFields && out.empty(); ++fi) {
u32 base, count;
if (!followVec(rootOff, fi, base, count) || count == 0) continue;
if (count > 16) continue;
for (u32 i = 0; i < count; ++i) {
u32 elemTable;
if (!followElem(base, i, elemTable)) continue;
// Element vtable analysis
u16 elemVtSize = rd16(vtOf(elemTable));
u32 nElemFields = elemVtSize >= 4 ? (elemVtSize - 4) / 2 : 0;
// Try to find stage type and SPIRV data
u32 stageType = 0;
// Read stage type from field[2] (common for shader stages)
u16 stFo = fieldFo(elemTable, 2);
if (stFo) {
stageType = rd32((u64)elemTable + stFo);
}
// Find SPIRV byte vector — try multiple fields
u32 spirvStart = 0, spirvCount = 0;
// For shader stages in libshell, the SPIRV is typically in fields 4, 5, or 3
// Try byte vectors first (uoffset to byte blob)
for (u32 bf = 0; bf < nElemFields && spirvCount == 0; ++bf) {
u16 bfo = fieldFo(elemTable, bf);
if (!bfo) continue;
u64 fieldSlot = (u64)elemTable + bfo;
u32 uoff = rd32(fieldSlot);
if (!uoff || uoff > SZ) continue;
u64 vecPos = fieldSlot + uoff;
if (vecPos + 4 > SZ) continue;
u32 cnt = rd32(vecPos);
// Heuristic: SPIRV byte vector is typically > 500 bytes
if (cnt > 500 && cnt < 1048576 && vecPos + 4 + cnt <= SZ) {
spirvStart = (u32)(vecPos + 4);
spirvCount = cnt;
}
}
if (spirvCount >= 500 && isSmolvBlob(data.data() + spirvStart, spirvCount)) {
// SMOL-V stage: decode, and take the stage from the module's own OpEntryPoint
// (field[2] is not a stage enum in these tables).
SpirvBlob blob;
if (!decodeSmolvStage(data.data() + spirvStart, spirvCount, blob.code)) continue;
blob.smolv = true;
blob.stageType = 0xFFFFFFFFu;
for (size_t wi = 5; wi + 1 < blob.code.size() && wi < 60; ) {
u32 w = blob.code[wi], op = w & 0xFFFF, wc = w >> 16;
if (wc == 0) break;
if (op == 15) { blob.stageType = blob.code[wi + 1]; break; }
wi += wc;
}
if (blob.stageType == 0 || blob.stageType == 4) out.push_back(std::move(blob));
} else if (spirvCount >= 500) {
SpirvBlob blob;
blob.stageType = stageType;
u32 wordCount = spirvCount / 4;
blob.code.resize(wordCount);
memcpy(blob.code.data(), data.data() + spirvStart, (size_t)wordCount * 4); // whole words only: a non-%4 vector overran the buffer
out.push_back(std::move(blob));
}
}
}
{ // v210 stripped SMOL-V modules: restore names, texture slots ranked over every stage of the program
RendShadReflection refl;
if (parseRendShadReflection(data, refl)) {
for (auto& b : out) if (b.smolv) spirvSet2ImageBindings(b.code, refl.imageBindings);
for (auto& b : out) if (b.smolv) rehydrateSpirvNames(b.code, refl);
}
}
// Fallback: direct SPIRV magic scanning (used when FlatBuffer structure can't be parsed)
if (out.empty()) {
const u8 magic[] = {0x03, 0x02, 0x23, 0x07};
std::vector<size_t> positions;
for (size_t pos = 0; pos + 4 <= data.size(); ++pos) {
if (memcmp(data.data() + pos, magic, 4) == 0) {
// Verify SPIRV header
if (pos + 20 <= data.size()) {
u32 version = loadUnaligned<u32>(data.data() + pos + 4);
u32 bound = loadUnaligned<u32>(data.data() + pos + 12);
if (version == 0x00010000 && bound > 0 && bound < 100000) {
positions.push_back(pos);
}
}
}
}
for (size_t pi = 0; pi < positions.size(); ++pi) {
size_t pos = positions[pi];
size_t endPos = (pi + 1 < positions.size()) ? positions[pi+1] : data.size();
u32 byteSize = (u32)(endPos - pos);
if (byteSize >= 500 && byteSize < 1048576) {
SpirvBlob blob;
// Determine stage from SPIRV header before extracting
// OpEntryPoint is typically at word 5-15
u32 wordCount = byteSize / 4;
blob.code.resize(wordCount);
memcpy(blob.code.data(), data.data() + pos, (size_t)wordCount * 4);
// Find execution model from SPIRV header
for (size_t wi = 5; wi < wordCount && wi < 30; ) {
u32 word = blob.code[wi];
u32 op = word & 0xFFFF;
u32 wc = word >> 16;
if (wc == 0) break;
if (op == 15) { // OpEntryPoint
if (wi + 1 < wordCount) {
blob.stageType = blob.code[wi + 1];
}
break;
}
wi += wc;
}
out.push_back(std::move(blob));
}
}
}
return !out.empty();
}