mirror of
https://github.com/Toastconcern/FrameStudio.git
synced 2026-10-29 04:02:56 +01:00
Frame Studio - Gaussian splats (PLY, SPZ v2-v4), PLY vertex colours, panorama skies - Copy, cut, paste and duplicate for objects, splats and world items - Walkable-area rebake moved off the UI thread; viewport one frame sooner; refresh-locked pacing (FS_PACE=timer for the old one) - Fixed: bundle thumbnail use-after-free crash, auto-save deleting environments, saves into scratch copies, edits lost on non-ASCII paths, unopenable manifests with inf/nan, bundle save data loss, opening a home marking it changed, gizmo NaN, long paths, many malformed-file crashes - Updater verifies sha256, journals the swap and never leaves the exe missing Frame Home - Seats turn you to face their way - Draw batching (Hand Tutorials up to ~20% less GPU time), cut-out EQUAL pass, late head-pose read before submit - Proper queue-family hand-over to the compositor layer; layer descriptor generations and seqlocked slots; structural SPIR-V check - Fixed: hangs/crashes on damaged .fenv files and shaders, render-pass validation error, sync hazard in the foveation smoothing pass, unverified update downloads, half-done installs Tests: tests_loco/env_share/spv_check wired into CMake (FRAME_TESTS) and CI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsaXGU27gsrXjDoFtyFJP9
2403 lines
165 KiB
C++
2403 lines
165 KiB
C++
#pragma once
|
||
#include "core/types.h"
|
||
#include <array>
|
||
#include "core/load_progress.h" // live stage/counter for the loading splash
|
||
#include "core/world.h" // the world layer (spawns, seats, colliders) collected while walking the HSTFs
|
||
#include "loaders/asmh_parser.h"
|
||
#include "loaders/rendmesh_parser.h"
|
||
#include "loaders/rendtxtr_parser.h"
|
||
#include "loaders/matlmatl_parser.h"
|
||
#include "loaders/hstf_parser.h"
|
||
#include "loaders/rendshad_parser.h" // parseRendShadForward -> faithful transparent-pass (f4-omitted) detection
|
||
#include "loaders/zip_reader_guard.h"
|
||
#include "loaders/scene_zip.h"
|
||
|
||
#include "miniz.h"
|
||
|
||
#include <string>
|
||
#include <vector>
|
||
#include <cstdio>
|
||
#include <cstring>
|
||
#include <cstdarg>
|
||
#include <algorithm>
|
||
#include <functional>
|
||
#include <set>
|
||
#include <map>
|
||
#include <cctype>
|
||
#include <cmath>
|
||
|
||
|
||
// ──────────────────────────────────────────────────────────────
|
||
// Scene Loader — mirrors libshell.so HSR environment loading
|
||
// ──────────────────────────────────────────────────────────────
|
||
|
||
#include "loaders/rendskel_parser.h"
|
||
class SceneLoader {
|
||
public:
|
||
std::vector<MeshData> meshes;
|
||
// V203 IBL: captured EQUIRECT reflection panorama (RGBA8) from the skybox reflectionMap / assets/ibl/ —
|
||
// gated by HSR_SKYIBL. main.cpp feeds it to vkRenderer.setIblEquirectRGBA8() for the SpecIbl per-vertex bake.
|
||
std::vector<u8> iblEquirectRGBA8; u32 iblEqW = 0, iblEqH = 0;
|
||
bool verbose = true;
|
||
// ── Baked-lightmap binding (the warm baked room lighting), DEVICE-FAITHFUL via the override chain:
|
||
// lmGuidName : meshGUID(13) -> mesh name (cooked USD templates *.usda/template_9k0v)
|
||
// lmGuidRaw : meshGUID(13) -> raw lightmap (calming_lightmap_overrides/*_lm.hstf deltas:
|
||
// sourceId -> MeshPlatformComponent.lightingParams.lightMapTexture asset, readAsset'd)
|
||
// Combine via GUID -> {mesh name -> baked lightmap} (incl shared merge-group atlases). Both maps cached
|
||
// at load (scene.zip is freed after). Cross-loader: vista ships overrides+lightmaps, home ships USD
|
||
// names+meshes -> pass the companion. lmRawByKey = stem name-match FALLBACK (envs without override HSTFs).
|
||
std::map<std::string, std::vector<u8>> lmRawByKey;
|
||
std::map<std::string, std::string> lmGuidName;
|
||
std::map<std::string, std::vector<u8>> lmGuidRaw;
|
||
std::map<std::string, std::array<float,4>> lmGuidUv; // meshGUID(13) -> {lightmapUvScale.xy, lightmapUvOffset.xy}
|
||
// HSTF deltas ({sourceId, components}) carrying MaterialPropertyOverridesPlatformComponent, keyed like
|
||
// lmGuidRaw by the target entity's GUID(13): the home's per-object constants (layer multipliers, tints...)
|
||
// and textures (LightMap_Tx, ...). HSR Studio applies these over the material; without them the ceiling
|
||
// lost its layer colours and its lightmap.
|
||
std::map<std::string, std::vector<MatOverride>> deltaConsts;
|
||
std::map<std::string, decltype(MeshData::namedTex)> deltaTex; // sampler hash
|
||
std::map<std::string, std::vector<u8>> deltaLightmap; // raw lmhdr
|
||
float sceneNearClip = 0.f, sceneFarClip = 0.f; // device finite near/far clip planes (space.hstf ScenePlatformComponent)
|
||
|
||
void applyLightmapOverrides(std::vector<MeshData>& targets, SceneLoader* other = nullptr) {
|
||
auto lower = [](std::string s){ for (auto& c : s) c = (char)tolower((unsigned char)c); return s; };
|
||
// Each lightmap (often one atlas shared by many meshes) is decoded once, to linear HDR half floats.
|
||
struct Decoded { std::shared_ptr<const std::vector<u16>> half; std::shared_ptr<const TexSource> src; u32 w = 0, h = 0; };
|
||
std::map<std::pair<size_t,u64>, Decoded> decoded;
|
||
auto bind = [&](MeshData& md, const std::vector<u8>& raw, const std::array<float,4>* uv = nullptr) -> bool {
|
||
u64 hsh = 1469598103934665603ull;
|
||
for (size_t i = 0, step = std::max<size_t>(1, raw.size() / 8192); i < raw.size(); i += step) hsh = (hsh ^ raw[i]) * 1099511628211ull;
|
||
auto key = std::make_pair(raw.size(), hsh);
|
||
auto it = decoded.find(key);
|
||
if (it == decoded.end()) {
|
||
Decoded d;
|
||
RendtxtrInfo ti;
|
||
auto half = std::make_shared<std::vector<u16>>();
|
||
if (parseRendtxtrHeader(raw, ti)) {
|
||
if (g_deferTextures && (d.src = hdrAstcSource(raw, ti))) { d.w = ti.width; d.h = ti.height; }
|
||
else if (astc::decodeASTCHalf(raw.data()+ti.rawDataOffset, ti.rawDataLen, ti.width, ti.height, ti.blockW, ti.blockH, *half)) {
|
||
d.half = half; d.w = ti.width; d.h = ti.height;
|
||
}
|
||
}
|
||
it = decoded.emplace(key, d).first;
|
||
}
|
||
if (!it->second.half && !it->second.src) return false;
|
||
md.lmHalf = it->second.half; md.lmSrc = it->second.src; md.lmW = it->second.w; md.lmH = it->second.h;
|
||
md.hasLightmap = true; md.bakeLightmapVtx = true;
|
||
if (uv) std::memcpy(md.lmUvScaleOffset, uv->data(), sizeof md.lmUvScaleOffset);
|
||
return true;
|
||
};
|
||
// FAITHFUL: meshGUID -> name (this+other) crossed with meshGUID -> raw lightmap (this+other).
|
||
std::map<std::string, const std::vector<u8>*> nameRaw;
|
||
std::map<std::string, const std::array<float,4>*> nameUv;
|
||
auto nameOf = [&](const std::string& g) -> const std::string* {
|
||
auto it = lmGuidName.find(g); if (it != lmGuidName.end()) return &it->second;
|
||
if (other) { auto j = other->lmGuidName.find(g); if (j != other->lmGuidName.end()) return &j->second; }
|
||
return nullptr; };
|
||
auto addRaw = [&](SceneLoader& src) {
|
||
for (auto& kv : src.lmGuidRaw) {
|
||
const std::string* nm = nameOf(kv.first);
|
||
if (!nm || kv.second.empty()) continue;
|
||
nameRaw[lower(*nm)] = &kv.second;
|
||
auto uv = src.lmGuidUv.find(kv.first);
|
||
if (uv != src.lmGuidUv.end()) nameUv[lower(*nm)] = &uv->second;
|
||
}
|
||
};
|
||
addRaw(*this); if (other) addRaw(*other);
|
||
int bound = 0;
|
||
for (auto& md : targets) {
|
||
if (md.hasLightmap || md.uvs2.empty()) continue;
|
||
auto it = nameRaw.find(lower(md.name));
|
||
if (it == nameRaw.end()) continue;
|
||
auto uv = nameUv.find(lower(md.name));
|
||
if (bind(md, *it->second, uv != nameUv.end() ? uv->second : nullptr)) ++bound;
|
||
}
|
||
if (bound && verbose) log(" [LM-faithful] bound %d baked lightmaps via override GUID chain", bound);
|
||
// Material overrides from deltas (this loader's and the companion's), by the target entity's name.
|
||
int dc = 0, dt = 0, dl = 0;
|
||
auto applyDeltas = [&](SceneLoader& src) {
|
||
std::map<std::string, std::vector<MeshData*>> byName;
|
||
for (auto& md : targets) byName[lower(md.name)].push_back(&md);
|
||
auto each = [&](const std::string& g, auto&& fn) {
|
||
const std::string* nm = nameOf(g);
|
||
if (!nm) { auto j = src.lmGuidName.find(g); if (j != src.lmGuidName.end()) nm = &j->second; }
|
||
if (!nm) return;
|
||
auto it = byName.find(lower(*nm));
|
||
if (it != byName.end()) for (MeshData* md : it->second) fn(*md);
|
||
};
|
||
for (auto& kv : src.deltaConsts)
|
||
each(kv.first, [&](MeshData& md) { md.matOverrides.insert(md.matOverrides.end(), kv.second.begin(), kv.second.end()); ++dc; });
|
||
for (auto& kv : src.deltaTex)
|
||
each(kv.first, [&](MeshData& md) {
|
||
for (auto& t : kv.second) {
|
||
auto at = std::find_if(md.namedTex.begin(), md.namedTex.end(), [&](const auto& e) { return e.first == t.first; });
|
||
if (at != md.namedTex.end()) at->second = t.second; else md.namedTex.push_back(t);
|
||
}
|
||
++dt;
|
||
});
|
||
for (auto& kv : src.deltaLightmap)
|
||
each(kv.first, [&](MeshData& md) { md.hasLightmap = false; if (bind(md, kv.second)) ++dl; });
|
||
};
|
||
applyDeltas(*this);
|
||
if (other) applyDeltas(*other);
|
||
if ((dc || dt || dl) && verbose) log(" [deltas] material overrides: %d constant sets, %d texture sets, %d lightmaps", dc, dt, dl);
|
||
// FALLBACK: stem name-match (this loader's lmRawByKey) for envs lacking override HSTFs.
|
||
int fb = 0;
|
||
if (!lmRawByKey.empty()) for (auto& md : targets) {
|
||
if (md.hasLightmap || md.uvs2.empty()) continue;
|
||
std::string mk = lower(md.name);
|
||
if (mk.rfind("root_xform_",0)==0) mk = mk.substr(11);
|
||
{ size_t l=mk.find("_lod"); if (l!=std::string::npos) mk=mk.substr(0,l); }
|
||
for (bool ch=true; ch;){ ch=false; for (std::string pre : {std::string("sm_"),std::string("lbg_"),std::string("merged_"),std::string("inst_")}) if (mk.rfind(pre,0)==0){ mk=mk.substr(pre.size()); ch=true; } }
|
||
const std::vector<u8>* raw=nullptr; auto it=lmRawByKey.find(mk); if (it!=lmRawByKey.end()) raw=&it->second;
|
||
if (!raw) for (auto& kv : lmRawByKey){ const std::string& k=kv.first; if ((k.size()>3&&mk.find(k)!=std::string::npos)||(mk.size()>3&&k.find(mk)!=std::string::npos)){ raw=&kv.second; break; } }
|
||
if (raw && bind(md, *raw)) ++fb;
|
||
}
|
||
if (fb && verbose) log(" [LM-namematch] bound %d via stem fallback", fb);
|
||
// ORM MERGE-GROUP fallback (faithful): a mesh whose own name misses its lightmap shares a baked
|
||
// merge-group with its ORM/rbaodir texture — the rbaodir map and the lmhdr lightmap for a baked
|
||
// mesh-group carry the same group stem. The haven ceiling is the case: mesh "Root_Xform_SM_LBG_cieling"
|
||
// (misspelled) + ORM "t_merged_ceilingtrim_rbaodir" + lightmap "t_sm_merged_ceilingtrim_..._lmhdr" are
|
||
// ONE group, so match the lightmap by the ORM's group stem when the mesh name fails.
|
||
int fg = 0;
|
||
if (!lmRawByKey.empty()) for (auto& md : targets) {
|
||
if (md.hasLightmap || md.uvs2.empty() || md.ormTexName.empty()) continue;
|
||
std::string base = lower(md.ormTexName);
|
||
size_t ext = base.find(".png"); if (ext==std::string::npos) ext = base.find(".hdr");
|
||
if (ext==std::string::npos) continue;
|
||
size_t st = base.rfind('/', ext); st = (st==std::string::npos)?0:st+1;
|
||
base = base.substr(st, ext-st); // t_merged_ceilingtrim_rbaodir
|
||
for (std::string pre : {std::string("t_sm_"),std::string("t_")}) if (base.rfind(pre,0)==0){ base=base.substr(pre.size()); break; }
|
||
for (std::string suf : {std::string("_rbaodir"),std::string("_onxrny"),std::string("_orm"),std::string("_nx"),std::string("_normal"),std::string("_basecolor")}) { size_t f=base.rfind(suf); if (f!=std::string::npos){ base=base.substr(0,f); break; } }
|
||
for (bool ch=true; ch;){ ch=false; for (std::string pre : {std::string("sm_"),std::string("lbg_"),std::string("merged_")}) if (base.rfind(pre,0)==0){ base=base.substr(pre.size()); ch=true; } } // -> ceilingtrim
|
||
if (base.size()<4) continue;
|
||
const std::vector<u8>* raw=nullptr; auto it=lmRawByKey.find(base); if (it!=lmRawByKey.end()) raw=&it->second;
|
||
if (!raw) for (auto& kv : lmRawByKey){ const std::string& k=kv.first; if ((k.size()>3&&base.find(k)!=std::string::npos)||(base.size()>3&&k.find(base)!=std::string::npos)){ raw=&kv.second; break; } }
|
||
if (raw && bind(md, *raw)) { ++fg; if (verbose) log(" [LM-ormgroup] '%s' lightmap via ORM merge-group '%s'", md.name.c_str(), base.c_str()); }
|
||
}
|
||
if (fg && verbose) log(" [LM-ormgroup] bound %d via ORM merge-group", fg);
|
||
|
||
// MERGE-GROUP SIBLING fallback (faithful): per AREA (moat/front/rear/planter/window/garden/...), the
|
||
// ground{X} + boulder{X} shell meshes share ONE baked lightmap ATLAS named after the boulder
|
||
// (t_sm_merged_calming_boulder{X}_shell_lmhdr). The cooked override deltas bind the boulder{X} mesh but
|
||
// MISS the ground{X} mesh -> its pbrlightmap material gets a white lightmap = the "groundMoat too white /
|
||
// should be greenish" wash. Both meshes sample the SAME atlas (each keeps its OWN uv2 region), so copy the
|
||
// bound same-area sibling's decoded lightmap texture to the unmapped one. HSR_NOLMSIB disables.
|
||
if (!std::getenv("HSR_NOLMSIB")) {
|
||
auto areaOf = [](const std::string& name) -> std::string {
|
||
std::string n = name; for (char& c : n) c = (char)tolower((unsigned char)c);
|
||
size_t p = n.find("ground"); size_t off = 6;
|
||
if (p == std::string::npos) { p = n.find("boulder"); off = 7; }
|
||
if (p == std::string::npos) return "";
|
||
std::string a = n.substr(p + off); size_t e = 0;
|
||
while (e < a.size() && a[e] >= 'a' && a[e] <= 'z') ++e; // moat/front/rear/planter/window/garden/pot
|
||
return a.substr(0, e);
|
||
};
|
||
int sib = 0;
|
||
if (!lmRawByKey.empty()) for (auto& md : targets) {
|
||
if (md.hasLightmap || md.uvs2.empty()) continue;
|
||
std::string area = areaOf(md.name); if (area.size() < 3) continue;
|
||
// the merged atlas is named after the boulder of this area (…calming_boulder{area}…_lmhdr).
|
||
const std::vector<u8>* raw = nullptr; std::string hit;
|
||
for (auto& kv : lmRawByKey) if (kv.first.find("boulder"+area) != std::string::npos || kv.first.find("ground"+area) != std::string::npos) { raw = &kv.second; hit = kv.first; break; }
|
||
if (raw && bind(md, *raw)) { ++sib; if (verbose) log(" [LM-mergegroup] '%s' lightmap '%s' (area '%s')", md.name.c_str(), hit.c_str(), area.c_str()); }
|
||
}
|
||
if (sib && verbose) log(" [LM-mergegroup] bound %d via ground/boulder area sibling", sib);
|
||
}
|
||
if (std::getenv("HSR_LMDBG")) for (auto& md : targets) { if (md.hasLightmap||md.overlayKind) continue;
|
||
log(" [LM-MISS] '%s' uv2=%zu", md.name.c_str(), md.uvs2.size()); }
|
||
}
|
||
// Skipped / undecoded elements, recorded during load and printed as a summary at the end (always, even
|
||
// with verbose off) — so missing textures / unparsed meshes / unresolved assets are never silent.
|
||
std::vector<std::string> skips;
|
||
void recordSkip(const std::string& s) { skips.push_back(s); }
|
||
|
||
// ── v203 HzAnim skeletal animation (CPU skin into dynamicVerts, like OPA/glTF) ──
|
||
struct SkinRec { size_t meshIdx; std::vector<float> basePos; std::vector<u8> bIdx, bWgt; };
|
||
// Per-asset animation group: each animated fbx (bat/owl/windmill/lantern) owns its OWN skeleton+clip+
|
||
// skinned meshes. The device AnimationSystem is PER-ENTITY; the old single skel/clip drove only ONE rig
|
||
// (whale/incredibles), so multi-rig envs like horror had nothing/everything-wrong animate.
|
||
struct AnimGroup { RendSkel skel; RendClip clip; std::vector<SkinRec> skinRecs; };
|
||
std::vector<AnimGroup> animGroups;
|
||
|
||
// Player spawn points (home_spawn_points.hstf JSON) -> editable cone markers (overlayKind 4).
|
||
// tag "local" = the player start (cyan), "remote" = other spawns (yellow).
|
||
struct SpawnPt { std::string name, tag; float pos[3]={0,0,0}, euler[3]={0,0,0}; int meshIdx=-1; };
|
||
std::vector<SpawnPt> spawnPts;
|
||
HomeWorld world; // spawns, seats, hotspots, colliders (world space), from every HSTF
|
||
|
||
// Whether an HSTF entity draws anything (a MeshPlatformComponent): its colliders are then that thing's.
|
||
static bool entityDrawn(const tinyjson::Value& ent) {
|
||
for (const auto& c : ent["components"].arrVal)
|
||
if (c["data"]["class"].asString() == "horizon::platform_api::MeshPlatformComponent") return true;
|
||
return false;
|
||
}
|
||
// How tall a collider stands in the world (world Y extent), metres.
|
||
static float verticalExtent(const WorldCollider& c) {
|
||
const float* m = c.world;
|
||
auto axisY = [&](int k) { return std::fabs(m[k * 4 + 1]); }; // a local axis' world Y
|
||
auto axisLen = [&](int k) { return std::sqrt(m[k * 4] * m[k * 4] + m[k * 4 + 1] * m[k * 4 + 1] + m[k * 4 + 2] * m[k * 4 + 2]); };
|
||
switch (c.kind) {
|
||
case WorldCollider::Box: return 2 * (axisY(0) * c.half[0] + axisY(1) * c.half[1] + axisY(2) * c.half[2]);
|
||
case WorldCollider::Sphere: return 2 * c.radius * std::max({axisLen(0), axisLen(1), axisLen(2)});
|
||
case WorldCollider::Capsule: return axisY(1) * c.length + 2 * c.radius * std::max({axisLen(0), axisLen(1), axisLen(2)});
|
||
default: {
|
||
float lo = 1e30f, hi = -1e30f;
|
||
for (size_t v = 0; v + 2 < c.positions.size(); v += 3) {
|
||
const float y = m[1] * c.positions[v] + m[5] * c.positions[v + 1] + m[9] * c.positions[v + 2] + m[13];
|
||
lo = std::min(lo, y); hi = std::max(hi, y);
|
||
}
|
||
return hi > lo ? hi - lo : 0.0f;
|
||
}
|
||
}
|
||
}
|
||
bool hasAnimation() const {
|
||
for (auto& g : animGroups) if (g.skel.ok() && g.clip.ok() && !g.skinRecs.empty()) return true;
|
||
return false;
|
||
}
|
||
void animate(float t) {
|
||
static int animdbg = -1; if (animdbg < 0) animdbg = std::getenv("HSR_ANIMDBG") ? 1 : 0;
|
||
static int dbgShots = 0; // print the first few calls + one around t=15 (to catch hold-then-move clips)
|
||
bool dbg = animdbg && (dbgShots < 3 || (t > 15.f && dbgShots < 6));
|
||
if (dbg) ++dbgShots;
|
||
int gi = -1;
|
||
for (auto& g : animGroups) {
|
||
++gi;
|
||
if (!(g.skel.ok() && g.clip.ok() && !g.skinRecs.empty())) { if (dbg) fprintf(stderr, "[ANIMDBG] t=%.2f g%d SKIPPED (skel=%d clip=%d recs=%zu)\n", t, gi, (int)g.skel.ok(), (int)g.clip.ok(), g.skinRecs.size()); continue; }
|
||
std::vector<float> skin; sampleRendClip(g.skel, g.clip, t, skin); // device: quat->mat3*scale+T per joint
|
||
if (dbg) { float tx=skin.size()>=32?skin[28]:0, ty=skin.size()>=32?skin[29]:0, tz=skin.size()>=32?skin[30]:0;
|
||
std::string ms; for (auto& r : g.skinRecs) { ms += std::to_string(r.meshIdx) + ","; }
|
||
fprintf(stderr, "[ANIMDBG] t=%.2f g%d meshes=[%s] j1skinT=(%.2f,%.2f,%.2f)\n", t, gi, ms.c_str(), tx, ty, tz);
|
||
for (auto& r : g.skinRecs) if (r.meshIdx == 5 && r.basePos.size() >= 3 && r.bIdx.size() >= 4) {
|
||
fprintf(stderr, "[ANIMDBG] mesh5 base0=(%.2f,%.2f,%.2f) bIdx=[%d,%d,%d,%d] bWgt=[%d,%d,%d,%d] nj=%d\n",
|
||
r.basePos[0], r.basePos[1], r.basePos[2], r.bIdx[0], r.bIdx[1], r.bIdx[2], r.bIdx[3],
|
||
r.bWgt[0], r.bWgt[1], r.bWgt[2], r.bWgt[3], (int)skin.size()/16); } }
|
||
int nj = (int)skin.size()/16;
|
||
for (auto& r : g.skinRecs) {
|
||
auto& md = meshes[r.meshIdx];
|
||
size_t nv = r.basePos.size()/3;
|
||
if (md.positions.size() < nv*3) md.positions.resize(nv*3);
|
||
for (size_t v=0; v<nv; ++v) {
|
||
float bx=r.basePos[v*3], by=r.basePos[v*3+1], bz=r.basePos[v*3+2];
|
||
float ox=0,oy=0,oz=0,wsum=0;
|
||
for (int k=0;k<4;k++) {
|
||
if (v*4+k >= r.bWgt.size()) break;
|
||
float w = r.bWgt[v*4+k]/255.0f; if (w<=0.f) continue;
|
||
int j = r.bIdx[v*4+k]; if (j<0||j>=nj) continue;
|
||
const float* m=&skin[16*j];
|
||
ox += w*(m[0]*bx+m[4]*by+m[8]*bz+m[12]);
|
||
oy += w*(m[1]*bx+m[5]*by+m[9]*bz+m[13]);
|
||
oz += w*(m[2]*bx+m[6]*by+m[10]*bz+m[14]);
|
||
wsum += w;
|
||
}
|
||
if (wsum < 1e-4f) { ox=bx; oy=by; oz=bz; }
|
||
else { float inv=1.0f/wsum; ox*=inv; oy*=inv; oz*=inv; } // normalize (weights may not sum to 1)
|
||
md.positions[v*3]=ox; md.positions[v*3+1]=oy; md.positions[v*3+2]=oz;
|
||
}
|
||
if (dbg && r.meshIdx == 5 && md.positions.size() >= 3)
|
||
fprintf(stderr, "[ANIMDBG] mesh5 SKINNED v0=(%.2f,%.2f,%.2f)\n", md.positions[0], md.positions[1], md.positions[2]);
|
||
}
|
||
}
|
||
}
|
||
|
||
void log(const char* fmt, ...) {
|
||
if (!verbose) return;
|
||
va_list args;
|
||
va_start(args, fmt);
|
||
fprintf(stderr, "[LOADER] ");
|
||
vfprintf(stderr, fmt, args);
|
||
fprintf(stderr, "\n");
|
||
va_end(args);
|
||
}
|
||
|
||
bool load(const std::string& apkPath) {
|
||
log("========================================");
|
||
log("HSR Scene Loader — libshell.so pipeline");
|
||
log("APK: %s", apkPath.c_str());
|
||
log("========================================");
|
||
|
||
// ── Step 0: the APK's assets/scene.zip (unpacked once per load, shared: scene_zip.h)
|
||
std::shared_ptr<const std::vector<u8>> sceneZipHold = scenezip::get(apkPath);
|
||
if (!sceneZipHold) {
|
||
log("FATAL: no readable assets/scene.zip in %s", apkPath.c_str());
|
||
return false;
|
||
}
|
||
const void* sceneZipData = sceneZipHold->data();
|
||
const size_t sceneZipSize = sceneZipHold->size();
|
||
log("scene.zip: %zu bytes", sceneZipSize);
|
||
|
||
// ── Step 1-2: Parse scene.zip contents
|
||
mz_zip_archive sceneZip;
|
||
memset(&sceneZip, 0, sizeof(sceneZip));
|
||
if (!mz_zip_reader_init_mem(&sceneZip, sceneZipData, sceneZipSize, 0)) {
|
||
log("FATAL: Cannot open scene.zip in memory");
|
||
log(" szip error: %s", mz_zip_get_error_string(mz_zip_get_last_error(&sceneZip)));
|
||
sceneZipHold.reset();
|
||
return false;
|
||
}
|
||
{
|
||
std::string archiveError;
|
||
if (!zipread::validateArchive(sceneZip, hslcook::zipsafety::kSceneReadLimits, &archiveError)) {
|
||
log("FATAL: Unsafe or oversized scene.zip: %s", archiveError.c_str());
|
||
mz_zip_reader_end(&sceneZip);
|
||
sceneZipHold.reset();
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// List all files in scene.zip for debugging
|
||
if (verbose) {
|
||
log("Scene.zip contents (%u files):", mz_zip_reader_get_num_files(&sceneZip));
|
||
mz_zip_archive_file_stat fstat;
|
||
for (int i = 0; i < (int)mz_zip_reader_get_num_files(&sceneZip) && i < 50; ++i) {
|
||
if (mz_zip_reader_file_stat(&sceneZip, i, &fstat))
|
||
log(" [%3d] %s", i, fstat.m_filename);
|
||
}
|
||
if (mz_zip_reader_get_num_files(&sceneZip) > 50)
|
||
log(" ... (%u more files)", mz_zip_reader_get_num_files(&sceneZip) - 50);
|
||
}
|
||
|
||
// Detect whether this env actually has skeletal animation. Skinning must be
|
||
// gated on the presence of an HZANSKEL skeleton — NOT on vertex stride. Many
|
||
// static envs (haven2025) use wide vertex layouts (stride 48: pos+normal+
|
||
// tangent+multiUV+color) that are not bone data. Reading "bones" from those
|
||
// bytes marked every static mesh as skinned and collapsed them.
|
||
bool envHasSkeleton = false;
|
||
{
|
||
int nf = (int)mz_zip_reader_get_num_files(&sceneZip);
|
||
mz_zip_archive_file_stat st;
|
||
for (int i = 0; i < nf; ++i) {
|
||
if (!mz_zip_reader_file_stat(&sceneZip, i, &st)) continue;
|
||
std::string n(st.m_filename);
|
||
if (n.find("hzanim_skel") != std::string::npos || n.find("__skel") != std::string::npos
|
||
|| n.find(".skel/skeleton") != std::string::npos) { // OUR cooker's naming
|
||
envHasSkeleton = true; break;
|
||
}
|
||
}
|
||
log("envHasSkeleton=%d (skinning %s)", (int)envHasSkeleton,
|
||
envHasSkeleton ? "ENABLED" : "disabled — all meshes static");
|
||
}
|
||
|
||
// Helper: read a file from scene.zip by its manifest path.
|
||
// Manifest paths are like "meta/nux/nux_d/tx_dome_a_03.png/tex" (no "content/" prefix).
|
||
// The zip entry is stored as "content/meta/..." so we try both.
|
||
auto readAsset = [&](const std::string& path) -> std::vector<u8> {
|
||
std::string full = "content/" + path;
|
||
int idx = mz_zip_reader_locate_file(&sceneZip, full.c_str(), nullptr, 0);
|
||
if (idx < 0)
|
||
idx = mz_zip_reader_locate_file(&sceneZip, path.c_str(), nullptr, 0);
|
||
if (idx < 0) {
|
||
if (verbose) log(" MISSING: %s", full.c_str());
|
||
return {};
|
||
}
|
||
size_t sz = 0;
|
||
void* d = mz_zip_reader_extract_to_heap(&sceneZip, idx, &sz, 0);
|
||
if (!d) {
|
||
log(" EXTRACT FAILED: %s", full.c_str());
|
||
return {};
|
||
}
|
||
std::vector<u8> out((u8*)d, (u8*)d + sz);
|
||
mz_free(d);
|
||
return out;
|
||
};
|
||
|
||
// Streamed textures: the cooked ".../<hash>/tex_xxxx" is only a low-res base; the full-resolution
|
||
// levels ship beside it as complete RENDTXTRs in ".../<hash>/__tex_sub_targets___*/streamN_*"
|
||
// (e.g. 128 base, 256 stream0, 512 stream1). The device streams the larger ones in; load the largest.
|
||
std::map<std::string, std::vector<std::string>> texStreams; // texture dir (no "content/") -> stream entries
|
||
for (mz_uint i = 0; i < mz_zip_reader_get_num_files(&sceneZip); ++i) {
|
||
char nm[1024];
|
||
if (!mz_zip_reader_get_filename(&sceneZip, i, nm, sizeof nm)) continue;
|
||
std::string s = nm;
|
||
size_t p = s.find("/__tex_sub_targets__");
|
||
if (p == std::string::npos || s.find("/stream", p) == std::string::npos) continue;
|
||
std::string dirKey = s.substr(0, p);
|
||
if (dirKey.rfind("content/", 0) == 0) dirKey = dirKey.substr(8);
|
||
texStreams[dirKey].push_back(s);
|
||
}
|
||
auto readTexture = [&](const std::string& path) -> std::vector<u8> {
|
||
std::vector<u8> best = readAsset(path);
|
||
size_t slash = path.rfind('/');
|
||
auto it = slash == std::string::npos ? texStreams.end() : texStreams.find(path.substr(0, slash));
|
||
if (it == texStreams.end() || std::getenv("FH_BASE_TEXTURES")) return best;
|
||
u64 bestArea = 0;
|
||
RendtxtrInfo bi;
|
||
if (!best.empty() && parseRendtxtrHeader(best, bi)) bestArea = (u64)bi.width * bi.height;
|
||
for (const std::string& entry : it->second) {
|
||
int idx = mz_zip_reader_locate_file(&sceneZip, entry.c_str(), nullptr, 0);
|
||
if (idx < 0) continue;
|
||
size_t sz = 0;
|
||
void* d = mz_zip_reader_extract_to_heap(&sceneZip, idx, &sz, 0);
|
||
if (!d) continue;
|
||
std::vector<u8> cand((u8*)d, (u8*)d + sz);
|
||
mz_free(d);
|
||
RendtxtrInfo ci;
|
||
if (!parseRendtxtrHeader(cand, ci) || (u64)ci.width * ci.height <= bestArea) continue;
|
||
bestArea = (u64)ci.width * ci.height;
|
||
best = std::move(cand);
|
||
}
|
||
return best;
|
||
};
|
||
if (!texStreams.empty()) log(" %zu streamed textures: loading their full-resolution levels", texStreams.size());
|
||
|
||
// ── Step 3: Parse content/assets.manifest → complete (ing,tgt)→path map
|
||
log("Step 1: Parsing ASMH manifest...");
|
||
auto asmhData = readAsset("assets.manifest");
|
||
if (asmhData.empty()) {
|
||
log("FATAL: ASMH manifest not found");
|
||
mz_zip_reader_end(&sceneZip);
|
||
sceneZipHold.reset();
|
||
return false;
|
||
}
|
||
log(" ASMH size: %zu bytes, magic: '%c%c%c%c'",
|
||
asmhData.size(), asmhData[0], asmhData[1], asmhData[2], asmhData[3]);
|
||
|
||
AssetMap assetMap;
|
||
if (!parseAsmh(asmhData, assetMap)) {
|
||
log("FATAL: ASMH parse returned no entries");
|
||
mz_zip_reader_end(&sceneZip);
|
||
sceneZipHold.reset();
|
||
return false;
|
||
}
|
||
log(" Registered %zu asset map entries (includes pkg=0 fallbacks)", assetMap.size());
|
||
if (verbose) {
|
||
for (auto& [key, path] : assetMap) {
|
||
if (key.pkg != 0) // only print real entries to reduce noise
|
||
log(" ing=%016llX tgt=%08X -> %s",
|
||
(unsigned long long)key.ing, key.tgt, path.c_str());
|
||
}
|
||
}
|
||
|
||
// Helper: resolve any AssetKey → path string (tries real pkg then pkg=0).
|
||
AssetKey sceneRefl{}; // a reflectionMap named by a sky component without a mesh (parseHstf)
|
||
auto resolve = [&](const AssetKey& k) -> const std::string* {
|
||
auto it = assetMap.find(k);
|
||
if (it != assetMap.end()) return &it->second;
|
||
AssetKey k0 = k; k0.pkg = 0;
|
||
it = assetMap.find(k0);
|
||
if (it != assetMap.end()) return &it->second;
|
||
return nullptr;
|
||
};
|
||
|
||
// ── Step 4: shellconfig → firstWorldAssetId
|
||
log("Step 2: Reading shellconfig...");
|
||
auto cfgData = readAsset("configs/shellconfig.jsonc");
|
||
if (cfgData.empty()) {
|
||
log("FATAL: shellconfig.jsonc not found");
|
||
mz_zip_reader_end(&sceneZip);
|
||
sceneZipHold.reset();
|
||
return false;
|
||
}
|
||
std::string cfgJson((char*)cfgData.data(), cfgData.size());
|
||
log(" shellconfig: %zu bytes", cfgJson.size());
|
||
|
||
tinyjson::Value cfg;
|
||
try { cfg = tinyjson::parse(cfgJson); } catch (...) {} // malformed/too deep -> Null -> fails below
|
||
if (!cfg.has("firstWorldAssetId")) {
|
||
log("FATAL: shellconfig missing firstWorldAssetId");
|
||
mz_zip_reader_end(&sceneZip);
|
||
sceneZipHold.reset();
|
||
return false;
|
||
}
|
||
auto& fw = cfg["firstWorldAssetId"];
|
||
AssetKey rootKey;
|
||
try {
|
||
rootKey.pkg = fw.has("packageOrRemoteId") ? std::stoull(fw["packageOrRemoteId"].asString()) : 0ull;
|
||
rootKey.ing = fw.has("ingestionId") ? std::stoull(fw["ingestionId"].asString()) : 0ull;
|
||
rootKey.tgt = fw.has("targetId") ? (u32)fw["targetId"].asInt() : 0u;
|
||
} catch (...) {
|
||
log("FATAL: Failed to parse firstWorldAssetId values");
|
||
mz_zip_reader_end(&sceneZip);
|
||
sceneZipHold.reset();
|
||
return false;
|
||
}
|
||
log(" firstWorldAssetId: pkg=%016llX ing=%016llX tgt=%08X",
|
||
(unsigned long long)rootKey.pkg,
|
||
(unsigned long long)rootKey.ing,
|
||
rootKey.tgt);
|
||
|
||
const std::string* spacePath = resolve(rootKey);
|
||
if (!spacePath) {
|
||
log("FATAL: firstWorldAssetId not in manifest: pkg=%016llX ing=%016llX tgt=%08X",
|
||
(unsigned long long)rootKey.pkg,
|
||
(unsigned long long)rootKey.ing,
|
||
rootKey.tgt);
|
||
mz_zip_reader_end(&sceneZip);
|
||
sceneZipHold.reset();
|
||
return false;
|
||
}
|
||
log(" space.hstf path: %s", spacePath->c_str());
|
||
|
||
// ── Step 3: Recursively load HSTF hierarchy → entity list
|
||
// Handles both flat scenes (v79_test: space.hstf IS the leaf) and
|
||
// deeply nested templates (haven2025: space → arch/props → shell.usda → entities)
|
||
log("Step 3: Loading HSTF hierarchy recursively from: %s", spacePath->c_str());
|
||
std::vector<DrawableEntity> entities;
|
||
{
|
||
// stackHstf = recursion-STACK (cycle guard only). The old code used a permanent visited-set,
|
||
// which silently DROPPED any template reached via a 2nd parent/transform — i.e. legitimately
|
||
// re-instanced templates (the repeated "pockets", the cave sand/ground clones) → seafloor/cave
|
||
// GAPS. A stack guard still breaks real cycles (A→…→A) but lets a template instance many times.
|
||
std::set<std::string> stackHstf;
|
||
std::set<std::string> everHstf; // diagnostic: which templates were being re-instanced (previously deduped)
|
||
// The world layer of one HSTF: every entity (drawn or not) placed through its parents (relationships
|
||
// RelationChildOf) and the template instance carrying the file (parentMat), its components read into
|
||
// HomeWorld. Mesh colliders load their physics mesh and land in world space.
|
||
auto collectWorld = [&](const std::string& hjson, const HstfDeltas& deltas, const float* parentMat) {
|
||
tinyjson::Value doc;
|
||
try { doc = tinyjson::parse(hjson); } catch (...) { return; }
|
||
applyHstfDeltas(doc, deltas);
|
||
if (!doc.has("entities") || !doc["entities"].isArray()) return;
|
||
std::map<std::string, const tinyjson::Value*> byId;
|
||
std::map<std::string, std::string> parentOf;
|
||
for (const auto& e : doc["entities"].arrVal) if (e.has("id")) byId[e["id"].asString()] = &e;
|
||
if (doc.has("relationships") && doc["relationships"].isArray())
|
||
for (const auto& r : doc["relationships"].arrVal)
|
||
if (r["relationshipType"].asString() == "RelationChildOf") parentOf[r["source"].asString()] = r["destination"].asString();
|
||
std::map<std::string, std::array<float, 16>> worldOf;
|
||
std::function<const float*(const std::string&, int)> worldMat = [&](const std::string& id, int guard) -> const float* {
|
||
auto it = worldOf.find(id);
|
||
if (it != worldOf.end()) return it->second.data();
|
||
std::array<float, 16> m;
|
||
float local[16];
|
||
auto e = byId.find(id);
|
||
trsToMat4(e != byId.end() ? parseHstfEntityTransform(*e->second) : Transform{}, local);
|
||
auto p = parentOf.find(id);
|
||
const float* pm = p != parentOf.end() && guard > 0 && byId.count(p->second) ? worldMat(p->second, guard - 1) : parentMat;
|
||
mat4mul4(pm, local, m.data());
|
||
return worldOf.emplace(id, m).first->second.data();
|
||
};
|
||
auto xf = [](const float* m, const float* v, float* o) { for (int k = 0; k < 3; ++k) o[k] = m[k] * v[0] + m[4 + k] * v[1] + m[8 + k] * v[2] + m[12 + k]; };
|
||
auto keyOf = [](const tinyjson::Value& r) {
|
||
AssetKey k;
|
||
try {
|
||
k.pkg = r.has("packageOrRemoteId") ? std::stoull(r["packageOrRemoteId"].asString()) : 0ull;
|
||
k.ing = r.has("ingestionId") ? std::stoull(r["ingestionId"].asString()) : 0ull;
|
||
if (r.has("targetId")) k.tgt = r["targetId"].isString() ? static_cast<u32>(std::stoul(r["targetId"].asString())) : static_cast<u32>(r["targetId"].asFloat());
|
||
} catch (...) {}
|
||
return k;
|
||
};
|
||
for (const auto& [id, ep] : byId) {
|
||
const tinyjson::Value& ent = *ep;
|
||
if (ent.has("__removedByDelta") || !ent.has("components") || !ent["components"].isArray()) continue;
|
||
const float* wm = worldMat(id, 16);
|
||
const std::string name = ent["name"].asString();
|
||
bool trigger = false, hotspot = false, seat = false;
|
||
for (const auto& c : ent["components"].arrVal) {
|
||
const std::string cls = c["data"]["class"].asString();
|
||
if (cls.find("LocomotionHotspotComponent") != std::string::npos) hotspot = true;
|
||
// A seat's own collider is what you point at to sit (Haven: a 0.6 m sphere scaled 2.45, so a
|
||
// 1.47 m ball round every chair), not something solid: the chair's mesh collider is that.
|
||
if (cls.find("AvatarSittingPlatformComponent") != std::string::npos) seat = true;
|
||
if (cls.find("PhysicsBodyPlatformComponent") != std::string::npos && c["data"]["data"]["type"].asString().find("Trigger") != std::string::npos) trigger = true;
|
||
}
|
||
for (const auto& c : ent["components"].arrVal) {
|
||
const std::string cls = c["data"]["class"].asString();
|
||
const tinyjson::Value& d = c["data"]["data"];
|
||
auto point = [&](WorldPoint& p) {
|
||
p.name = name;
|
||
const float o[3] = {0, 0, 0}, f[3] = {0, 0, -1}; // Meta's forward is -Z (right-handed, Y up)
|
||
xf(wm, o, p.pos);
|
||
float tip[3];
|
||
xf(wm, f, tip);
|
||
p.yaw = std::atan2(-(tip[0] - p.pos[0]), -(tip[2] - p.pos[2])); // camera yaw (0 = facing -Z) along the point's forward
|
||
};
|
||
if (cls.find("SpawnPointPlatformComponent") != std::string::npos) {
|
||
WorldPoint p; point(p);
|
||
bool local = false;
|
||
for (const auto& t : d["tags"].arrVal) local |= t.asString() == "local";
|
||
if (local) world.spawns.insert(world.spawns.begin(), p); else world.spawns.push_back(p);
|
||
} else if (cls.find("AvatarSittingPlatformComponent") != std::string::npos) {
|
||
WorldPoint p; point(p);
|
||
const auto& ex = d["playerExitPosition"];
|
||
p.exit[0] = static_cast<float>(ex["x"].asFloat()); p.exit[1] = static_cast<float>(ex["y"].asFloat()); p.exit[2] = static_cast<float>(ex["z"].asFloat());
|
||
world.seats.push_back(p);
|
||
} else if (cls.find("LocomotionHotspotComponent") != std::string::npos) {
|
||
WorldPoint p; point(p); world.hotspots.push_back(p);
|
||
} else if (cls.find("Collider") != std::string::npos && cls.find("PlatformComponent") != std::string::npos) {
|
||
WorldCollider col; col.name = name; std::memcpy(col.world, wm, 64); col.role = trigger || hotspot || seat ? WorldCollider::Trigger : WorldCollider::Solid;
|
||
if (cls.find("ColliderBox") != std::string::npos) {
|
||
col.kind = WorldCollider::Box;
|
||
const auto& he = d["halfExtents"];
|
||
if (he.has("x")) { col.half[0] = static_cast<float>(he["x"].asFloat()); col.half[1] = static_cast<float>(he["y"].asFloat()); col.half[2] = static_cast<float>(he["z"].asFloat()); }
|
||
} else if (cls.find("ColliderSphere") != std::string::npos) {
|
||
col.kind = WorldCollider::Sphere;
|
||
if (d.has("radius")) col.radius = static_cast<float>(d["radius"].asFloat());
|
||
} else if (cls.find("ColliderCapsule") != std::string::npos) {
|
||
col.kind = WorldCollider::Capsule;
|
||
if (d.has("radius")) col.radius = static_cast<float>(d["radius"].asFloat());
|
||
if (d.has("length")) col.length = static_cast<float>(d["length"].asFloat());
|
||
const std::string axis = d["axis"].asString();
|
||
if (axis == "X" || axis == "Z") { // the capsule's axis becomes local Y
|
||
float rot[16] = {1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1}, out[16];
|
||
if (axis == "X") { rot[0] = 0; rot[1] = 1; rot[4] = -1; rot[5] = 0; }
|
||
else { rot[5] = 0; rot[6] = 1; rot[9] = -1; rot[10] = 0; }
|
||
mat4mul4(wm, rot, out); std::memcpy(col.world, out, 64);
|
||
}
|
||
} else { // ColliderMesh / ColliderConvexMesh
|
||
col.kind = WorldCollider::Mesh;
|
||
const tinyjson::Value& ref = d.has("meshAsset") ? d["meshAsset"] : d["convexMeshAsset"];
|
||
// The physics mesh is a serialized physics shape ("SEBD"), cooked from the same FBX node as the
|
||
// entity's drawn mesh: when it can't be read as a mesh, the drawn mesh is the collider.
|
||
auto readMeshKey = [&](AssetKey mk, std::vector<float>& pos, std::vector<u32>& idx) -> bool {
|
||
const std::string* mp = resolve(mk);
|
||
if (!mp) { mk.pkg = 0; mp = resolve(mk); }
|
||
if (!mp) return false;
|
||
std::vector<u8> bytes = readAsset(*mp);
|
||
std::vector<float> uv;
|
||
return !bytes.empty() && parseRendMesh(bytes, pos, uv, idx);
|
||
};
|
||
std::vector<float> pos;
|
||
std::vector<u32> idx;
|
||
bool okMesh = readMeshKey(keyOf(ref), pos, idx);
|
||
if (!okMesh)
|
||
for (const auto& c2 : ent["components"].arrVal)
|
||
if (c2["data"]["class"].asString() == "horizon::platform_api::MeshPlatformComponent" && c2["data"]["data"].has("mesh")) {
|
||
okMesh = readMeshKey(keyOf(c2["data"]["data"]["mesh"]), pos, idx);
|
||
break;
|
||
}
|
||
if (!okMesh) continue;
|
||
// Kept in the entity's frame (exactly the mesh's own vertices): a .fenv then stores a
|
||
// collider made from a drawn mesh as a reference to it instead of a second copy.
|
||
float lo[3] = {1e30f, 1e30f, 1e30f}, hi[3] = {-1e30f, -1e30f, -1e30f};
|
||
for (size_t v = 0; v + 2 < pos.size(); v += 3) {
|
||
float wp[3];
|
||
xf(wm, &pos[v], wp);
|
||
for (int k = 0; k < 3; ++k) { lo[k] = std::min(lo[k], wp[k]); hi[k] = std::max(hi[k], wp[k]); }
|
||
}
|
||
std::memcpy(col.world, wm, 64);
|
||
col.positions = std::move(pos);
|
||
// Skies and the vista's distant scenery carry colliders too; no one walks into them.
|
||
if (hi[0] - lo[0] > 200 || hi[1] - lo[1] > 200 || hi[2] - lo[2] > 200) continue;
|
||
col.indices = std::move(idx);
|
||
}
|
||
// Meta's invisible walls: a solid collider with nothing drawn for it, standing 2 m or more
|
||
// (the bounds round a home's play area: Cascadia's, Space Station's, Desert Terrace's, Haven's
|
||
// moats). They become invisible walls (Object): they stop you, and their tops, metres up, are
|
||
// never floor. Flatter ones stay surfaces (floors, ceilings, a desk's top).
|
||
if (col.role == WorldCollider::Solid && !entityDrawn(ent) && verticalExtent(col) >= 2.0f) col.role = WorldCollider::Object;
|
||
world.colliders.push_back(std::move(col));
|
||
}
|
||
}
|
||
}
|
||
};
|
||
// Nested templates multiply (N instances per level, 6 levels): a hostile home could expand to billions of
|
||
// entities or template parses. Cap both; real homes are orders of magnitude below.
|
||
constexpr size_t kMaxHstfEntities = 200000, kMaxHstfInstances = 100000;
|
||
size_t hstfInstances = 0;
|
||
bool hstfCapLogged = false;
|
||
std::function<void(const std::string&, int, const Transform&, const float*, const HstfDeltas&, const std::string&)> loadHstfRec;
|
||
// folder: the template instances it was placed through (where its entities sit in the home's layout)
|
||
loadHstfRec = [&](const std::string& path, int depth, const Transform& parent, const float* parentMat,
|
||
const HstfDeltas& deltas, const std::string& folder) {
|
||
if (depth <= 0 || stackHstf.count(path)) return; // skip only if already on the CURRENT path (a cycle)
|
||
if (entities.size() >= kMaxHstfEntities || ++hstfInstances > kMaxHstfInstances) {
|
||
if (!hstfCapLogged) { hstfCapLogged = true; log(" HSTF template expansion capped (%zu entities, %zu instances)", entities.size(), hstfInstances); }
|
||
return;
|
||
}
|
||
if (everHstf.count(path)) log(" RE-INSTANCE template (was previously deduped → gap): %s", path.c_str());
|
||
stackHstf.insert(path); everHstf.insert(path);
|
||
log(" [d=%d] %s", depth, path.c_str());
|
||
|
||
auto hdata = readAsset(path);
|
||
if (hdata.empty()) { stackHstf.erase(path); return; }
|
||
std::string hjson((char*)hdata.data(), hdata.size());
|
||
|
||
// Collect drawable entities in this HSTF (leaf behavior)
|
||
std::vector<DrawableEntity> localEnts;
|
||
parseHstf(hjson, localEnts, false, &deltas, &sceneRefl);
|
||
collectWorld(hjson, deltas, parentMat);
|
||
|
||
// FAITHFUL empty-transform resolution. The cooker sometimes emits a mesh entity whose
|
||
// TransformPlatformComponent has NO localPosition (oceanarium Root_sand_SHELL): a flat read
|
||
// drops it to the origin, leaving a seafloor GAP — but in-headset it sits with the rest of
|
||
// the level. The cave content (rocks/sand/trim) all share one localPosition (10,0,-5) — a
|
||
// group offset the cook baked per-mesh — while a few distant rocks sit elsewhere, so it is a
|
||
// DOMINANT (majority) offset, not unanimous. When the same localPosition is shared by a strict
|
||
// majority (>50%) of a level's positioned mesh siblings, an empty-transform mesh belongs at
|
||
// that group offset. Levels whose meshes have VARIED positions (fgpockets — oceanGround
|
||
// correctly stays at origin; every coral is unique → no majority) leave empty meshes alone.
|
||
// The level ROOT has no mesh (meshRef.ing==0) so it never participates. Reproduces VR for both
|
||
// levels; generalizes to any empty-transform mesh ("fix one fixes the rest").
|
||
{
|
||
int nPos = 0, bestCnt = 0; float best[3] = {0,0,0};
|
||
for (size_t i = 0; i < localEnts.size(); ++i) {
|
||
if (localEnts[i].emptyTransform || localEnts[i].meshRef.ing == 0) continue;
|
||
++nPos; const float* pi = localEnts[i].transform.pos; int cnt = 0;
|
||
for (const auto& e : localEnts) {
|
||
if (e.emptyTransform || e.meshRef.ing == 0) continue;
|
||
const float* pj = e.transform.pos;
|
||
if (std::fabs(pj[0]-pi[0])<1e-3f && std::fabs(pj[1]-pi[1])<1e-3f && std::fabs(pj[2]-pi[2])<1e-3f) ++cnt;
|
||
}
|
||
if (cnt > bestCnt) { bestCnt = cnt; best[0]=pi[0]; best[1]=pi[1]; best[2]=pi[2]; }
|
||
}
|
||
bool nonZero = std::fabs(best[0])>1e-3f || std::fabs(best[1])>1e-3f || std::fabs(best[2])>1e-3f;
|
||
if (bestCnt >= 2 && bestCnt * 2 > nPos && nonZero) { // a GROUP (≥2) forming a strict majority share one offset
|
||
int fixed = 0;
|
||
for (auto& e : localEnts)
|
||
if (e.emptyTransform && e.meshRef.ing != 0) { e.transform.pos[0]=best[0]; e.transform.pos[1]=best[1]; e.transform.pos[2]=best[2]; ++fixed; }
|
||
if (fixed) log(" empty-transform mesh(es) inherited level group offset (%.2f,%.2f,%.2f) [%d/%d siblings]: %d fixed", best[0],best[1],best[2], bestCnt,nPos, fixed);
|
||
}
|
||
}
|
||
// Compose this level's accumulated world transform onto each local drawable (so template
|
||
// instances land at their world placement, not template-local — fixes far-flung sunrays2,
|
||
// seafloor gaps, etc.). Top level passes identity, so direct entities are unchanged.
|
||
for (auto& e : localEnts) {
|
||
if (entities.size() >= kMaxHstfEntities) break;
|
||
float lm[16]; trsToMat4(e.transform, lm); // this node's LOCAL T·R·S
|
||
mat4mul4(parentMat, lm, e.worldMatrix); e.hasWorldMatrix = true; // faithful 4×4 world (keeps shear)
|
||
e.transform = composeTransform(parent, e.transform); // composed TRS too (bounds/probe/editor)
|
||
e.path = folder.empty() ? e.path : e.path.empty() ? folder : folder + "/" + e.path;
|
||
entities.push_back(e);
|
||
}
|
||
|
||
// Follow entity type refs (intermediate/recursive behavior)
|
||
try {
|
||
auto doc = tinyjson::parse(hjson);
|
||
applyHstfDeltas(doc, deltas); // a delta can also retarget an instance's own components
|
||
if (!doc.has("entities") || !doc["entities"].isArray()) { stackHstf.erase(path); return; }
|
||
const auto& arr = doc["entities"];
|
||
std::map<std::string, std::string> nameOfId, parentOfId; // the instance's own groups in this file
|
||
for (size_t i = 0; i < arr.size(); ++i) if (arr[i].has("id")) nameOfId[arr[i]["id"].asString()] = arr[i].has("name") ? arr[i]["name"].asString() : arr[i]["id"].asString();
|
||
if (doc.has("relationships") && doc["relationships"].isArray())
|
||
for (const auto& r : doc["relationships"].arrVal)
|
||
if (r["relationshipType"].asString() == "RelationChildOf") parentOfId[r["source"].asString()] = r["destination"].asString();
|
||
std::map<std::string, size_t> indexOfId;
|
||
for (size_t i = 0; i < arr.size(); ++i) if (arr[i].has("id")) indexOfId[arr[i]["id"].asString()] = i;
|
||
for (size_t i = 0; i < arr.size(); ++i) {
|
||
const auto& ent = arr[i];
|
||
if (!ent.has("type") || ent.has("__removedByDelta")) continue; // a removed instance brings nothing in
|
||
const auto& typeRef = ent["type"];
|
||
AssetKey typeKey;
|
||
try {
|
||
typeKey.pkg = typeRef.has("packageOrRemoteId") ? std::stoull(typeRef["packageOrRemoteId"].asString()) : 0ull;
|
||
typeKey.ing = typeRef.has("ingestionId") ? std::stoull(typeRef["ingestionId"].asString()) : 0ull;
|
||
typeKey.tgt = typeRef.has("targetId") ? (u32)typeRef["targetId"].asInt() : 0u;
|
||
} catch (...) { continue; }
|
||
if (typeKey.ing == 0) continue;
|
||
const std::string* typePath = resolve(typeKey);
|
||
if (!typePath) {
|
||
log(" type ref unresolved: ing=%016llX tgt=%08X",
|
||
(unsigned long long)typeKey.ing, typeKey.tgt);
|
||
continue;
|
||
}
|
||
// Compose the INSTANCE entity's placement onto the parent before descending, so the
|
||
// referenced template's geometry inherits where this instance sits in the world.
|
||
Transform inst = parseHstfEntityTransform(ent);
|
||
float im[16], cm[16]; trsToMat4(inst, im);
|
||
// The instance's own parents in this file place it too (Haven2025 hangs a Seating_Hotspot
|
||
// under each chair: read flat, all eight sat at the origin).
|
||
{
|
||
auto p = parentOfId.find(ent.has("id") ? ent["id"].asString() : std::string());
|
||
for (int guard = 0; p != parentOfId.end() && guard < 64; ++guard) {
|
||
auto pe = indexOfId.find(p->second);
|
||
if (pe == indexOfId.end()) break;
|
||
const Transform pt = parseHstfEntityTransform(arr[pe->second]);
|
||
float pm[16], t[16]; trsToMat4(pt, pm); mat4mul4(pm, im, t); std::memcpy(im, t, sizeof t);
|
||
inst = composeTransform(pt, inst);
|
||
p = parentOfId.find(p->second);
|
||
}
|
||
}
|
||
mat4mul4(parentMat, im, cm); // parent·(this file's parents)·instance (4×4)
|
||
// This instance's deltas target entities inside the template it places; deltas inherited from
|
||
// further out keep travelling down and apply after (they win).
|
||
HstfDeltas child;
|
||
if (ent.has("deltas") && ent["deltas"].isArray())
|
||
for (const auto& d : ent["deltas"].arrVal)
|
||
if (d.has("sourceId")) child.emplace_back(hstfGuid13(d["sourceId"].asString()), d);
|
||
child.insert(child.end(), deltas.begin(), deltas.end());
|
||
std::string sub = ent.has("name") ? ent["name"].asString() : std::string();
|
||
if (sub.empty()) { sub = *typePath; if (auto sl = sub.find_last_of('/'); sl != std::string::npos) sub = sub.substr(sl + 1); }
|
||
{ // its parents in this file, then itself
|
||
auto p = parentOfId.find(ent.has("id") ? ent["id"].asString() : std::string());
|
||
for (int guard = 0; p != parentOfId.end() && guard < 64; ++guard) {
|
||
auto n = nameOfId.find(p->second);
|
||
sub = (n != nameOfId.end() ? n->second : p->second) + "/" + sub;
|
||
p = parentOfId.find(p->second);
|
||
}
|
||
}
|
||
loadHstfRec(*typePath, depth - 1, composeTransform(parent, inst), cm, child, folder.empty() ? sub : folder + "/" + sub);
|
||
}
|
||
} catch (...) {}
|
||
stackHstf.erase(path); // pop: allow re-entry of this template via a different parent/transform
|
||
};
|
||
float identMat[16] = {1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1};
|
||
loadHstfRec(*spacePath, 6, Transform{}, identMat, HstfDeltas{}, std::string());
|
||
// A hotspot on a seat (Haven2025's Seating_Hotspot under each chair) marks the seat, which seats already
|
||
// cover: kept, a teleport landing near a stool would snap onto its top, and the stool top would be walkable.
|
||
{
|
||
size_t dropped = 0;
|
||
world.hotspots.erase(std::remove_if(world.hotspots.begin(), world.hotspots.end(), [&](const WorldPoint& h) {
|
||
for (const WorldPoint& s : world.seats)
|
||
if (std::fabs(h.pos[0] - s.pos[0]) < 0.1f && std::fabs(h.pos[1] - s.pos[1]) < 0.1f && std::fabs(h.pos[2] - s.pos[2]) < 0.1f) { ++dropped; return true; }
|
||
return false;
|
||
}), world.hotspots.end());
|
||
if (dropped) log(" %zu hotspot(s) on seats dropped", dropped);
|
||
}
|
||
world.origin = world.empty() ? "" : "meta";
|
||
log(" World: %zu spawns, %zu seats, %zu hotspots, %zu colliders", world.spawns.size(), world.seats.size(),
|
||
world.hotspots.size(), world.colliders.size());
|
||
}
|
||
// DEDUP empty-transform template duplicates. A mesh defined in a level .usda with NO transform lands
|
||
// at the ORIGIN (it's the PROTOTYPE); a sibling entity of the SAME NAME placed by an .hstf carries the
|
||
// real transform. The device renders only the placed instance — so drop the origin-stuck empty
|
||
// duplicate (the "plant in the center that shouldn't be there": Root_..._plantC = empty in the .usda
|
||
// → origin, but calming_fgpocket.hstf places it at (-13.5,..)). Empty meshes with NO real-placed
|
||
// namesake are untouched (they keep the group-offset inheritance path — the oceanarium sand).
|
||
{
|
||
// ANIMATOR-driven entities are EXEMPT: our cook ships skinned/rigid-HZANIM meshes with an
|
||
// INTENTIONALLY empty {} transform (whale cull parity — the device places them via the skeleton,
|
||
// entityJson tfData). They are NOT template duplicates, but their names are material-derived
|
||
// (dozens share one name), so this dedup silently dropped 502/766 of cooked storybook (all the
|
||
// node-animated plants/lilypads/flames' siblings) — "doesn't render in HSL as the original".
|
||
auto hasAnimator = [](const DrawableEntity& e){
|
||
for (const auto& c : e.components) if (c.shortCls.find("Animator") != std::string::npos) return true;
|
||
return false; };
|
||
std::set<std::string> realNamed;
|
||
for (auto& e : entities) if (!e.emptyTransform && e.meshRef.ing != 0) realNamed.insert(e.name);
|
||
size_t before = entities.size();
|
||
entities.erase(std::remove_if(entities.begin(), entities.end(), [&](const DrawableEntity& e){
|
||
return e.emptyTransform && e.meshRef.ing != 0 && realNamed.count(e.name) != 0 && !hasAnimator(e); }), entities.end());
|
||
if (entities.size() != before) log(" dropped %zu empty-transform template duplicate(s) at origin (real-placed namesake exists)", before - entities.size());
|
||
}
|
||
log(" Total drawable entities from HSTF hierarchy: %zu", entities.size());
|
||
if (entities.empty()) {
|
||
log("FATAL: No drawable entities found in HSTF hierarchy");
|
||
mz_zip_reader_end(&sceneZip);
|
||
sceneZipHold.reset();
|
||
return false;
|
||
}
|
||
|
||
// ── Helper: load + decode texture via AssetKey
|
||
// Only legacy/test scenes without MaterialPlatformComponent need the expensive fallback
|
||
// material cycle below. Normal cooked scenes reference their material per entity, so
|
||
// pre-decoding every fallback texture would keep a second complete scene copy in memory.
|
||
const bool allEntitiesNoMat = std::all_of(entities.begin(), entities.end(),
|
||
[](const DrawableEntity& e){ return e.matRefs.empty(); });
|
||
|
||
// ── Decoded-texture cache: a texture shared by many entities (Calming's plant atlas: 31) is read and
|
||
// ASTC-decoded ONCE; every later use copies the pixels.
|
||
struct DecodedTex {
|
||
bool ok = false; std::vector<u8> rgba; u32 w = 0, h = 0, fmt = 0; std::vector<u8> cube; u32 cubeSize = 0;
|
||
std::vector<u8> srgb; // an RGBA16F texture: its colour sRGB-encoded (rgba holds it linear)
|
||
std::shared_ptr<const TexSource> src; // deferred: still-compressed ASTC, rgba empty
|
||
std::shared_ptr<const TexSource> cubeSrc; // deferred cube: six ASTC faces, cube empty
|
||
};
|
||
std::map<std::string, std::shared_ptr<DecodedTex>> texCache;
|
||
// HDR maps (lightmaps, reflection panoramas), once per path: linear half floats, or in deferred mode the
|
||
// cooked ASTC itself. rawHalf: also take uncompressed RGBA16F (converted homes' .exr maps), single-face only.
|
||
struct HdrEntry { std::shared_ptr<const std::vector<u16>> half; std::shared_ptr<const TexSource> src; u32 w = 0, h = 0; };
|
||
std::map<std::string, HdrEntry> hdrCache;
|
||
auto hdrMap = [&](const std::string& path, bool rawHalf) -> const HdrEntry& {
|
||
const std::string key = path + (rawHalf ? "#raw" : "");
|
||
auto it = hdrCache.find(key);
|
||
if (it != hdrCache.end()) return it->second;
|
||
HdrEntry e;
|
||
auto raw = readTexture(path);
|
||
RendtxtrInfo hi;
|
||
if (parseRendtxtrHeader(raw, hi) && (!rawHalf || hi.faces == 1)) {
|
||
const size_t texels = static_cast<size_t>(hi.width) * hi.height;
|
||
if (rawHalf && hi.rawDataLen >= texels * 8 && hi.rawDataOffset + texels * 8 <= raw.size() &&
|
||
(hi.rawDataLen == texels * 8 || hi.formatCode == 1)) {
|
||
auto half = std::make_shared<std::vector<u16>>(texels * 4);
|
||
std::memcpy(half->data(), raw.data() + hi.rawDataOffset, texels * 8);
|
||
e.half = half;
|
||
} else if (!(g_deferTextures && (e.src = hdrAstcSource(raw, hi)))) {
|
||
auto half = std::make_shared<std::vector<u16>>();
|
||
if (astc::decodeASTCHalf(raw.data() + hi.rawDataOffset, hi.rawDataLen, hi.width, hi.height, hi.blockW, hi.blockH, *half))
|
||
e.half = half;
|
||
}
|
||
if (e.half || e.src) { e.w = hi.width; e.h = hi.height; }
|
||
}
|
||
return hdrCache.emplace(key, std::move(e)).first->second;
|
||
};
|
||
auto decodeCached = [&](const std::string& path, bool hdr, bool wantCube, bool mayDefer) -> std::shared_ptr<DecodedTex> {
|
||
const std::string key = path + (hdr ? "#hdr" : "") + (mayDefer && g_deferTextures ? "#src" : "");
|
||
auto it = texCache.find(key);
|
||
if (it != texCache.end()) return it->second;
|
||
auto d = std::make_shared<DecodedTex>();
|
||
texCache[key] = d;
|
||
auto texData = readTexture(path);
|
||
RendtxtrInfo ti;
|
||
if (texData.empty()) { recordSkip("texture data empty: " + path); return d; }
|
||
if (!parseRendtxtrHeader(texData, ti)) { recordSkip("RENDTXTR header parse failed: " + path); return d; }
|
||
// Deferred: keep the cooked ASTC chain; the renderer uploads it as-is or decodes it in the background.
|
||
const u32 mip0 = ((ti.width + ti.blockW - 1) / ti.blockW) * ((ti.height + ti.blockH - 1) / ti.blockH) * 16;
|
||
if (mayDefer && g_deferTextures && !hdr && !wantCube && ti.faces == 1 && ti.rawDataLen >= mip0 &&
|
||
ti.rawDataLen != (u32)((u64)ti.width * ti.height * 4) && ti.rawDataLen != (u32)((u64)ti.width * ti.height * 8)) {
|
||
auto src = std::make_shared<TexSource>();
|
||
const bool exact = isExactAstcPayload(ti);
|
||
const u32 len = exact ? ti.rawDataLen : mip0;
|
||
src->astc.assign(texData.begin() + ti.rawDataOffset, texData.begin() + ti.rawDataOffset + len);
|
||
src->w = ti.width; src->h = ti.height; src->bw = ti.blockW; src->bh = ti.blockH;
|
||
src->mips = exact ? ti.mipCount : 1;
|
||
d->src = src; d->ok = true; d->w = ti.width; d->h = ti.height; d->fmt = ti.formatCode;
|
||
return d;
|
||
}
|
||
const u32 face0 = mip0;
|
||
if (mayDefer && g_deferTextures && wantCube && ti.faces == 6 && ti.width == ti.height && ti.rawDataLen >= face0 * 6 &&
|
||
ti.rawDataOffset + (size_t)face0 * 6 <= texData.size()) {
|
||
// Skybox cube kept compressed too: mip 0 of all six faces (and face 0 as the 2D texture).
|
||
auto cube = std::make_shared<TexSource>();
|
||
cube->astc.assign(texData.begin() + ti.rawDataOffset, texData.begin() + ti.rawDataOffset + (size_t)face0 * 6);
|
||
cube->w = cube->h = ti.width; cube->bw = ti.blockW; cube->bh = ti.blockH; cube->faces = 6;
|
||
auto f0 = std::make_shared<TexSource>(*cube);
|
||
f0->astc.resize(face0); f0->faces = 1;
|
||
d->cubeSrc = cube; d->src = f0; d->cubeSize = ti.width;
|
||
d->ok = true; d->w = ti.width; d->h = ti.height; d->fmt = ti.formatCode;
|
||
return d;
|
||
}
|
||
if (wantCube && ti.faces == 6 && ti.width == ti.height &&
|
||
astc::decodeCubeFaces(texData.data() + ti.rawDataOffset, ti.rawDataLen, ti.width, ti.blockW, ti.blockH, d->cube)) {
|
||
d->cubeSize = ti.width; // six faces once; face 0 doubles as the 2D texture
|
||
d->rgba.assign(d->cube.begin(), d->cube.begin() + (size_t)ti.width * ti.height * 4);
|
||
d->ok = true; d->w = ti.width; d->h = ti.height; d->fmt = ti.formatCode;
|
||
return d;
|
||
}
|
||
if (!astc::decodeASTC(texData.data() + ti.rawDataOffset, ti.rawDataLen, ti.width, ti.height, ti.blockW, ti.blockH, d->rgba, hdr)) {
|
||
recordSkip("ASTC decode failed: " + path);
|
||
return d;
|
||
}
|
||
d->ok = true; d->w = ti.width; d->h = ti.height; d->fmt = ti.formatCode;
|
||
if (ti.rawBpp == 8) { // straight from the halves: 8-bit linear first would band the darks
|
||
d->srgb.resize(d->rgba.size());
|
||
for (size_t q = 0; q < d->srgb.size(); ++q) {
|
||
uint16_t hv; std::memcpy(&hv, texData.data() + ti.rawDataOffset + q * 2, 2);
|
||
const float v = std::clamp(astc::astcHalf2Float(hv), 0.0f, 1.0f);
|
||
d->srgb[q] = (u8)std::lround(((q & 3) == 3 ? v : v <= 0.0031308f ? v * 12.92f : 1.055f * std::pow(v, 1.0f / 2.4f) - 0.055f) * 255.0f);
|
||
}
|
||
}
|
||
return d;
|
||
};
|
||
|
||
auto loadTexture = [&](const AssetKey& texKey, MeshData& md) -> bool {
|
||
const std::string* tp = resolve(texKey);
|
||
if (!tp) {
|
||
char b[64]; snprintf(b,sizeof b,"ing=%016llX tgt=%08X",(unsigned long long)texKey.ing,texKey.tgt);
|
||
if (texKey.ing != 0) // ing=0 = material declares no base texture (pos-only/emissive mesh) — not a failure
|
||
recordSkip(std::string("texture key not in manifest: ")+b);
|
||
log(" Texture key not in manifest: ing=%016llX tgt=%08X",
|
||
(unsigned long long)texKey.ing, texKey.tgt);
|
||
return false;
|
||
}
|
||
auto d = decodeCached(*tp, false, md.isSkybox, true);
|
||
if (!d->ok) return false;
|
||
md.texRGBA = d->rgba; md.texW = d->w; md.texH = d->h; md.hasTexture = true; md.texSrc = d->src;
|
||
if (!d->srgb.empty()) md.texRGBA = d->srgb; // an RGBA16F base colour is linear; the base texture is sampled as sRGB
|
||
if (d->cubeSize) { md.cubeRGBA = d->cube; md.cubeSize = d->cubeSize; md.cubeSrc = d->cubeSrc; }
|
||
log(" Texture: %s (%ux%u fmt=%u)", tp->c_str(), d->w, d->h, d->fmt);
|
||
return true;
|
||
};
|
||
|
||
// ── Helper: decode a texture by AssetKey into an explicit RGBA buffer + dims
|
||
// (used to load normal/ORM/lightmap maps into their MeshData slots).
|
||
// `src` (when given) receives the still-compressed texture in deferred mode, leaving `out` empty.
|
||
auto decodeTexInto = [&](const AssetKey& texKey, std::vector<u8>& out, u32& ow, u32& oh,
|
||
std::shared_ptr<const TexSource>* src = nullptr) -> bool {
|
||
const std::string* tp = resolve(texKey);
|
||
if (!tp) return false; // role-specific map simply not present — not an error
|
||
// .hdr/.exr baked lightmaps are ASTC-HDR — decode with the HDR profile (LDR decode = magenta).
|
||
const bool isHdr = tp->find(".hdr") != std::string::npos || tp->find(".exr") != std::string::npos;
|
||
auto d = decodeCached(*tp, isHdr, false, src != nullptr);
|
||
if (!d->ok) return false;
|
||
out = d->rgba; ow = d->w; oh = d->h;
|
||
if (src) *src = d->src;
|
||
return true;
|
||
};
|
||
// ── Helper: VAT offset texture (t_*_vatdata.exr -> tex). It's NOT ASTC — it's an
|
||
// R16G16B16A16_SFLOAT (half-float) image (libshell TextureFormat 97; sub_DE8EEC), width=vertexCount,
|
||
// height=frameCount, + a mip chain. Grab mip0 (w*h*8 raw half-float bytes) for verbatim upload.
|
||
auto decodeVatFloat = [&](const AssetKey& texKey, MeshData& md) -> bool {
|
||
const std::string* tp = resolve(texKey);
|
||
if (!tp) return false;
|
||
auto texData = readAsset(*tp);
|
||
if (texData.empty()) return false;
|
||
RendtxtrInfo ti;
|
||
if (!parseRendtxtrHeader(texData, ti)) return false;
|
||
size_t mip0 = (size_t)ti.width * ti.height * 8; // 4 channels * 2 bytes (half)
|
||
if (ti.width == 0 || ti.height == 0 || ti.rawDataOffset + mip0 > texData.size()) return false;
|
||
md.vatRaw.assign(texData.begin() + ti.rawDataOffset, texData.begin() + ti.rawDataOffset + mip0);
|
||
md.vatW = ti.width; md.vatH = ti.height; md.hasVat = true;
|
||
return true;
|
||
};
|
||
// Classify a texture path into a shader role by its filename suffix — this mirrors
|
||
// how libshell binds each shader texture resource (BaseColorMetallic_Tx / ONxRNy_Tx
|
||
// / lightmap) to the material's like-named texture parameter.
|
||
// 0=base 1=normal(onxrny) 2=orm/ao/rough/metal 3=emissive 4=lightmap -1=unknown
|
||
auto texRole = [](const std::string& p) -> int {
|
||
std::string s = p; for (auto& c : s) c = (char)tolower((unsigned char)c);
|
||
if (s.find("unsupported") != std::string::npos) return -1;
|
||
// Lightmap: the full word OR the "_lm" suffix (t_sand_lm.png). Without the suffix match the
|
||
// sand's lightmap (t_sand_lm) was mis-classified as BASE color -> the sand floor rendered its
|
||
// lightmap as the texture (broken). (loungeSand worked: its lightmap is named "..._lightmap".)
|
||
if (s.find("lightmap") != std::string::npos ||
|
||
s.find("_lm.") != std::string::npos || s.find("_lm/") != std::string::npos) return 4;
|
||
if (s.find("emissive") != std::string::npos) return 3;
|
||
if (s.find("onxrny") != std::string::npos || s.find("normal") != std::string::npos ||
|
||
s.find("_nrm") != std::string::npos) return 1;
|
||
if (s.find("_orm") != std::string::npos || s.find("rbaodir") != std::string::npos ||
|
||
s.find("_ao") != std::string::npos || s.find("rough") != std::string::npos) return 2;
|
||
if (s.find("basecolor") != std::string::npos || s.find("albedo") != std::string::npos ||
|
||
s.find("diffuse") != std::string::npos || s.find("_color") != std::string::npos) return 0;
|
||
return 0; // default: treat as base color
|
||
};
|
||
|
||
// ── Helper: stable fallback color from mesh name
|
||
auto fallbackColor = [](const std::string& name) -> std::array<u8,3> {
|
||
u32 h = 2166136261u;
|
||
for (char c : name) { h ^= (u8)c; h *= 16777619u; }
|
||
u32 sel = h % 6, mid = 100 + (h >> 8) % 100;
|
||
u8 r,g,b;
|
||
switch (sel) {
|
||
case 0: r=255; g=(u8)mid; b=60; break;
|
||
case 1: r=60; g=255; b=(u8)mid; break;
|
||
case 2: r=(u8)mid; g=60; b=255; break;
|
||
case 3: r=255; g=60; b=(u8)mid; break;
|
||
case 4: r=(u8)mid; g=255; b=60; break;
|
||
default:r=60; g=(u8)mid; b=255; break;
|
||
}
|
||
return {r,g,b};
|
||
};
|
||
|
||
// ── Pre-load all MATLMATL entries from ASMH for universal fallback assignment.
|
||
// Used for APKs where HSTF entities have no MaterialPlatformComponent.
|
||
struct MatEntry {
|
||
AssetKey matKey;
|
||
AssetKey texKey;
|
||
u64 shaderIng = 0;
|
||
u32 shaderTgt = 0;
|
||
bool useBlend = false;
|
||
std::vector<u8> texRGBA;
|
||
u32 texW = 1, texH = 1;
|
||
bool texOk = false;
|
||
};
|
||
std::vector<MatEntry> fallbackMats;
|
||
{
|
||
for (auto& [key, path] : assetMap) {
|
||
if (key.pkg == 0) continue;
|
||
if (path.find(".material/") == std::string::npos) continue;
|
||
auto matData = readAsset(path);
|
||
if (matData.empty()) continue;
|
||
MatlmatlInfo mi;
|
||
if (!parseMatlmatl(matData, mi)) continue;
|
||
MatEntry me;
|
||
me.matKey = key;
|
||
me.shaderIng = mi.shaderIng;
|
||
me.shaderTgt = mi.shaderTgt;
|
||
me.texKey = {mi.texPkg, mi.texIng, mi.texTgt};
|
||
// useBlend: check shader path in assetMap for "blend" or "Blend"
|
||
{
|
||
AssetKey sk = {0, mi.shaderIng, mi.shaderTgt};
|
||
const std::string* sp = resolve(sk);
|
||
if (!sp) { sk.pkg = mi.shaderPkg; sp = resolve(sk); }
|
||
if (sp && (sp->find("blend") != std::string::npos ||
|
||
sp->find("Blend") != std::string::npos ||
|
||
sp->find("flipbook") != std::string::npos || // cook flipbook (waterfall/fog atlas) = blend
|
||
sp->find("_b.surface") != std::string::npos || sp->find("wispscale") != std::string::npos))
|
||
me.useBlend = true;
|
||
}
|
||
// Only all-no-material test scenes need the fallback pixel buffers. Production
|
||
// scenes load referenced materials lazily through applyMat/loadTexture.
|
||
const std::string* tp = allEntitiesNoMat ? resolve(me.texKey) : nullptr;
|
||
if (tp) {
|
||
auto texData = readAsset(*tp);
|
||
if (!texData.empty()) {
|
||
RendtxtrInfo ti;
|
||
if (parseRendtxtrHeader(texData, ti)) {
|
||
u32 rawLen = ti.rawDataLen;
|
||
if (astc::decodeASTC(texData.data() + ti.rawDataOffset, rawLen,
|
||
ti.width, ti.height, ti.blockW, ti.blockH, me.texRGBA)) {
|
||
me.texW = ti.width; me.texH = ti.height; me.texOk = true;
|
||
log(" [FallbackMat] %s -> %s (%ux%u)", path.c_str(), tp->c_str(), ti.width, ti.height);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
fallbackMats.push_back(std::move(me));
|
||
}
|
||
log(" Pre-loaded %zu fallback materials from ASMH", fallbackMats.size());
|
||
}
|
||
|
||
// ── Pre-load unreferenced textures from ASMH (textures NOT used by any material).
|
||
// These are skybox / environment textures not referenced via any MATLMATL.
|
||
// Sort descending by area (largest first = most likely to be the skybox dome).
|
||
struct UnrefTex {
|
||
std::vector<u8> texRGBA;
|
||
u32 texW = 1, texH = 1;
|
||
};
|
||
std::vector<UnrefTex> unrefTextures;
|
||
// Decoding every unreferenced texture up front cost seconds per home and fed only the opt-in guesses.
|
||
if (std::getenv("FH_UNREF_TEX") || std::getenv("HSR_SKYIBL")) {
|
||
// Collect all texIng values already claimed by fallbackMats.
|
||
std::set<u64> usedTexIngs;
|
||
for (auto& me : fallbackMats)
|
||
usedTexIngs.insert(me.texKey.ing);
|
||
|
||
static constexpr u32 TEX_TGT_SENTINEL = 0x6E4CC522u;
|
||
for (auto& [key, path] : assetMap) {
|
||
if (key.pkg == 0) continue; // skip pkg=0 alias entries
|
||
if (key.tgt != TEX_TGT_SENTINEL) continue; // only texture entries
|
||
if (usedTexIngs.count(key.ing)) continue; // skip already-used textures
|
||
|
||
auto texData = readAsset(path);
|
||
if (texData.empty()) continue;
|
||
RendtxtrInfo ti;
|
||
if (!parseRendtxtrHeader(texData, ti)) continue;
|
||
u32 rawLen = ti.rawDataLen;
|
||
UnrefTex ut;
|
||
if (!astc::decodeASTC(texData.data() + ti.rawDataOffset, rawLen,
|
||
ti.width, ti.height, ti.blockW, ti.blockH, ut.texRGBA))
|
||
continue;
|
||
ut.texW = ti.width; ut.texH = ti.height;
|
||
log(" [UnrefTex] %s (%ux%u)", path.c_str(), ti.width, ti.height);
|
||
// V203 IBL capture: the skybox reflectionMap / assets/ibl/ panorama (equirect ASTC-HDR). Prefer a
|
||
// "refl"-named map; gated by HSR_SKYIBL so default rendering is untouched.
|
||
if (std::getenv("HSR_SKYIBL")) {
|
||
std::string lp = path; for (char& c : lp) c = (char)tolower((unsigned char)c);
|
||
bool isRefl = lp.find("/ibl/")!=std::string::npos || lp.find("refl")!=std::string::npos ||
|
||
lp.find("radiance")!=std::string::npos || lp.find("skydome")!=std::string::npos ||
|
||
lp.find("panorama")!=std::string::npos;
|
||
if (isRefl && (iblEquirectRGBA8.empty() || lp.find("refl")!=std::string::npos)) {
|
||
iblEquirectRGBA8 = ut.texRGBA; iblEqW = ti.width; iblEqH = ti.height;
|
||
log(" [SKYIBL] captured IBL equirect: %s (%ux%u)", path.c_str(), ti.width, ti.height);
|
||
}
|
||
}
|
||
unrefTextures.push_back(std::move(ut));
|
||
}
|
||
// Sort descending by area (largest = most likely skybox)
|
||
std::sort(unrefTextures.begin(), unrefTextures.end(),
|
||
[](const UnrefTex& a, const UnrefTex& b) {
|
||
return (a.texW * a.texH) > (b.texW * b.texH);
|
||
});
|
||
log(" Pre-loaded %zu unreferenced textures (sorted by area desc)",
|
||
unrefTextures.size());
|
||
}
|
||
|
||
// ── Helper: apply material info to MeshData (useBlend + texture)
|
||
// A SkyboxPlatformComponent's reflectionMap -> the global IBL reflection cube (ASTC HDR, every cooked level:
|
||
// mip-major, six faces per level), on `md`. Decoded by the renderer at upload.
|
||
auto decodeReflCube = [&](const AssetKey& k, MeshData& md) {
|
||
const std::string* rp = resolve(k);
|
||
if (!rp) return;
|
||
// A streamed cube keeps its top levels in the streams and the rest in the base file: the cooked, blurred
|
||
// levels the shaders read for rough surfaces (Cascadia: 1024 and 512 streamed, 256 to 16 in the base).
|
||
// readTexture takes the largest file alone, which left one sharp level and every floor a mirror; this
|
||
// takes each level, largest first, from whichever file holds it.
|
||
std::vector<std::vector<u8>> files;
|
||
files.push_back(readAsset(*rp));
|
||
const size_t slash = rp->rfind('/');
|
||
auto st = slash == std::string::npos ? texStreams.end() : texStreams.find(rp->substr(0, slash));
|
||
if (st != texStreams.end() && !std::getenv("FH_BASE_TEXTURES"))
|
||
for (const std::string& entry : st->second) {
|
||
int idx = mz_zip_reader_locate_file(&sceneZip, entry.c_str(), nullptr, 0);
|
||
if (idx < 0) continue;
|
||
size_t sz = 0;
|
||
void* d = mz_zip_reader_extract_to_heap(&sceneZip, idx, &sz, 0);
|
||
if (!d) continue;
|
||
files.emplace_back((u8*)d, (u8*)d + sz);
|
||
mz_free(d);
|
||
}
|
||
std::map<u32, std::pair<const u8*, size_t>, std::greater<u32>> levels; // size -> its six faces
|
||
RendtxtrInfo first;
|
||
u32 bw = 0, bh = 0;
|
||
for (const auto& f : files) {
|
||
RendtxtrInfo ri;
|
||
if (!parseRendtxtrHeader(f, ri) || ri.faces != 6 || ri.width != ri.height || (size_t)ri.rawDataOffset + ri.rawDataLen > f.size()) continue;
|
||
if (bw && (ri.blockW != bw || ri.blockH != bh)) continue; // (one block size for the whole chain)
|
||
if (!bw) first = ri;
|
||
bw = ri.blockW; bh = ri.blockH;
|
||
size_t off = ri.rawDataOffset;
|
||
const size_t end = off + ri.rawDataLen;
|
||
for (u32 s = ri.width; s >= 1; s /= 2) {
|
||
const size_t face = (size_t)((s + bw - 1) / bw) * ((s + bh - 1) / bh) * 16;
|
||
if (off + face * 6 > end) break;
|
||
levels.emplace(s, std::make_pair(f.data() + off, face * 6));
|
||
off += face * 6;
|
||
if (s == 1) break;
|
||
}
|
||
}
|
||
if (levels.empty()) {
|
||
log(" [skybox] reflectionMap %s is not a cube (%u faces, %ux%u)", rp->c_str(), first.faces, first.width, first.height);
|
||
return;
|
||
}
|
||
// The chain from the largest level down, as far as it has no gap.
|
||
const u32 top = levels.begin()->first;
|
||
auto t = std::make_shared<TexSource>();
|
||
u32 mips = 0;
|
||
for (u32 s = top; s >= 1; s /= 2) {
|
||
auto it = levels.find(s);
|
||
if (it == levels.end()) break;
|
||
t->astc.insert(t->astc.end(), it->second.first, it->second.first + it->second.second);
|
||
++mips;
|
||
if (s == 1) break;
|
||
}
|
||
t->w = t->h = top; t->bw = bw; t->bh = bh; t->mips = mips; t->hdr = true; t->faces = 6;
|
||
md.reflCubeSrc = std::move(t);
|
||
md.reflCubeHalf.clear();
|
||
md.reflCubeSize = top; md.reflCubeMips = mips;
|
||
log(" [skybox] reflectionMap cube %ux%u, %u mips (from %zu file%s)", top, top, mips, files.size(), files.size() == 1 ? "" : "s");
|
||
};
|
||
|
||
auto applyMat = [&](MatlmatlInfo& matInfo, MeshData& md) -> bool {
|
||
// useBlend: only true alpha-blend shaders. (Masked/vegetation cutouts need
|
||
// alpha-TESTING, not blending — blending them without depth writes bleeds
|
||
// foliage cards over the scene. Faithful cutout handling needs the masked
|
||
// shader variants, a follow-up.)
|
||
md.shaderIng = matInfo.shaderIng; // record which RENDSHAD this material uses
|
||
md.matParamsBlob = matInfo.constBlock; // real per-material matParams (Tint/Layer*/Metallic…)
|
||
md.constParams = matInfo.constParams; // {nameHash,off,size} -> bind by name (MurmurHash3)
|
||
md.texSlots = matInfo.texSlots; // sampler-slot name-hashes -> pick matching shader variant
|
||
AssetKey sk = {0, matInfo.shaderIng, matInfo.shaderTgt};
|
||
const std::string* sp = resolve(sk);
|
||
if (!sp) { sk.pkg = matInfo.shaderPkg; sp = resolve(sk); }
|
||
if (sp) md.shaderPath = *sp; // so the renderer can match it to the global shader
|
||
// FAITHFUL blend (data-proven, NOT name-based): the surface's RENDSHAD forward pass has field 4
|
||
// OMITTED iff its pass is alpha-blended — true for animatedfog + mattepaintingalpha (the "white
|
||
// fog" / "broken matte"), false for every opaque forward shader. Read the .surface + check it.
|
||
// (Only ADDS blend. Other transparents whose pass == opaque are caught by the name rules below.)
|
||
if (sp) {
|
||
auto surfData = readAsset(*sp);
|
||
if (!surfData.empty()) {
|
||
std::vector<SpirvBlob> tmpBlobs; bool passTransparent = false, passA2C = false;
|
||
bool parsed = parseRendShadForward(surfData, tmpBlobs, &passTransparent, &passA2C);
|
||
if (passTransparent) { md.useBlend = true; log(" [blend] '%s' forward pass = ALPHA-BLENDED (blend state omitted)", md.name.c_str()); }
|
||
md.alphaToCoverage = passA2C;
|
||
if (std::getenv("HSR_BLENDDBG")) log(" [blendchk] '%s' surf=%zuB parsed=%d transp=%d sp=%s", md.name.c_str(), surfData.size(), (int)parsed, (int)passTransparent, sp->c_str());
|
||
}
|
||
}
|
||
if (sp && (sp->find("blend") != std::string::npos ||
|
||
sp->find("Blend") != std::string::npos))
|
||
md.useBlend = true;
|
||
// The cook's GENERATED rotation / oscillation / uv-scroll shaders use a "_b" filename-suffix for their
|
||
// BLEND variant (rot_<hash>_b.surface, osc_*_b, uvscroll_*_b, +optional _dc) — built from the unlitblend
|
||
// base, so they ARE alpha-blended, but the name carries no "blend" token and the forward pass-state matches
|
||
// unlitblend's (which the renderer detects by NAME, not f4-omission). Without this the Outer Wilds planet /
|
||
// erebor wisp billboards render OPAQUE in HSL-mode preview — their transparent margins become solid boxes,
|
||
// so a planet's whole quad shows (looks huge / "super close"). Match the cook's _b suffix convention.
|
||
if (sp && (sp->find("_b.surface") != std::string::npos || sp->find("_b_dc.surface") != std::string::npos))
|
||
md.useBlend = true;
|
||
// The cook's ADDITIVE uv-scroll variant (uvscroll_<hash>_a.surface — lake/stream FOAM: ShellEnv Additive:true)
|
||
// is the OPAQUE-pass uvscroll shader kept in a transparent MATL = ADDITIVE on device. Its name has no "blend"/"_b"
|
||
// token, so HSL-mode read it OPAQUE -> the dark/low-alpha foam texture rendered as a solid black card over the lake
|
||
// (user: "don't see lake_foam in the cooked HSL"). Mark additive+blend so the preview ADDS the premultiplied foam.
|
||
if (sp && (sp->find("_a.surface") != std::string::npos || sp->find("_a_dc.surface") != std::string::npos)) {
|
||
md.useBlend = true; md.additive = true;
|
||
}
|
||
// The cook's wisp-breathe shader (wispscale.surface, the erebor flame wisps) is built from the unlitblend
|
||
// base + blend material, but its name has no "blend" token -> same opaque-box bug as the _b shaders above.
|
||
if (sp && sp->find("wispscale") != std::string::npos) md.useBlend = true;
|
||
// vatlitbubble is a TRANSLUCENT shader (its frag computes alpha/alphaSq/fogAlpha + ACES tonemap)
|
||
// but its name has no "blend" token, so it was drawn in the OPAQUE pass — the alpha was ignored
|
||
// and the bubbles rendered as solid GRAY spheres. Mark bubble shaders blend so the alpha shows.
|
||
if (sp && sp->find("bubble") != std::string::npos) md.useBlend = true;
|
||
// Foliage/card shaders (unlitfoliage — the vista MERGED_BG rocks/cliffs, cWeed/coral cards) are
|
||
// alpha CUTOUTS: the texture's alpha=0 border must drop out, not render as solid black. The frag
|
||
// discards by design but the per-material variant pick doesn't always hit the discard variant, so
|
||
// mark it alpha-test (and blend as the safety net) to kill the black halo.
|
||
if (sp && sp->find("foliage") != std::string::npos) { md.alphaTest = true; md.useBlend = true; }
|
||
// The cook's CUTOUT shader (unlitcutout.surface — the "_masked" ground/terrain) is opaque-pass + alpha-test
|
||
// DISCARD; its name has no "blend" token so HSL-mode read it OPAQUE -> the hard-alpha border rendered as
|
||
// solid black. Mark alpha-test so the discard drops the transparent texels (user: "transparent bg black").
|
||
if (sp && sp->find("cutout") != std::string::npos) md.alphaTest = true;
|
||
// The cook's FLIPBOOK shader (flipbook_*.surface — the waterfall/stream/fog ATLAS, incl. the OFFSET "...o"
|
||
// variant) is built from the unlitblend base = ALPHA-BLEND, but the name carries no "blend" token -> HSL-mode
|
||
// drew it OPAQUE (the soft fog/waterfall rendered as a solid black card). Mark blend so the alpha shows.
|
||
if (sp && sp->find("flipbook") != std::string::npos) md.useBlend = true;
|
||
// Transparent atmospheric/water shaders that lack a "blend" token but ARE alpha-blended on device:
|
||
// unlitmist (the mist planes rendered as SOLID BLOCKS), lakesurf/water (transparent surface), smoke.
|
||
if (sp && (sp->find("mist") != std::string::npos || sp->find("smoke") != std::string::npos ||
|
||
sp->find("lakesurf") != std::string::npos || sp->find("water") != std::string::npos))
|
||
md.useBlend = true;
|
||
// animatedfog (atmospheric_fog_*, waterfallSplash) + mattepaintingalpha (waterfall_matte) are
|
||
// ALPHA-BLENDED on device, but their forward pass-state is byte-identical to OPAQUE (proven via
|
||
// readAsset f4-check: transp=0) — the blend lives in the MATL render-queue (not yet parsed). Their
|
||
// base texture is opaque (alpha=255) so the device shader computes the fog/matte alpha itself; we
|
||
// just need the alpha-blend pipeline. Match by device shader name as the render-queue proxy until
|
||
// the MATL queue field is decoded. (The skybox 'skyboxwithglobalfog' is excluded — opaque dome.)
|
||
if (sp && (sp->find("animatedfog") != std::string::npos || sp->find("mattepainting") != std::string::npos))
|
||
md.useBlend = true;
|
||
// The "*alpha.surface" family (unlitalpha tree cards, mattepaintingalpha) is the blended queue in the SHAD
|
||
// pass state (HSR Studio: sortClass "blended", src-alpha / one-minus-src-alpha, no depth write). Drawn
|
||
// opaque, the cards' transparent texels showed as grey boxes around the background trees.
|
||
if (sp && sp->find("alpha.surface") != std::string::npos && !md.additive)
|
||
md.useBlend = true;
|
||
// Candle FLAME / glow = ADDITIVE emissive (self-lit, brightens the scene; not opaque).
|
||
if (sp && (sp->find("flame") != std::string::npos || sp->find("glow") != std::string::npos)) {
|
||
md.useBlend = true; md.additive = true;
|
||
}
|
||
// FAITHFUL (the cook's own contract, not a name rule): the MATL is a clone of OUR transparent
|
||
// template (realdome unlitblend — byte-fingerprinted in parseMatlmatl). That alone = alpha-blend.
|
||
// Paired with an OPAQUE-family shader (none of the name rules above fired) it is the cook's
|
||
// ADDITIVE-GLOW signature — the opaque-pass shader kept in a transparent MATL = src+dst on device
|
||
// (godRays/warp/nebula). HSL preview read it OPAQUE -> storybook godRays drew as a huge DARK sheet.
|
||
if (matInfo.isCookBlendTpl && !md.useBlend) {
|
||
md.useBlend = true; md.additive = true;
|
||
log(" [blend] '%s' cook BLEND-template MATL + opaque-pass shader = ADDITIVE glow", md.name.c_str());
|
||
}
|
||
// OPAQUE-SKINNED cook template = authoritative OPAQUE + depth-write. Overrides the f4-omission
|
||
// heuristic, which misreads SKINNED surface layouts (skinuv/unlitdoublesidedskinned report
|
||
// transp=1) and classified every cooked skinned mesh as alpha-blend/no-depth-write — the
|
||
// storybook river's overlapping opaque cards z-fought and composited dark vs OPA.
|
||
if (matInfo.isCookOpaqueSkinTpl && md.useBlend) {
|
||
md.useBlend = false; md.additive = false;
|
||
log(" [blend] '%s' cook OPAQUE-SKINNED MATL -> forced opaque/depth-write (skinned f4 misread)", md.name.c_str());
|
||
}
|
||
|
||
// Load EVERY texture the material references into its role slot, exactly as
|
||
// libshell binds each shader texture resource by name. A HAVEN prop material
|
||
// carries base-color(+metallic), ONxRNy(normal+rough+AO), and sometimes ORM /
|
||
// emissive / lightmap. The base-color is the one bound to BaseColorMetallic_Tx.
|
||
// Base-color selection: a material may list the SYSTEM white.png default FIRST and the REAL
|
||
// base-colour texture (e.g. t_fabricbeige_fabricteal_basecolormetallic = the blue cushion
|
||
// fabric) after it. Picking "first base wins" grabbed white -> washed/flat props. PREFER a
|
||
// texture whose name actually marks it as a base colour ("basecolor"/"albedo"/"diffuse") over
|
||
// the generic white/renderer_module default.
|
||
u64 baseIng = 0, defaultBaseIng = 0;
|
||
// FAITHFUL texture-role assignment by samplerNameHash (device binds each texture to its shader
|
||
// sampler by MurmurHash3(samplerName,0) — see matlmatl textureValues). The old filename heuristic
|
||
// (texRole) missed horror's lightmaps (named _lm/.hdr/candle_lightmap) -> gm.lmView stayed WHITE
|
||
// -> the baked-dark scene washed bright. PROVEN: MurmurHash3("baseColorTex")==0xe9c182de.
|
||
auto roleFromSamplerHash = [](u32 h) -> int {
|
||
static const std::pair<std::string,int> kS[] = {
|
||
{"lightmap",4},{"Lightmap_Tx",4},{"LightMap_Tx",4},
|
||
{"ONxRNy_Tx",1},{"Normal_Tx",1},
|
||
{"RBAoDir_Tx",2},{"metalnessRoughnessOcclusionTex",2},
|
||
{"EmissiveColor_Tx",3},{"Emissive_Tx",3},{"emissiveTex",3},
|
||
{"baseColorTex",0},{"BaseColorMetallic_Tx",0},{"BaseColor_Tx",0},
|
||
};
|
||
for (auto& s : kS) if (murmur3_x86_32(s.first.data(), s.first.size(), 0) == h) return s.second;
|
||
return -1;
|
||
};
|
||
auto samplerHashForIng = [&](u64 ing) -> u32 {
|
||
for (auto& st : matInfo.samplerTex) if (st.second == ing) return st.first;
|
||
return 0;
|
||
};
|
||
for (u64 ing : matInfo.texIngs) {
|
||
AssetKey k = {0, ing, matInfo.texTgt};
|
||
const std::string* tp = resolve(k);
|
||
if (!tp) { k.pkg = matInfo.texPkg; tp = resolve(k); }
|
||
if (!tp) continue;
|
||
{ // NEVER bind renderer_module/missing_lightmap.png — it's the "no baked lightmap yet"
|
||
// PLACEHOLDER (gray 64x64) the device swaps for the real baked lightmap at runtime.
|
||
// It was a role-4 entry listed AFTER the real lightmap (candle_lightmap) and OVERWROTE
|
||
// md.lmRGBA -> the candle (and others) rendered gray. Skip it so the real lightmap wins
|
||
// (and meshes that ONLY have it fall to the white default = show base albedo, not gray).
|
||
std::string ln=*tp; for (char& c:ln) c=(char)tolower((unsigned char)c);
|
||
if (ln.find("missing_lightmap") != std::string::npos) {
|
||
// ...but it IS what HSR Studio (and the device, until it bakes) samples in a lightmap slot
|
||
// that has no baked lightmap: the material's nameless binding. Offered by sampler name;
|
||
// a real baked lightmap (md.lmHalf) still takes precedence in the renderer.
|
||
if (g_deferTextures) {
|
||
auto d = decodeCached(*tp, false, false, true);
|
||
if (d->src)
|
||
for (const char* nm : {"lightmap", "LightMap_Tx", "lightMap", "Lightmap_Tx"})
|
||
md.namedTex.push_back({murmur3_x86_32(nm, std::strlen(nm), 0), d->src});
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
{ // VAT offset texture (half-float) — decode separately, never as ASTC/base.
|
||
std::string ln = *tp; for (char& c : ln) c = (char)tolower((unsigned char)c);
|
||
if (ln.find("vatdata") != std::string::npos) {
|
||
AssetKey tk = {matInfo.texPkg, ing, matInfo.texTgt};
|
||
if (decodeVatFloat(tk, md)) log(" [vat] %s (%ux%u f16)", tp->c_str(), md.vatW, md.vatH);
|
||
continue;
|
||
}
|
||
}
|
||
u32 sh = samplerHashForIng(ing);
|
||
int role = sh ? roleFromSamplerHash(sh) : -1; // device-faithful: role by samplerNameHash
|
||
if (role < 0) role = texRole(*tp); // fallback: filename heuristic
|
||
log(" [tex] ing=%016llX role=%d samplerHash=%08X %s", (unsigned long long)ing, role, sh, tp->c_str());
|
||
AssetKey tk = {matInfo.texPkg, ing, matInfo.texTgt};
|
||
// Every texture also by its SAMPLER name hash (the device binds exactly this way), so custom slots
|
||
// — flipbookTex / flipbookMask / AmbienceTint_Tx / noise maps — get their own texture, not white.
|
||
if (sh && g_deferTextures) {
|
||
std::string ln = *tp; for (char& c : ln) c = (char)tolower((unsigned char)c);
|
||
const bool hdrPath = ln.find(".hdr") != std::string::npos || ln.find(".exr") != std::string::npos;
|
||
if (hdrPath && ln.find("vatdata") == std::string::npos) {
|
||
// HDR maps by sampler name too (reflectionPano...), decoded once (or kept as ASTC).
|
||
const HdrEntry& hc = hdrMap(*tp, true);
|
||
if (hc.half || hc.src)
|
||
for (auto& st : matInfo.samplerTex)
|
||
if (st.second == ing)
|
||
md.namedHdr.push_back({st.first, {hc.half, hc.w, hc.h, hc.src}});
|
||
}
|
||
if (!hdrPath && ln.find("vatdata") == std::string::npos) {
|
||
auto d = decodeCached(*tp, false, false, true);
|
||
// A texture can feed several samplers (baseColorTex1 + baseColorTex2, overlayTex +
|
||
// overlayMask): bind it under every sampler hash that references it.
|
||
if (d->src)
|
||
for (auto& st : matInfo.samplerTex)
|
||
if (st.second == ing) md.namedTex.push_back({st.first, d->src});
|
||
}
|
||
}
|
||
switch (role) {
|
||
case 1: if (decodeTexInto(tk, md.normalRGBA, md.normalW, md.normalH, &md.normalSrc)) {
|
||
md.hasNormal = true; log(" [normal] %s", tp->c_str());
|
||
// ONxRNy is a COMBINED occlusion/normal/roughness map; the V203 PBR shaders
|
||
// (isotropicemissiveusd etc.) bind it at metalnessRoughnessOcclusionTex and have
|
||
// NO separate normal slot — so ALSO feed it to ORM (the shader reads O/R from it).
|
||
{ std::string ln=*tp; for (char& c : ln) c=(char)tolower((unsigned char)c);
|
||
if (ln.find("onxrny")!=std::string::npos && !md.hasOrm) {
|
||
md.ormRGBA=md.normalRGBA; md.ormSrc=md.normalSrc; md.ormW=md.normalW; md.ormH=md.normalH; md.hasOrm=true; } }
|
||
} break;
|
||
case 2: if (decodeTexInto(tk, md.ormRGBA, md.ormW, md.ormH, &md.ormSrc)) {
|
||
md.hasOrm = true; md.ormTexName = *tp; log(" [orm] %s", tp->c_str()); } break;
|
||
case 4: {
|
||
// ⛔ The pbrlightmap materials reference white.png / a renderer_module default in
|
||
// their lightmap slot as a PLACEHOLDER (the device swaps it for the real baked
|
||
// lightmap at runtime, like missing_lightmap.png). Binding it set hasLightmap=true,
|
||
// which made applyLightmapOverrides SKIP the mesh (line "if md.hasLightmap continue")
|
||
// -> the REAL baked lightmap (shipped by the vista's *_lm override) never bound ->
|
||
// haven ceiling/cushions/candles rendered FLAT/DARK ("not lit"). Skip the placeholder
|
||
// so the override chain binds the real lightmap.
|
||
std::string ln=*tp; for (char& c:ln) c=(char)tolower((unsigned char)c);
|
||
bool placeholder = ln.find("white.png")!=std::string::npos || ln.find("renderer_module")!=std::string::npos;
|
||
if (placeholder) break;
|
||
// The real HDR radiance (what the shader samples), decoded once per lightmap.
|
||
const HdrEntry& hc = hdrMap(*tp, false);
|
||
if (hc.half || hc.src) {
|
||
md.lmHalf = hc.half; md.lmSrc = hc.src;
|
||
md.lmW = hc.w; md.lmH = hc.h;
|
||
md.hasLightmap = true; log(" [lightmap] %s", tp->c_str());
|
||
// The tonemapped 8-bit preview only when not streaming (the renderer uses lmHalf).
|
||
if (!g_deferTextures) decodeTexInto(tk, md.lmRGBA, md.lmW, md.lmH);
|
||
} else if (decodeTexInto(tk, md.lmRGBA, md.lmW, md.lmH)) {
|
||
md.hasLightmap = true; log(" [lightmap] %s", tp->c_str());
|
||
}
|
||
} break;
|
||
case 3: if (decodeTexInto(tk, md.emissiveRGBA, md.emissiveW, md.emissiveH, &md.emissiveSrc)) {
|
||
md.hasEmissive = true; log(" [emissive] %s", tp->c_str()); } break;
|
||
case 0: default: {
|
||
std::string ln = *tp; for (char& c : ln) c = (char)tolower((unsigned char)c);
|
||
bool isRealBase = ln.find("basecolor") != std::string::npos ||
|
||
ln.find("albedo") != std::string::npos ||
|
||
ln.find("diffuse") != std::string::npos;
|
||
bool isDefault = ln.find("renderer_module") != std::string::npos ||
|
||
ln.find("white.png") != std::string::npos;
|
||
if (isRealBase && !isDefault) { if (!baseIng) baseIng = ing; } // the REAL fabric/albedo
|
||
else if (!defaultBaseIng) defaultBaseIng = ing; // white / generic fallback
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!baseIng) baseIng = defaultBaseIng;
|
||
if (!baseIng && !matInfo.texIngs.empty()) baseIng = matInfo.texIngs.front();
|
||
AssetKey texKey = {matInfo.texPkg, baseIng, matInfo.texTgt};
|
||
bool ok = loadTexture(texKey, md);
|
||
log(" [base] ing=%016llX normal=%d orm=%d lm=%d",
|
||
(unsigned long long)baseIng, (int)md.hasNormal, (int)md.hasOrm, (int)md.hasLightmap);
|
||
return ok;
|
||
};
|
||
|
||
// In all-no-material test scenes, cycle fallback materials across the entities.
|
||
// Normal production/cooked scenes use their per-entity material references.
|
||
log(" allEntitiesNoMat=%d — %s fallback cycling",
|
||
(int)allEntitiesNoMat, allEntitiesNoMat ? "ENABLING" : "disabling");
|
||
|
||
// ── Step 7: Load mesh + material + texture for each HSTF entity
|
||
int loadedCount = 0;
|
||
int failedCount = 0;
|
||
std::set<std::string> claimedPaths;
|
||
size_t fallbackMatIdx = 0; // cycles through fallbackMats for no-matRef entities
|
||
size_t unrefTexIdx = 0; // cycles through unrefTextures for no-mat prod entities
|
||
|
||
// ── Baked-LIGHTMAP OVERRIDE map (THE warm room lighting). V205 binds each mesh's baked lightmap
|
||
// (t_<mesh>_lmhdr[2].hdr under .../lightmaps/) BY NAME, NOT via material textureValues — so they
|
||
// show up "unreferenced" and the room renders FLAT (vs the device's soft warm baked light). Build
|
||
// name->AssetKey so each mesh fetches its lightmap. (Vista envs ship these; haven2025 alone doesn't.)
|
||
auto lmKeyOf = [](std::string p) -> std::string {
|
||
for (auto& c : p) c = (char)tolower((unsigned char)c);
|
||
// the asset path is ".../lightmaps/t_<mesh>_lmhdr[2].hdr/tex_r676" — the lightmap name is the
|
||
// PATH COMPONENT containing "_lmhdr", NOT the basename ("tex_r676"). Extract that component.
|
||
size_t h = p.find("_lmhdr"); if (h == std::string::npos) return "";
|
||
size_t s = p.rfind('/', h); s = (s == std::string::npos) ? 0 : s + 1;
|
||
std::string name = p.substr(s, h - s); // e.g. "t_sm_platformblocks"
|
||
if (name.rfind("t_", 0) == 0) name = name.substr(2);
|
||
size_t sh = name.find("_shell"); if (sh != std::string::npos) name = name.substr(0, sh);
|
||
// strip prefixes in ANY order (sm_merged_cabinetb -> cabinetb, sm_lbg_x -> x) so the lightmap
|
||
// name ("t_sm_merged_cabinetb_shell") matches the mesh ("SM_LBG_cabinetB").
|
||
for (bool ch = true; ch; ) { ch = false;
|
||
for (std::string pre : {std::string("sm_"),std::string("merged_"),std::string("lbg_"),std::string("inst_")})
|
||
if (name.rfind(pre,0)==0) { name = name.substr(pre.size()); ch = true; } }
|
||
return name;
|
||
};
|
||
for (auto& [key, path] : assetMap) {
|
||
if (path.find("_lmhdr") == std::string::npos) continue;
|
||
std::string k = lmKeyOf(path);
|
||
if (k.empty() || lmRawByKey.count(k)) continue;
|
||
auto raw = readTexture(path);
|
||
if (!raw.empty()) lmRawByKey[k] = std::move(raw);
|
||
}
|
||
log(" Lightmap-override: cached %zu baked lightmaps", lmRawByKey.size());
|
||
|
||
// ── DEVICE-FAITHFUL baked-lightmap mapping (JSON): cooked USD templates give meshGUID->name; the
|
||
// calming_lightmap_overrides *_lm.hstf give sourceId(meshGUID)->lightMapTexture(the _lmhdr asset).
|
||
{
|
||
auto guid13 = [](const std::string& s) -> std::string {
|
||
size_t sl = s.find_last_of('/'); std::string g = (sl==std::string::npos)?s:s.substr(sl+1);
|
||
return g.substr(0, 13);
|
||
};
|
||
int nf_lm = (int)mz_zip_reader_get_num_files(&sceneZip);
|
||
mz_zip_archive_file_stat lmst;
|
||
for (int zi = 0; zi < nf_lm; ++zi) {
|
||
if (!mz_zip_reader_file_stat(&sceneZip, zi, &lmst)) continue;
|
||
std::string path = lmst.m_filename; // "content/meta/.../X.usda/template_9k0v"
|
||
bool isUsdTpl = path.find(".usda/template") != std::string::npos;
|
||
bool isOverride = path.find("_lm.hstf") != std::string::npos ||
|
||
path.find("lightmap_overrides") != std::string::npos;
|
||
bool isSpace = path.find("space.hstf") != std::string::npos;
|
||
if (!isUsdTpl && !isOverride && !isSpace) continue;
|
||
size_t zsz = 0; void* zd = mz_zip_reader_extract_to_heap(&sceneZip, zi, &zsz, 0);
|
||
if (!zd) continue;
|
||
std::string t((const char*)zd, zsz); mz_free(zd);
|
||
if (t.empty() || t[0] != '{') continue;
|
||
// A space.hstf can carry the per-mesh lightingParams deltas itself (prefab instance overrides).
|
||
if (isSpace && t.find("\"lightMapTexture\"") != std::string::npos) isOverride = true;
|
||
if (isSpace) { // ScenePlatformComponent/ClippingPlanesComponent: the DEVICE's finite far/near
|
||
auto num = [&](const char* k) -> float { size_t q=t.find(k); if(q==std::string::npos) return 0.f;
|
||
size_t c=t.find(':',q+strlen(k)); if(c==std::string::npos) return 0.f;
|
||
return (float)atof(t.c_str()+c+1); };
|
||
float fc=num("\"farClippingPlane\""), nc=num("\"nearClippingPlane\"");
|
||
if (fc>0.f) { sceneFarClip=fc; if(nc>0.f) sceneNearClip=nc;
|
||
log(" Scene clip planes (device-faithful): near=%.3f far=%.0f", sceneNearClip, sceneFarClip); }
|
||
}
|
||
if (t.empty() || (!isUsdTpl && !isOverride)) continue;
|
||
// whitespace-tolerant: from key opening-quote pos, read its quoted string value (JSON is
|
||
// pretty-printed: `"id": "..."` with spaces). Returns the value's closing-quote pos.
|
||
auto vstr = [&](size_t keyPos, size_t keyLen, std::string& out) -> size_t {
|
||
size_t c = t.find(':', keyPos + keyLen); if (c == std::string::npos) return std::string::npos;
|
||
size_t q1 = t.find('"', c + 1); if (q1 == std::string::npos) return std::string::npos;
|
||
size_t q2 = t.find('"', q1 + 1); if (q2 == std::string::npos) return std::string::npos;
|
||
out = t.substr(q1 + 1, q2 - q1 - 1); return q2;
|
||
};
|
||
if (isUsdTpl) { // "id": "<guid>" ... "name": "<name>"
|
||
size_t p = 0;
|
||
while ((p = t.find("\"id\"", p)) != std::string::npos) {
|
||
std::string idv; size_t ke = vstr(p, 4, idv);
|
||
if (ke == std::string::npos) { p += 4; continue; }
|
||
std::string gd = guid13(idv);
|
||
size_t nmpos = t.find("\"name\"", ke), nextid = t.find("\"id\"", ke);
|
||
if (nmpos != std::string::npos && (nextid == std::string::npos || nmpos < nextid)) {
|
||
std::string nm; vstr(nmpos, 6, nm);
|
||
if (!nm.empty() && !lmGuidName.count(gd)) lmGuidName[gd] = nm;
|
||
}
|
||
p = ke;
|
||
}
|
||
}
|
||
if (isOverride) { // "sourceId": "<guid>" ... "lightMapTexture": {...}
|
||
size_t p = 0;
|
||
while ((p = t.find("\"sourceId\"", p)) != std::string::npos) {
|
||
std::string srcv; size_t ke = vstr(p, 10, srcv);
|
||
if (ke == std::string::npos) { p += 10; continue; }
|
||
std::string gd = guid13(srcv);
|
||
size_t nextSrc = t.find("\"sourceId\"", ke);
|
||
size_t lm = t.find("\"lightMapTexture\"", ke);
|
||
if (lm != std::string::npos && (nextSrc == std::string::npos || lm < nextSrc) && !lmGuidRaw.count(gd)) {
|
||
size_t ip = t.find("\"ingestionId\"", lm), pp = t.find("\"packageOrRemoteId\"", lm), tp = t.find("\"targetId\"", lm);
|
||
std::string ing, pkg, tgt;
|
||
if (ip != std::string::npos && (nextSrc==std::string::npos || ip<nextSrc)) vstr(ip, 13, ing);
|
||
if (pp != std::string::npos && (nextSrc==std::string::npos || pp<nextSrc)) vstr(pp, 19, pkg);
|
||
if (tp != std::string::npos && (nextSrc==std::string::npos || tp<nextSrc)) { size_t c=t.find(':',tp+10); if(c!=std::string::npos){ size_t e=t.find_first_of(",}",c+1); tgt=t.substr(c+1,e-c-1);
|
||
// the value may be a bare number or a quoted string ("targetId":"1850524962")
|
||
tgt.erase(std::remove_if(tgt.begin(), tgt.end(), [](char ch){ return ch=='"' || std::isspace((unsigned char)ch); }), tgt.end()); } }
|
||
if (!ing.empty()) {
|
||
AssetKey lk{}; lk.ing = strtoull(ing.c_str(),nullptr,10);
|
||
lk.pkg = strtoull(pkg.c_str(),nullptr,10); lk.tgt = (u32)strtoul(tgt.c_str(),nullptr,10);
|
||
const std::string* lp = resolve(lk);
|
||
if (!lp) { lk.pkg = 0; lp = resolve(lk); }
|
||
if (lp) { auto lr = readTexture(*lp); if (!lr.empty()) lmGuidRaw[gd] = std::move(lr); }
|
||
}
|
||
// Atlas placement: lightmapUvScale / lightmapUvOffset {x, y} in the same delta.
|
||
auto vec2 = [&](const char* k, float& x, float& y) -> bool {
|
||
size_t q = t.find(k, ke);
|
||
if (q == std::string::npos || (nextSrc != std::string::npos && q > nextSrc)) return false;
|
||
size_t qx = t.find("\"x\"", q), qy = t.find("\"y\"", q);
|
||
if (qx == std::string::npos || qy == std::string::npos) return false;
|
||
x = (float)atof(t.c_str() + t.find(':', qx) + 1);
|
||
y = (float)atof(t.c_str() + t.find(':', qy) + 1);
|
||
return true;
|
||
};
|
||
std::array<float,4> uv = {1, 1, 0, 0};
|
||
bool hs = vec2("\"lightmapUvScale\"", uv[0], uv[1]);
|
||
bool ho = vec2("\"lightmapUvOffset\"", uv[2], uv[3]);
|
||
if (hs || ho) lmGuidUv[gd] = uv;
|
||
}
|
||
p = ke;
|
||
}
|
||
}
|
||
}
|
||
log(" Lightmap GUID chain: %zu mesh-names, %zu override lightmaps", lmGuidName.size(), lmGuidRaw.size());
|
||
}
|
||
|
||
// ── Per-object material overrides carried as deltas in any HSTF (as HSR Studio applies them).
|
||
{
|
||
auto guid13 = [](const std::string& s) -> std::string {
|
||
size_t sl = s.find_last_of('/'); std::string g = (sl==std::string::npos)?s:s.substr(sl+1);
|
||
return g.substr(0, 13);
|
||
};
|
||
auto keyOf = [](const tinyjson::Value& m) -> AssetKey {
|
||
AssetKey k{};
|
||
auto num = [&](const char* f) -> u64 {
|
||
if (!m.has(f)) return 0;
|
||
const auto& v = m[f];
|
||
return v.isString() ? strtoull(v.asString().c_str(), nullptr, 10) : (u64)v.asFloat();
|
||
};
|
||
k.pkg = num("packageOrRemoteId"); k.ing = num("ingestionId"); k.tgt = (u32)num("targetId");
|
||
return k;
|
||
};
|
||
int nf = (int)mz_zip_reader_get_num_files(&sceneZip);
|
||
mz_zip_archive_file_stat st;
|
||
size_t nConst = 0, nTex = 0;
|
||
for (int zi = 0; zi < nf; ++zi) {
|
||
if (!mz_zip_reader_file_stat(&sceneZip, zi, &st)) continue;
|
||
const std::string path = st.m_filename;
|
||
if (path.find(".hstf/") == std::string::npos) continue;
|
||
size_t zsz = 0; void* zd = mz_zip_reader_extract_to_heap(&sceneZip, zi, &zsz, 0);
|
||
if (!zd) continue;
|
||
std::string t((const char*)zd, zsz); mz_free(zd);
|
||
if (t.empty() || t[0] != '{' || t.find("\"sourceId\"") == std::string::npos ||
|
||
t.find("MaterialPropertyOverrides") == std::string::npos) continue;
|
||
tinyjson::Value doc;
|
||
try { doc = tinyjson::parse(t); } catch (...) { continue; }
|
||
if (!doc.has("entities") || !doc["entities"].isArray()) continue;
|
||
const auto& ents = doc["entities"];
|
||
for (size_t ei = 0; ei < ents.size(); ++ei) {
|
||
const auto& e = ents[ei];
|
||
if (!e.has("deltas") || !e["deltas"].isArray()) continue;
|
||
const auto& ds = e["deltas"];
|
||
for (size_t di = 0; di < ds.size(); ++di) {
|
||
const auto& d = ds[di];
|
||
if (!d.has("sourceId") || !d.has("components") || !d["components"].isArray()) continue;
|
||
const std::string g = guid13(d["sourceId"].asString());
|
||
const auto& cs = d["components"];
|
||
for (size_t ci = 0; ci < cs.size(); ++ci) {
|
||
if (!cs[ci].has("data")) continue;
|
||
const auto& cd = cs[ci]["data"];
|
||
if (!cd.has("class") || cd["class"].asString().find("MaterialPropertyOverrides") == std::string::npos) continue;
|
||
if (!cd.has("data") || !cd["data"].isObject()) continue;
|
||
const auto& dat = cd["data"];
|
||
if (dat.has("constantParameters") && dat["constantParameters"].isArray()) {
|
||
const auto& cps = dat["constantParameters"];
|
||
for (size_t k = 0; k < cps.size(); ++k) {
|
||
const auto& cp = cps[k].has("data") ? cps[k]["data"] : tinyjson::Value();
|
||
if (!cp.has("parameterName") || !cp.has("value")) continue;
|
||
const auto& v = cp["value"];
|
||
MatOverride ov; ov.name = cp["parameterName"].asString();
|
||
const char* f[4] = {"x", "y", "z", "w"};
|
||
for (int c = 0; c < 4; ++c) ov.v[c] = v.has(f[c]) ? (float)v[f[c]].asFloat() : 0.0f;
|
||
deltaConsts[g].push_back(ov);
|
||
++nConst;
|
||
}
|
||
}
|
||
if (dat.has("textureParameters") && dat["textureParameters"].isArray()) {
|
||
const auto& tps = dat["textureParameters"];
|
||
for (size_t k = 0; k < tps.size(); ++k) {
|
||
const auto& tp = tps[k].has("data") ? tps[k]["data"] : tinyjson::Value();
|
||
if (!tp.has("parameterName") || !tp.has("texture")) continue;
|
||
const std::string pn = tp["parameterName"].asString();
|
||
const std::string* tpath = resolve(keyOf(tp["texture"]));
|
||
if (!tpath) continue;
|
||
std::string ln = pn; for (auto& c : ln) c = (char)tolower((unsigned char)c);
|
||
if (ln.find("lightmap") != std::string::npos) {
|
||
if (!deltaLightmap.count(g)) { auto raw = readTexture(*tpath); if (!raw.empty()) deltaLightmap[g] = std::move(raw); }
|
||
} else {
|
||
auto dtex = decodeCached(*tpath, false, false, true);
|
||
if (dtex->src) deltaTex[g].emplace_back(murmur3_x86_32(pn.data(), pn.size(), 0), dtex->src);
|
||
}
|
||
++nTex;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
log(" Material-override deltas: %zu constants, %zu textures over %zu objects", nConst, nTex,
|
||
deltaConsts.size() + deltaTex.size() + deltaLightmap.size());
|
||
}
|
||
|
||
g_loadProgress.set("Loading meshes + textures...", 0, (int)entities.size());
|
||
for (size_t ei = 0; ei < entities.size(); ++ei) {
|
||
g_loadProgress.tick((int)ei);
|
||
auto& obj = entities[ei];
|
||
log("========================================");
|
||
log("Entity[%zu] '%s'", ei, obj.name.c_str());
|
||
|
||
// ── Resolve mesh
|
||
const std::string* meshPath = resolve(obj.meshRef);
|
||
if (!meshPath) {
|
||
char b[64]; snprintf(b,sizeof b,"ing=%016llX tgt=%08X",(unsigned long long)obj.meshRef.ing,obj.meshRef.tgt);
|
||
recordSkip(std::string("mesh not in manifest: ")+b);
|
||
log(" SKIP: mesh not in manifest (ing=%016llX tgt=%08X)",
|
||
(unsigned long long)obj.meshRef.ing, obj.meshRef.tgt);
|
||
failedCount++;
|
||
continue;
|
||
}
|
||
log(" Mesh: %s", meshPath->c_str());
|
||
claimedPaths.insert(*meshPath);
|
||
|
||
// Skip non-visual meshes. Two classes, both authored alongside render
|
||
// geometry but NOT drawn in the environment by libshell:
|
||
// (a) physics/navigation: __phys_mesh, _col_mesh/_COL, navmesh.
|
||
// (b) UI/interaction gizmos: wall/object PLACEMENT markers, hotspot dots,
|
||
// and 2D ICONS (ChairIcon etc.) — flat 1x1 quads with an icon atlas
|
||
// that showed up as a stray red/orange strip. These are contextual UI.
|
||
int overlayKind = 0;
|
||
{
|
||
std::string low = *meshPath, nlow = obj.name;
|
||
for (auto& c : low) c = (char)tolower((unsigned char)c);
|
||
for (auto& c : nlow) c = (char)tolower((unsigned char)c);
|
||
auto npos = std::string::npos;
|
||
bool isPhys = low.find("__phys_") != npos;
|
||
bool isNav = low.find("navmesh") != npos || nlow.find("navmesh") != npos;
|
||
bool isCol = low.find("_col_mesh") != npos || nlow.find("_col") != npos || nlow.find("collision") != npos;
|
||
bool isUI = nlow.find("placement") != npos || nlow.find("hotspot") != npos || nlow.find("icon") != npos;
|
||
// "blkt" = BLOCKOUT/greybox proxy: every blktGardenWall/blktDeskWall/blktPortalWall/blktBackWall/
|
||
// blktFloorBlock/blktPlatformBlockB is a flat untextured (stride-12) twin of a DETAILED mesh
|
||
// (gardenWall, deskWall, ...). Rendering it overlaps & HIDES the real grooved wall ("misplaced,
|
||
// needs groves"). libshell doesn't show greybox — the detailed twin is the visual. Skip it.
|
||
// ⛔ blkt (blockout) meshes are REAL render geometry — DO NOT skip them. IDA-VERIFIED (2026-06-18):
|
||
// libshell has NO "blkt"/"blockout"/"greybox" string or skip path; the blkt meshes live in the
|
||
// RENDER template (home_3d_staticarch_shell.usda, same as the detailed walls), carry the SAME
|
||
// material (ing 3870980354968886336) + a MeshPlatformComponent with isVisibleSelf=true. The old
|
||
// name-skip ("detailed twin renders instead") was a wrong guess: it dropped structural surfaces,
|
||
// and blktGardenGravel has NO detailed twin → its gravel surface went MISSING ([NOT RENDERED] bug).
|
||
// Editor: load navmesh (1) + collision/walls (2) as editable translucent overlays instead of
|
||
// dropping them. Still skip physics-duplicate (__phys_) meshes and isVisibleSelf=false UI quads.
|
||
// Navmesh/collision are RENDERED as editable overlays (green/red) — not "not rendered". Log them as
|
||
// OVERLAY so the [NOT RENDERED] report only lists genuinely-dropped meshes (phys dup / hidden UI).
|
||
if (isNav && !isPhys) { overlayKind = 1; log(" OVERLAY (navmesh, editable): %s", obj.name.c_str()); }
|
||
else if (isCol && !isPhys) { overlayKind = 2; log(" OVERLAY (collision, editable): %s", obj.name.c_str()); }
|
||
if (isPhys || isUI) {
|
||
if (isUI) recordSkip("NOT RENDERED — classified UI/gizmo: "+obj.name);
|
||
log(" SKIP: non-visual (phys-duplicate / UI gizmo)");
|
||
continue;
|
||
}
|
||
}
|
||
|
||
auto meshData = readAsset(*meshPath);
|
||
if (meshData.empty()) {
|
||
log(" SKIP: mesh data empty");
|
||
failedCount++;
|
||
continue;
|
||
}
|
||
|
||
MeshData md;
|
||
md.name = obj.name;
|
||
md.path = obj.path;
|
||
md.components = obj.components; // all hstf components -> editor inspector
|
||
if (std::getenv("HSR_ANIMPAIRDBG")) for (auto& c : md.components) if (c.shortCls=="AnimatorPlatformComponent") {
|
||
fprintf(stderr,"[ANIMPAIR] mesh '%s' AnimatorPlatformComponent:\n", md.name.c_str());
|
||
for (auto& kv : c.fields) fprintf(stderr," %s = %s\n", kv.first.c_str(), kv.second.c_str()); }
|
||
// V203 skybox-IBL (gated HSR_SKYIBL): mark meshes to receive the equirect diffuse-IBL ambient bake
|
||
// (vk_renderer per-vertex). Opt-in so default rendering (lightmaps) is untouched.
|
||
if (std::getenv("HSR_SKYIBL")) md.iblLit = true;
|
||
md.meshPath = *meshPath; // for multi-rig skeleton matching (shared .fbx path)
|
||
md.transform = obj.transform;
|
||
md.atlasCellIndex = obj.atlasCellIndex; // per-instance atlas variant (coral colour)
|
||
md.vatTrackIndex = obj.vatTrackIndex; md.vatRateFactor = obj.vatRateFactor; md.vatTimeOffset = obj.vatTimeOffset; // per-instance VAT track/phase
|
||
// Per-instance MaterialPropertyOverrides (matParams.* over the cooked block). Carry them onto the mesh,
|
||
// and pull out matParams.tint as the per-instance colour multiplier (grey butterfly -> rose-tinted).
|
||
md.matOverrides = obj.matOverrides;
|
||
for (const auto& ov : obj.matOverrides) {
|
||
if (ov.name.find("tint") != std::string::npos || ov.name.find("Tint") != std::string::npos) {
|
||
md.tint[0]=ov.v[0]; md.tint[1]=ov.v[1]; md.tint[2]=ov.v[2];
|
||
md.tint[3] = (ov.v[3] > 0.0f) ? ov.v[3] : 1.0f; // alpha defaults to opaque (override w often 0)
|
||
}
|
||
}
|
||
md.hasWorldMatrix = obj.hasWorldMatrix; // faithful 4×4 world (parent→child)
|
||
if (obj.hasWorldMatrix) memcpy(md.worldMatrix, obj.worldMatrix, sizeof(md.worldMatrix));
|
||
md.overlayKind = overlayKind;
|
||
|
||
if (const char* dm = std::getenv("HSR_DUMPMESH")) {
|
||
if (md.name.find(dm)!=std::string::npos) {
|
||
std::string fn = std::string("_")+md.name+".rendmesh";
|
||
FILE* f=fopen(fn.c_str(),"wb");
|
||
if(f){ fwrite(meshData.data(),1,meshData.size(),f); fclose(f);
|
||
log(" DUMPMESH wrote %zu bytes -> %s", meshData.size(), fn.c_str()); }
|
||
}
|
||
}
|
||
if (!parseRendMesh(meshData, md.positions, md.uvs, md.indices,
|
||
&md.boneIndices, &md.boneWeights, md.name.c_str(), &md.uvs2, &md.bonePalette, &md.colors, &md.uvs3, &md.uvs4,
|
||
&md.normals, &md.tangents)) {
|
||
recordSkip("RENDMESH parse failed: " + (meshPath ? *meshPath : md.name));
|
||
log(" SKIP: RENDMESH parse failed");
|
||
failedCount++;
|
||
continue;
|
||
}
|
||
md.hasBones = envHasSkeleton && !md.boneIndices.empty();
|
||
if (!md.hasBones) { md.boneIndices.clear(); md.boneWeights.clear(); }
|
||
|
||
// Re-importing one of our own older cooks must not treat its synthetic
|
||
// skinned anti-cull geometry as part of the environment. Those cooks
|
||
// append six tiny triangles (18 vertices) at the +/-100 km axis
|
||
// extremes. They are intentionally invisible on-device, but if they
|
||
// survive a second cook they poison scene bounds, auto-floor and spawn
|
||
// placement (for example, a perfectly valid home gets a Y=-99,998 m
|
||
// spawn). Match the exact legacy suffix and remove it before any
|
||
// bounds/navigation calculation. Genuine large environments are not
|
||
// affected because the indices and 18-vertex axis signature must both
|
||
// match exactly.
|
||
if (md.hasBones && md.positions.size() / 3 >= 18 && md.indices.size() >= 18) {
|
||
const size_t oldNv = md.positions.size() / 3;
|
||
const size_t base = oldNv - 18;
|
||
const float C = 1.0e5f;
|
||
const float ext[6][3] = {
|
||
{ C,0,0 }, { -C,0,0 }, { 0,C,0 },
|
||
{ 0,-C,0 }, { 0,0,C }, { 0,0,-C }
|
||
};
|
||
bool signature = true;
|
||
for (int e = 0; e < 6 && signature; ++e) for (int t = 0; t < 3; ++t) {
|
||
const size_t v = base + (size_t)e * 3 + (size_t)t;
|
||
const float expected[3] = {
|
||
ext[e][0] + (t == 1 ? 1.0f : 0.0f),
|
||
ext[e][1] + (t == 2 ? 1.0f : 0.0f),
|
||
ext[e][2]
|
||
};
|
||
for (int a = 0; a < 3; ++a)
|
||
if (std::fabs(md.positions[v * 3 + (size_t)a] - expected[a]) > 0.25f)
|
||
signature = false;
|
||
}
|
||
const size_t idxBase = md.indices.size() - 18;
|
||
for (size_t i = 0; i < 18 && signature; ++i)
|
||
if (md.indices[idxBase + i] != base + i) signature = false;
|
||
|
||
if (signature) {
|
||
md.positions.resize(base * 3);
|
||
md.indices.resize(idxBase);
|
||
auto trimFloat = [oldNv, base](std::vector<float>& v, size_t width) {
|
||
if (v.size() >= oldNv * width) v.resize(base * width);
|
||
};
|
||
auto trimByte = [oldNv, base](std::vector<u8>& v, size_t width) {
|
||
if (v.size() >= oldNv * width) v.resize(base * width);
|
||
};
|
||
trimFloat(md.uvs, 2); trimFloat(md.uvs2, 2); trimFloat(md.normals, 3); trimFloat(md.tangents, 4);
|
||
trimFloat(md.uvs3, 2); trimFloat(md.uvs4, 2);
|
||
trimByte(md.colors, 4); trimByte(md.boneIndices, 4);
|
||
trimByte(md.boneWeights, 4);
|
||
|
||
size_t usedPalette = 0;
|
||
for (size_t i = 0; i < md.boneIndices.size() && i < md.boneWeights.size(); ++i)
|
||
if (md.boneWeights[i] != 0)
|
||
usedPalette = std::max(usedPalette, (size_t)md.boneIndices[i] + 1);
|
||
if (usedPalette && md.bonePalette.size() > usedPalette)
|
||
md.bonePalette.resize(usedPalette);
|
||
log(" [RECOOK] stripped 18 legacy +/-100 km anti-cull vertices from '%s'", md.name.c_str());
|
||
}
|
||
}
|
||
md.nVerts = (u32)(md.positions.size() / 3);
|
||
md.nIdx = (u32)md.indices.size();
|
||
log(" RENDMESH: %u verts, %u idx (%u tris)", md.nVerts, md.nIdx, md.nIdx/3);
|
||
|
||
if (md.nVerts > 0) {
|
||
float xmin=1e30f, xmax=-1e30f, ymin=1e30f, ymax=-1e30f, zmin=1e30f, zmax=-1e30f;
|
||
for (u32 vi = 0; vi < md.nVerts; ++vi) {
|
||
float x=md.positions[vi*3], y=md.positions[vi*3+1], z=md.positions[vi*3+2];
|
||
if(x<xmin)xmin=x; if(x>xmax)xmax=x;
|
||
if(y<ymin)ymin=y; if(y>ymax)ymax=y;
|
||
if(z<zmin)zmin=z; if(z>zmax)zmax=z;
|
||
}
|
||
log(" Bounds: X[%.2f..%.2f] Y[%.2f..%.2f] Z[%.2f..%.2f] | pos=(%.2f,%.2f,%.2f) rot=(%.2f,%.2f,%.2f,%.2f) scl=(%.2f,%.2f,%.2f) name=%s",
|
||
xmin,xmax, ymin,ymax, zmin,zmax,
|
||
md.transform.pos[0], md.transform.pos[1], md.transform.pos[2],
|
||
md.transform.rot[0], md.transform.rot[1], md.transform.rot[2], md.transform.rot[3],
|
||
md.transform.scale[0], md.transform.scale[1], md.transform.scale[2], md.name.c_str());
|
||
}
|
||
|
||
// ── Resolve material → texture
|
||
bool gotTex = false;
|
||
// SKYBOX dome (SkyboxPlatformComponent): bind its colorTexture panorama directly — it has no material.
|
||
// Drawn as opaque geometry (the dome surrounds the scene). Without this the sky was missing entirely.
|
||
md.isSkybox = obj.isSkybox;
|
||
if (obj.isSkybox) {
|
||
if (obj.skyboxTex.ing != 0 && loadTexture(obj.skyboxTex, md)) {
|
||
gotTex = true; log(" [skybox] '%s' colorTexture bound (%ux%u)", md.name.c_str(), md.texW, md.texH);
|
||
} else log(" [skybox] '%s' has skyboxMesh but no colorTexture", md.name.c_str());
|
||
// reflectionMap -> the global IBL reflection cube
|
||
if (obj.skyboxRefl.ing) decodeReflCube(obj.skyboxRefl, md);
|
||
}
|
||
|
||
// Path 1: HSTF-provided matRefs (MaterialPlatformComponent — prod envs)
|
||
for (auto& matRef : obj.matRefs) {
|
||
const std::string* matPath = resolve(matRef);
|
||
if (!matPath) {
|
||
log(" Material not in manifest: ing=%016llX tgt=%08X",
|
||
(unsigned long long)matRef.ing, matRef.tgt);
|
||
continue;
|
||
}
|
||
log(" Material: %s", matPath->c_str());
|
||
// A "pbrlightmap_tiled" material (e.g. rugA) is genuinely TILED -> keep its cooked block
|
||
// (Tint + GlobalTile) like the room; only the plain-unwrap props (bowls/vases) strip it.
|
||
md.tiled = (matPath->find("_tiled") != std::string::npos);
|
||
auto matData = readAsset(*matPath);
|
||
if (matData.empty()) { recordSkip("material data empty: "+*matPath); log(" Material data empty"); continue; }
|
||
|
||
MatlmatlInfo matInfo;
|
||
if (!parseMatlmatl(matData, matInfo)) { recordSkip("MATLMATL parse failed: "+*matPath); log(" MATLMATL parse failed"); continue; }
|
||
log(" Shader: ing=%016llX Tex: ing=%016llX",
|
||
(unsigned long long)matInfo.shaderIng, (unsigned long long)matInfo.texIng);
|
||
if (std::getenv("HSR_DUMPMAT2") &&
|
||
(md.name.find("BowlA")!=std::string::npos || md.name.find("SculptureA")!=std::string::npos)) {
|
||
std::string fn = std::string("_mat_") + (md.name.find("BowlA")!=std::string::npos?"bowla":"sculpturea") + ".bin";
|
||
FILE* f=fopen(fn.c_str(),"wb"); if(f){ fwrite(matData.data(),1,matData.size(),f); fclose(f); }
|
||
log(" MATDUMP %s -> %s (%zu bytes) texIngs=%zu", md.name.c_str(), fn.c_str(), matData.size(), matInfo.texIngs.size());
|
||
}
|
||
|
||
if (applyMat(matInfo, md)) { gotTex = true; break; }
|
||
}
|
||
|
||
// Path 1b: no MaterialPlatformComponent -> the material the mesh was exported with (nested in the
|
||
// RENDMESH), which is what libshell draws it with. Horror's SM_skybox (unlit + skybox_d.png) and the
|
||
// hero lantern's candleFlame have no material component; without this they had no shader and vanished.
|
||
if (!gotTex && obj.matRefs.empty() && !obj.isSkybox) {
|
||
const std::vector<u8> own = rendMeshPartMaterial(meshData);
|
||
MatlmatlInfo matInfo;
|
||
if (!own.empty() && parseMatlmatl(own, matInfo)) {
|
||
log(" Mesh's own material: shader ing=%016llX", (unsigned long long)matInfo.shaderIng);
|
||
gotTex = applyMat(matInfo, md);
|
||
}
|
||
}
|
||
|
||
// Path 2: Fallback — only when ALL entities lack matRefs (v79_test-like APKs).
|
||
// Cycle through pre-loaded materials so each unmaterialed entity gets a texture.
|
||
// In prod APKs (where only a few entities lack matRefs), skip this — use neutral.
|
||
if (!gotTex && allEntitiesNoMat && !fallbackMats.empty()) {
|
||
size_t n = fallbackMats.size();
|
||
for (size_t fi = 0; fi < n && !gotTex; ++fi) {
|
||
auto& me = fallbackMats[(fallbackMatIdx + fi) % n];
|
||
if (me.texOk) {
|
||
md.texRGBA = me.texRGBA;
|
||
md.texW = me.texW; md.texH = me.texH;
|
||
md.hasTexture = true;
|
||
md.useBlend = me.useBlend;
|
||
gotTex = true;
|
||
log(" Fallback mat[%zu] -> tex %ux%u blend=%d",
|
||
(fallbackMatIdx + fi) % n, me.texW, me.texH, (int)me.useBlend);
|
||
}
|
||
}
|
||
fallbackMatIdx = (fallbackMatIdx + 1) % n;
|
||
}
|
||
|
||
// Path 3: Unreferenced textures — for prod envs where an entity has no matRefs.
|
||
// Skybox / environment textures not tied to any material (sorted largest first).
|
||
// Use useBlend=false since skybox geometry is opaque.
|
||
// Off by default: an entity with no material gets the neutral default (as on device), not a guessed
|
||
// "unreferenced" texture — the guess put lightmaps / sky panoramas onto unrelated meshes.
|
||
if (!gotTex && !allEntitiesNoMat && !unrefTextures.empty() && std::getenv("FH_UNREF_TEX")) {
|
||
size_t n = unrefTextures.size();
|
||
auto& ut = unrefTextures[unrefTexIdx % n];
|
||
md.texRGBA = ut.texRGBA;
|
||
md.texW = ut.texW; md.texH = ut.texH;
|
||
md.hasTexture = true;
|
||
// VFX overlays (god rays / sun / surface waves) carry a mostly-TRANSPARENT glow texture and
|
||
// have no material — drawn OPAQUE they showed as a solid WHITE blob (vfx_surfaceWaves_SUN).
|
||
// They're ADDITIVE glow (light), so the transparent areas add nothing and the glow blends.
|
||
// The real skybox dome stays opaque.
|
||
std::string nm = md.name; for (char& c : nm) c = (char)tolower((unsigned char)c);
|
||
bool isVfx = nm.find("vfx") != std::string::npos || nm.find("sunray") != std::string::npos ||
|
||
nm.find("surfacewave") != std::string::npos || nm.find("_sun") != std::string::npos ||
|
||
nm.find("godray") != std::string::npos || nm.find("caustic") != std::string::npos;
|
||
// VFX (sun/rays/surface waves): ADDITIVE + premultiply, so the transparent surround adds
|
||
// nothing (no white blob) but the disk/glow adds light. The no-material shader outputs
|
||
// alpha=1, so plain alpha-blend can't drop the surround — additive on a premultiplied tex is
|
||
// the only way to get the sun glow visible WITHOUT the full-white quad. Boost so it reads.
|
||
md.useBlend = md.useBlend || isVfx; // PRESERVE an already-set alpha-blend (f4-omitted fog/matte); don't reset to opaque
|
||
md.additive = md.additive || isVfx;
|
||
if (isVfx) {
|
||
for (size_t p = 0; p + 3 < md.texRGBA.size(); p += 4) {
|
||
u32 a = md.texRGBA[p+3];
|
||
for (int c = 0; c < 3; ++c) {
|
||
u32 v = (u32)md.texRGBA[p+c] * a / 255 * 3; // premult × 3 brightness boost
|
||
md.texRGBA[p+c] = (u8)(v > 255 ? 255 : v);
|
||
}
|
||
}
|
||
}
|
||
gotTex = true;
|
||
log(" UnrefTex[%zu] -> %ux%u (%s)",
|
||
unrefTexIdx % n, ut.texW, ut.texH, isVfx ? "VFX additive premult x3" : "skybox/env opaque");
|
||
unrefTexIdx = (unrefTexIdx + 1) % n;
|
||
}
|
||
|
||
if (!gotTex) {
|
||
u8 r, g, b;
|
||
if (allEntitiesNoMat) {
|
||
// v79_test: no materials at all — use hash color to distinguish meshes
|
||
auto [hr,hg,hb] = fallbackColor(md.name);
|
||
r = hr; g = hg; b = hb;
|
||
} else {
|
||
// prod env: entity has no material component — render as dark sky-blue
|
||
// (matches the clear color background so it's less distracting)
|
||
r = 15; g = 15; b = 25;
|
||
}
|
||
log(" No texture — fallback color R=%u G=%u B=%u", r,g,b);
|
||
md.texRGBA = {r,g,b,255};
|
||
md.texW = md.texH = 1;
|
||
md.hasTexture = true;
|
||
}
|
||
|
||
// Bind this mesh's baked LIGHTMAP override (the warm room lighting), matched BY NAME — only if it
|
||
// didn't already get a lightmap from its material and it has lightmap UVs (uv1) to sample.
|
||
meshes.push_back(std::move(md));
|
||
loadedCount++;
|
||
}
|
||
|
||
// ── Step 8: Load any __mesh_sub_targets__ files not claimed by Step 7.
|
||
// These are present in the zip but not referenced by any HSTF entity.
|
||
// With a complete manifest all entities should be claimed; this loop
|
||
// catches any remaining geometry (e.g. environment meshes with no entity).
|
||
log("========================================");
|
||
log("Step 6: Scanning for unclaimed mesh sub-targets...");
|
||
{
|
||
u32 nFiles = mz_zip_reader_get_num_files(&sceneZip);
|
||
for (u32 fi = 0; fi < nFiles; ++fi) {
|
||
mz_zip_archive_file_stat st;
|
||
if (!mz_zip_reader_file_stat(&sceneZip, fi, &st)) continue;
|
||
std::string fname(st.m_filename);
|
||
if (fname.find("__mesh_sub_targets__/") == std::string::npos) continue;
|
||
if (fname.find("__phys_mesh") != std::string::npos) continue;
|
||
|
||
// Normalise: strip "content/" prefix for set lookup
|
||
std::string normPath = fname;
|
||
if (normPath.size() > 8 && normPath.substr(0,8) == "content/")
|
||
normPath = normPath.substr(8);
|
||
if (claimedPaths.count(fname) || claimedPaths.count(normPath)) continue;
|
||
|
||
log(" Unclaimed: %s", fname.c_str());
|
||
{ std::string bn=fname; size_t p=bn.rfind('/'); if(p!=std::string::npos) bn=bn.substr(p+1);
|
||
recordSkip("UNCLAIMED mesh — loaded at ORIGIN (no entity transform → gap at real spot): "+bn); }
|
||
size_t msz = 0;
|
||
void* mdptr = mz_zip_reader_extract_to_heap(&sceneZip, fi, &msz, 0);
|
||
if (!mdptr) { log(" Extract failed"); continue; }
|
||
|
||
MeshData md;
|
||
{
|
||
std::string seg = fname;
|
||
if (auto p = seg.rfind('/'); p != std::string::npos) { md.path = seg.substr(0, p); seg = seg.substr(p+1); }
|
||
md.name = seg;
|
||
}
|
||
md.transform.scale[0] = md.transform.scale[1] = md.transform.scale[2] = 1.0f;
|
||
md.transform.rot[3] = 1.0f;
|
||
|
||
std::vector<u8> mdata((u8*)mdptr, (u8*)mdptr + msz);
|
||
mz_free(mdptr);
|
||
|
||
if (!parseRendMesh(mdata, md.positions, md.uvs, md.indices, &md.boneIndices, &md.boneWeights,
|
||
nullptr, nullptr, nullptr, &md.colors, &md.uvs3, &md.uvs4)) { // also extract sem4 vertexColor0 + uv2/uv3
|
||
log(" RENDMESH parse failed — skip");
|
||
failedCount++;
|
||
continue;
|
||
}
|
||
md.hasBones = envHasSkeleton && !md.boneIndices.empty();
|
||
if (!md.hasBones) { md.boneIndices.clear(); md.boneWeights.clear(); }
|
||
md.nVerts = (u32)(md.positions.size() / 3);
|
||
md.nIdx = (u32)md.indices.size();
|
||
log(" Loaded: %u verts, %u idx", md.nVerts, md.nIdx);
|
||
|
||
// Assign fallback color — no material reference available for unclaimed meshes.
|
||
auto [r,g,b] = fallbackColor(md.name);
|
||
md.texRGBA = {r,g,b,255};
|
||
md.texW = md.texH = 1;
|
||
md.hasTexture = true;
|
||
|
||
meshes.push_back(std::move(md));
|
||
loadedCount++;
|
||
}
|
||
}
|
||
|
||
// The scene's reflection cube when no sky mesh brought one (carried by the first mesh, like a sky's would be)
|
||
if (sceneRefl.ing && !meshes.empty() &&
|
||
std::none_of(meshes.begin(), meshes.end(), [](const MeshData& m) { return m.reflCubeSize != 0; }))
|
||
decodeReflCube(sceneRefl, meshes.front());
|
||
|
||
log("========================================");
|
||
log("LOAD COMPLETE: %d meshes loaded, %d failed", loadedCount, failedCount);
|
||
log("========================================");
|
||
|
||
// ── v203 HzAnim skeletal animation: load skeleton + clip, build CPU-skin records ──
|
||
{
|
||
int nf = (int)mz_zip_reader_get_num_files(&sceneZip);
|
||
auto extractBy=[&](const char* marker, std::vector<u8>& out)->bool{
|
||
for (int fi=0; fi<nf; ++fi) { mz_zip_archive_file_stat st;
|
||
if (!mz_zip_reader_file_stat(&sceneZip, fi, &st)) continue;
|
||
if (strstr(st.m_filename, marker)) { size_t sz=0; void* p=mz_zip_reader_extract_to_heap(&sceneZip, fi, &sz, 0);
|
||
if (p){ out.assign((u8*)p,(u8*)p+sz); mz_free(p); return true; } } }
|
||
return false; };
|
||
// Extract a zip entry by INDEX (we walk every entry to find all skeletons).
|
||
auto extractIdx=[&](int fi, std::vector<u8>& out)->bool{
|
||
size_t sz=0; void* p=mz_zip_reader_extract_to_heap(&sceneZip, fi, &sz, 0);
|
||
if (p){ out.assign((u8*)p,(u8*)p+sz); mz_free(p); return true; } return false; };
|
||
// DEVICE PAIRING (cook): every RIGID-HZANIM rig gets GENERIC joint names (joint_0/joint_1/bounds_anchor_*),
|
||
// so palette/name-hash matching binds EVERY mesh to EVERY rig -> all play the first clip = "everything moves
|
||
// randomly" in the HSL preview (device is fine: it pairs by the AnimatorPlatformComponent's asset refs).
|
||
// These helpers read each mesh's component skeleton/animation ingestionId and resolve to the asset path,
|
||
// so we pair EXACTLY like the device. HSR_HZNOIDPAIR reverts to the old palette matching.
|
||
auto compU64=[](const MeshData& md,const char* fld)->uint64_t{
|
||
for (auto& c: md.components) if (c.shortCls=="AnimatorPlatformComponent")
|
||
for (auto& kv: c.fields) if (kv.first==fld) return strtoull(kv.second.c_str(),nullptr,10);
|
||
return 0; };
|
||
auto refPath=[&](const MeshData& md,const char* ingF,const char* pkgF,const char* tgtF)->std::string{
|
||
uint64_t ing=compU64(md,ingF); if(!ing) return "";
|
||
AssetKey k{}; k.pkg=compU64(md,pkgF); k.ing=ing; k.tgt=(uint32_t)compU64(md,tgtF);
|
||
const std::string* p=resolve(k); if(!p) return "";
|
||
return (p->rfind("content/",0)==0)?p->substr(8):*p; };
|
||
auto meshSkelPath=[&](const MeshData& md){ return refPath(md,"skeleton.ingestionId","skeleton.packageOrRemoteId","skeleton.targetId"); };
|
||
auto meshAnimPath=[&](const MeshData& md){ return refPath(md,"animations[0].data.animation.ingestionId","animations[0].data.animation.packageOrRemoteId","animations[0].data.animation.targetId"); };
|
||
bool idPair = !std::getenv("HSR_HZNOIDPAIR");
|
||
// MULTI-RIG: enumerate EVERY skeleton (one per animated fbx: bat/owl/windmill/lantern...). For each,
|
||
// parse its skel + the matching anim under the SAME asset key (path up to ".../<name>.fbx" or
|
||
// "....skel/"), then bind every skinned mesh whose meshPath shares that key. The device
|
||
// AnimationSystem is PER-ENTITY, so each rig animates independently (was a single global skel/clip).
|
||
for (int si=0; si<nf; ++si) {
|
||
mz_zip_archive_file_stat st;
|
||
if (!mz_zip_reader_file_stat(&sceneZip, si, &st)) continue;
|
||
std::string sp = st.m_filename; bool cookedNaming=false; std::string zipKey;
|
||
size_t kp = sp.find("/__hzanim_skel_sub_targets__");
|
||
if (kp != std::string::npos) zipKey = sp.substr(0, kp);
|
||
else { kp = sp.find(".skel/skeleton"); if (kp!=std::string::npos){ zipKey=sp.substr(0,kp); cookedNaming=true; } }
|
||
if (zipKey.empty()) continue; // not a skeleton entry
|
||
// Zip entries carry a "content/" prefix that manifest/mesh paths (md.meshPath) do NOT — strip
|
||
// it for mesh matching (that prefix mismatch was binding 0 meshes to every rig).
|
||
std::string key = (zipKey.rfind("content/",0)==0) ? zipKey.substr(8) : zipKey;
|
||
std::vector<u8> sb; if (!extractIdx(si, sb)) continue;
|
||
AnimGroup g; if (!parseRendSkel(sb, g.skel)) continue;
|
||
// matching anim clip. V79/glTF share the .fbx base, so the clip path starts with the skeleton's
|
||
// zipKey. The COOK instead names them armatureN.skel + animclipM.anim under ONE home dir (different
|
||
// bases, independent dedup indices) — so prefix-matching found NO clip (cooked multi-rig = "0 groups",
|
||
// every skinned mesh forced static). For cooked naming, match within the shared HOME DIR and require the
|
||
// clip's JOINT COUNT to equal this skeleton's (the device pairs them via the AnimatorComponent; joint
|
||
// count is the file-level proxy). [[project_hsr_skinned_rendmesh_skinblock]]
|
||
std::string homeDir = zipKey; { size_t hs = zipKey.rfind('/'); if (hs != std::string::npos) homeDir = zipKey.substr(0, hs + 1); }
|
||
std::vector<u8> ab; bool gotAnim=false;
|
||
// DEVICE-faithful clip: load the anim the BELONGING mesh's AnimatorPlatformComponent names (the
|
||
// joint-count guess below binds every generic-named rig to the FIRST clip = "moves randomly").
|
||
if (idPair && cookedNaming) {
|
||
// ONE AnimGroup PER (this skeleton, DISTINCT anim). The cook DEDUPES byte-identical
|
||
// skeletons (polarvillage's two windmills both reference armature7.skel) while each
|
||
// mesh's AnimatorPlatformComponent names ITS OWN animclipN — the device plays per
|
||
// component. The old code fished ONE clip for the whole skel file, so every sharer
|
||
// played the FIRST mesh's clip: the vista windmill orbited the LODGE windmill's hub
|
||
// 200 units away ("windmill moving everywhere").
|
||
std::map<std::string, std::vector<size_t>> byAnim;
|
||
for (size_t mi=0; mi<meshes.size(); ++mi) { auto& md=meshes[mi];
|
||
if (!md.hasBones || md.boneIndices.size() < md.positions.size()/3*4) continue;
|
||
std::string msp=meshSkelPath(md); if (msp.empty() || msp.rfind(key+".skel",0)!=0) continue; // ".skel" boundary so armature1 != armature1X
|
||
std::string ap_=meshAnimPath(md); if (ap_.empty()) continue;
|
||
byAnim[ap_].push_back(mi);
|
||
}
|
||
if (!byAnim.empty()) {
|
||
int nJ=(int)g.skel.joints.size();
|
||
std::vector<u32> jointHash(nJ);
|
||
for (int j=0;j<nJ;++j) jointHash[j]=murmur3_x86_32(g.skel.joints[j].name.data(), g.skel.joints[j].name.size(), 0);
|
||
for (auto& kv : byAnim) {
|
||
AnimGroup g2; g2.skel=g.skel; bool got=false; std::vector<u8> ab2;
|
||
for (int ai=0; ai<nf && !got; ++ai){ mz_zip_archive_file_stat at; if(!mz_zip_reader_file_stat(&sceneZip,ai,&at)) continue;
|
||
std::string zp=at.m_filename, zs=(zp.rfind("content/",0)==0)?zp.substr(8):zp;
|
||
if (zs.rfind(kv.first,0)!=0) continue;
|
||
if (zp.find("__hzanim_anim_sub_targets__")==std::string::npos && zp.find(".anim/anim")==std::string::npos) continue;
|
||
if (extractIdx(ai,ab2) && parseRendClip(ab2,(int)g2.skel.joints.size(),g2.clip)) got=true; }
|
||
if (!got) continue;
|
||
for (size_t mi : kv.second) { auto& md=meshes[mi];
|
||
if (!md.bonePalette.empty() && !std::getenv("HSR_HZNOREMAP")) {
|
||
// slot -> joint by the palette's name-hash (THE faithful map)
|
||
std::vector<int> slotToJoint(md.bonePalette.size(), -1);
|
||
for (size_t s2=0;s2<md.bonePalette.size();++s2)
|
||
for (int j=0;j<nJ;++j) if (jointHash[j]==md.bonePalette[s2]) { slotToJoint[s2]=j; break; }
|
||
for (auto& bi : md.boneIndices) if ((size_t)bi<slotToJoint.size() && slotToJoint[bi]>=0) bi=(u8)slotToJoint[bi];
|
||
}
|
||
SkinRec r; r.meshIdx=mi; r.basePos=md.positions; r.bIdx=md.boneIndices; r.bWgt=md.boneWeights;
|
||
g2.skinRecs.push_back(std::move(r)); md.dynamicVerts=true;
|
||
}
|
||
size_t slash=key.rfind('/');
|
||
log(" HzAnim rig '%s' + clip '%s': %zu joints, %zu skinned meshes",
|
||
key.substr(slash==std::string::npos?0:slash+1).c_str(), kv.first.c_str(),
|
||
g2.skel.joints.size(), g2.skinRecs.size());
|
||
if (verbose && g2.clip.acl) {
|
||
float dur = hzAclDuration(g2.clip.acl);
|
||
std::vector<float> s0, s1; sampleRendClip(g2.skel, g2.clip, 0.f, s0); sampleRendClip(g2.skel, g2.clip, dur*0.5f, s1);
|
||
float maxd=0.f; for (size_t i2=0;i2<s0.size()&&i2<s1.size();++i2){ float dd=std::fabs(s0[i2]-s1[i2]); if(dd>maxd)maxd=dd; }
|
||
log(" [ACL] dur=%.3fs aclJoints=%d fps=%.1f skinDelta(t=0 vs %.2fs)=%.4f %s",
|
||
dur, g2.clip.aclJoints, g2.clip.fps, dur*0.5f, maxd, maxd<1e-4f?"<<STATIC!":"(animates)");
|
||
}
|
||
if (!g2.skinRecs.empty()) animGroups.push_back(std::move(g2));
|
||
}
|
||
continue; // this skeleton is fully handled (per-pair groups); skip the one-clip path
|
||
}
|
||
}
|
||
// PASS 0 = exact base prefix (every env where the clip shares the skeleton's .fbx/<name> base —
|
||
// V79/glTF AND official V203). PASS 1 (cook only) = the cook's armatureN.skel + animclipM.anim live
|
||
// under one home dir with different bases, so fall back to home-dir + matching JOINT COUNT. Exact-first
|
||
// means official envs keep their precise pairing (no cross-rig mismatch); only our cook needs the fallback.
|
||
for (int pass=0; pass<(cookedNaming?2:1) && !gotAnim; ++pass) {
|
||
const std::string& pre = (pass==0) ? zipKey : homeDir;
|
||
for (int ai=0; ai<nf && !gotAnim; ++ai) {
|
||
mz_zip_archive_file_stat at; if (!mz_zip_reader_file_stat(&sceneZip, ai, &at)) continue;
|
||
std::string ap = at.m_filename;
|
||
if (ap.size()<pre.size() || ap.compare(0,pre.size(),pre)!=0) continue;
|
||
if (ap.find("__hzanim_anim_sub_targets__")==std::string::npos && ap.find(".anim/anim")==std::string::npos) continue;
|
||
if (!extractIdx(ai, ab) || !parseRendClip(ab, (int)g.skel.joints.size(), g.clip)) continue;
|
||
if (pass==1) { // home-dir fallback: disambiguate the rigs sharing the dir — clip must be THIS skeleton's
|
||
int cj = g.clip.acl ? g.clip.aclJoints : g.clip.nJoints;
|
||
if (cj > 0 && cj != (int)g.skel.joints.size()) { if (g.clip.acl) hzAclDestroy(g.clip.acl); g.clip = RendClip{}; continue; }
|
||
}
|
||
gotAnim=true;
|
||
}
|
||
}
|
||
if (!gotAnim) { log(" HzAnim '%s': %zu joints but no clip", key.c_str(), g.skel.joints.size()); continue; }
|
||
// FAITHFUL slot->joint remap (the MAP libshell reads, NOT a DFS guess). The mesh's per-vertex
|
||
// boneIndices are SLOTS into the mesh's bone PALETTE (md.bonePalette, read from RENDMESH
|
||
// ROOT.f2[0].f0 = one MurmurHash3_x86_32(jointName,0) per slot). Each slot maps to the
|
||
// skeleton joint whose name-hash matches. Precompute each skeleton joint's name-hash once.
|
||
int nJ=(int)g.skel.joints.size();
|
||
std::vector<u32> jointHash(nJ);
|
||
for (int j=0;j<nJ;++j) jointHash[j]=murmur3_x86_32(g.skel.joints[j].name.data(), g.skel.joints[j].name.size(), 0);
|
||
// DFS order kept ONLY as a fallback for meshes whose RENDMESH carries no palette.
|
||
std::vector<int> dfs, stk;
|
||
for (int j=nJ-1;j>=0;--j) if (g.skel.joints[j].parent<0) stk.push_back(j);
|
||
while(!stk.empty()){int j=stk.back();stk.pop_back();dfs.push_back(j);for(int c=nJ-1;c>=0;--c) if(g.skel.joints[c].parent==j) stk.push_back(c);}
|
||
bool ident=((int)dfs.size()==nJ); for(int s=0;ident&&s<(int)dfs.size();++s) if(dfs[s]!=s) ident=false;
|
||
// bind every skinned mesh from THIS fbx (matched by shared asset path).
|
||
for (size_t mi=0; mi<meshes.size(); ++mi) {
|
||
auto& md=meshes[mi];
|
||
if (!md.hasBones || md.boneIndices.size() < md.positions.size()/3*4) continue;
|
||
// PREFER the shared-base path match (V79/glTF/official: mesh shares the skeleton's .fbx/<name>
|
||
// base). The cook's mXXX.rendmesh does NOT share the armatureN base, so for cooked naming fall back
|
||
// to BONE PALETTE: the mesh belongs to THIS rig iff every palette slot's joint name-hash is in this
|
||
// skeleton (the faithful map the device uses) — disambiguates the spacestation's 1-joint vs 25-joint vehicle rigs.
|
||
bool pathMatch = (md.meshPath.size()>=key.size() && md.meshPath.compare(0,key.size(),key)==0);
|
||
if (!pathMatch) {
|
||
// DEVICE pairing: bind iff THIS mesh's component skeleton-ref resolves to THIS group's skel.
|
||
// (The cook's generic joint names make the palette match EVERY rig -> wrong clip -> random motion.)
|
||
std::string msp = (idPair && cookedNaming) ? meshSkelPath(md) : "";
|
||
if (!msp.empty()) { if (msp.rfind(key+".skel",0)!=0) continue; } // has a ref: must match this skel (".skel" boundary)
|
||
else { // no component ref (older cook): fall back to palette name-hash
|
||
if (!cookedNaming || md.bonePalette.empty()) continue;
|
||
bool all=true; for (u32 ph : md.bonePalette){ bool f=false; for(int j=0;j<nJ;++j) if((u32)jointHash[j]==ph){f=true;break;} if(!f){all=false;break;} }
|
||
if (!all) continue;
|
||
}
|
||
}
|
||
if (!md.bonePalette.empty() && !std::getenv("HSR_HZNOREMAP")) {
|
||
// slot -> joint by the palette's name-hash (THE faithful map). Fixes the bird flock
|
||
// (palette != DFS); the whale palette == its DFS so it's unchanged.
|
||
std::vector<int> slotToJoint(md.bonePalette.size(), -1); int nMapped=0;
|
||
for (size_t s=0;s<md.bonePalette.size();++s)
|
||
for (int j=0;j<nJ;++j) if (jointHash[j]==md.bonePalette[s]) { slotToJoint[s]=j; ++nMapped; break; }
|
||
for (auto& bi : md.boneIndices) if ((size_t)bi<slotToJoint.size() && slotToJoint[bi]>=0) bi=(u8)slotToJoint[bi];
|
||
if (verbose) log(" [bonemap] '%s' palette %zu slots -> %d mapped by name-hash (faithful)", md.name.c_str(), md.bonePalette.size(), nMapped);
|
||
} else if ((int)dfs.size()==nJ && !ident && !std::getenv("HSR_HZNOREMAP") && !cookedNaming) {
|
||
for (auto& bi : md.boneIndices) if (bi<nJ) bi=(u8)dfs[bi]; // fallback: RENDMESH had no palette
|
||
}
|
||
SkinRec r; r.meshIdx=mi; r.basePos=md.positions; r.bIdx=md.boneIndices; r.bWgt=md.boneWeights;
|
||
g.skinRecs.push_back(std::move(r)); md.dynamicVerts=true;
|
||
}
|
||
size_t slash=key.rfind('/');
|
||
log(" HzAnim rig '%s': %zu joints, %d frames, %zu skinned meshes",
|
||
key.substr(slash==std::string::npos?0:slash+1).c_str(),
|
||
g.skel.joints.size(), g.clip.nFrames, g.skinRecs.size());
|
||
if (verbose && g.clip.acl) {
|
||
float dur = hzAclDuration(g.clip.acl);
|
||
std::vector<float> s0, s1; sampleRendClip(g.skel, g.clip, 0.f, s0); sampleRendClip(g.skel, g.clip, dur*0.5f, s1);
|
||
float maxd=0.f; for (size_t i=0;i<s0.size()&&i<s1.size();++i){ float dd=std::fabs(s0[i]-s1[i]); if(dd>maxd)maxd=dd; }
|
||
log(" [ACL] dur=%.3fs aclJoints=%d fps=%.1f skinDelta(t=0 vs %.2fs)=%.4f %s",
|
||
dur, g.clip.aclJoints, g.clip.fps, dur*0.5f, maxd, maxd<1e-4f?"<<STATIC!":"(animates)");
|
||
}
|
||
if (!g.skinRecs.empty()) animGroups.push_back(std::move(g));
|
||
}
|
||
log(" HzAnim: %zu animation groups (multi-rig)", animGroups.size());
|
||
// ONLY meshes bound to a real rig may animate. Horror's static trees/ground carry false-positive
|
||
// sem7/8 "bone" bytes and envHasSkeleton flagged them skinned -> the skinner wiggled EVERY mesh
|
||
// (user caught it in wireframe). Clear bone/dynamic flags on every non-rig mesh = static.
|
||
{
|
||
std::vector<char> rigged(meshes.size(), 0);
|
||
for (auto& g : animGroups) for (auto& r : g.skinRecs) if (r.meshIdx < rigged.size()) rigged[r.meshIdx] = 1;
|
||
int cleared = 0;
|
||
for (size_t mi=0; mi<meshes.size(); ++mi) if (!rigged[mi]) {
|
||
auto& md = meshes[mi];
|
||
if (md.hasBones || md.dynamicVerts) ++cleared;
|
||
md.hasBones = false; md.dynamicVerts = false;
|
||
md.boneIndices.clear(); md.boneWeights.clear();
|
||
}
|
||
log(" HzAnim: cleared stray skinned flag on %d non-rig meshes (now static)", cleared);
|
||
}
|
||
|
||
// ── Player spawn points: parse home_spawn_points.hstf JSON -> facing-cone markers ──
|
||
std::vector<u8> spj;
|
||
if (extractBy("home_spawn_points", spj)) {
|
||
std::string j((const char*)spj.data(), spj.size());
|
||
auto fnum=[&](size_t from, const char* key)->float{
|
||
size_t k=j.find(key, from); if(k==std::string::npos) return 0.f;
|
||
k=j.find(':', k); if(k==std::string::npos) return 0.f;
|
||
return (float)atof(j.c_str()+k+1); };
|
||
size_t p=0;
|
||
while ((p=j.find("\"name\":\"Spawn Point", p)) != std::string::npos) {
|
||
SpawnPt sp; size_t nq=p+8, nqe=j.find('"', nq); sp.name=j.substr(nq, nqe-nq);
|
||
if (sp.name == "Spawn Points") { p = nqe; continue; } // skip the parent group node
|
||
size_t tg=j.find("\"tags\":[\"", p); if(tg!=std::string::npos){ tg+=9; sp.tag=j.substr(tg, j.find('"',tg)-tg); }
|
||
size_t lp=j.find("\"localPosition\"", p);
|
||
sp.pos[0]=fnum(lp,"\"x\""); sp.pos[1]=fnum(lp,"\"y\""); sp.pos[2]=fnum(lp,"\"z\"");
|
||
size_t lr=j.find("\"localRotation\"", p);
|
||
sp.euler[0]=fnum(lr,"\"x\""); sp.euler[1]=fnum(lr,"\"y\""); sp.euler[2]=fnum(lr,"\"z\"");
|
||
spawnPts.push_back(sp); p = (lr==std::string::npos)? nqe : lr+12;
|
||
}
|
||
for (auto& sp : spawnPts) {
|
||
MeshData md; md.name=sp.name; md.overlayKind=4; md.useBlend=true; md.doubleSided=true;
|
||
md.texRGBA={255,255,255,255}; md.texW=1; md.texH=1; // 1x1 white; tint gives the colour
|
||
auto add=[&](float lx,float ly,float lz){
|
||
md.positions.push_back(sp.pos[0]+lx); md.positions.push_back(sp.pos[1]+0.12f+ly); md.positions.push_back(sp.pos[2]+lz);
|
||
md.uvs.push_back(0.5f); md.uvs.push_back(0.5f); };
|
||
auto tri=[&](u32 a,u32 b,u32 c){ md.indices.push_back(a); md.indices.push_back(b); md.indices.push_back(c); };
|
||
float r=0.16f, h=0.55f; const int nb=6;
|
||
add(0,0,h); // 0 = apex (facing +Z)
|
||
for(int s=0;s<nb;s++){ float a=s*6.28319f/nb; add(r*cosf(a), r*sinf(a), 0.f); } // 1..nb base ring
|
||
for(int s=0;s<nb;s++) tri(0, 1+s, 1+(s+1)%nb); // cone sides
|
||
for(int s=1;s<nb-1;s++) tri(1, 1+s, 2+s); // base cap
|
||
md.nVerts=(u32)(md.positions.size()/3); md.nIdx=(u32)md.indices.size();
|
||
sp.meshIdx=(int)meshes.size(); meshes.push_back(std::move(md));
|
||
}
|
||
log(" Spawn points: %zu facing-cone markers", spawnPts.size());
|
||
}
|
||
}
|
||
|
||
// ── PROBE: HSR_PROBE="x,z" lists the meshes nearest that world point (find a gap's mesh) ───────
|
||
if (const char* pe = std::getenv("HSR_PROBE")) {
|
||
float px = 0, pz = 0; sscanf(pe, "%f,%f", &px, &pz);
|
||
// Meshes whose WORLD XZ-AABB CONTAINS the point (so big floor meshes centred elsewhere are found),
|
||
// sorted by min-Y (the floor sits lowest). Transform every vert to world (quat xyzw · scale + pos).
|
||
std::vector<std::pair<float,size_t>> hit; // {minY, idx}
|
||
for (size_t i = 0; i < meshes.size(); ++i) {
|
||
auto& m = meshes[i]; size_t nv = m.positions.size()/3; if (!nv) continue;
|
||
const float* q = m.transform.rot; const float* sc = m.transform.scale; const float* tp = m.transform.pos;
|
||
float mn[3]={1e9f,1e9f,1e9f}, mx[3]={-1e9f,-1e9f,-1e9f};
|
||
for (size_t v=0; v<nv; ++v) {
|
||
float lx=m.positions[v*3]*sc[0], ly=m.positions[v*3+1]*sc[1], lz=m.positions[v*3+2]*sc[2];
|
||
float tx=2*(q[1]*lz-q[2]*ly), ty=2*(q[2]*lx-q[0]*lz), tz=2*(q[0]*ly-q[1]*lx);
|
||
float wx=lx+q[3]*tx+(q[1]*tz-q[2]*ty)+tp[0], wy=ly+q[3]*ty+(q[2]*tx-q[0]*tz)+tp[1], wz=lz+q[3]*tz+(q[0]*ty-q[1]*tx)+tp[2];
|
||
if(wx<mn[0])mn[0]=wx; if(wy<mn[1])mn[1]=wy; if(wz<mn[2])mn[2]=wz;
|
||
if(wx>mx[0])mx[0]=wx; if(wy>mx[1])mx[1]=wy; if(wz>mx[2])mx[2]=wz;
|
||
}
|
||
if (px>=mn[0]-0.5f && px<=mx[0]+0.5f && pz>=mn[2]-0.5f && pz<=mx[2]+0.5f)
|
||
hit.push_back({mn[1], i});
|
||
}
|
||
std::sort(hit.begin(), hit.end());
|
||
fprintf(stderr, "[PROBE] %zu meshes whose world XZ-AABB contains (%.1f,%.1f) [sorted by minY]:\n", hit.size(), px, pz);
|
||
for (int k=0; k<30 && k<(int)hit.size(); ++k) { size_t i=hit[k].second; auto& m=meshes[i];
|
||
fprintf(stderr, " minY=%6.1f blend=%d ovl=%d %-44.44s %s\n",
|
||
hit[k].first, (int)m.useBlend, m.overlayKind, m.name.c_str(), m.shaderPath.c_str()); }
|
||
}
|
||
|
||
// ── LOAD REPORT: skipped / undecoded elements (ALWAYS printed, even with verbose off) ──────────
|
||
{
|
||
std::sort(skips.begin(), skips.end());
|
||
skips.erase(std::unique(skips.begin(), skips.end()), skips.end());
|
||
fprintf(stderr, "[LOAD REPORT] %zu meshes loaded; %zu distinct skipped/undecoded element(s)%s\n",
|
||
meshes.size(), skips.size(), skips.empty() ? " (all decoded)" : ":");
|
||
size_t cap = skips.size() < 50 ? skips.size() : 50;
|
||
for (size_t i = 0; i < cap; ++i) fprintf(stderr, " - %s\n", skips[i].c_str());
|
||
if (skips.size() > cap) fprintf(stderr, " ... (%zu more)\n", skips.size() - cap);
|
||
}
|
||
|
||
mz_zip_reader_end(&sceneZip);
|
||
sceneZipHold.reset();
|
||
return loadedCount > 0;
|
||
}
|
||
};
|