From dc29f7f1351f3be0c6520b89a18e3f30ba2ba62c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 9 May 2026 13:54:57 -0700 Subject: [PATCH] feat(pipeline): add WOL validation + time-of-day sampling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three additions to the Wowee Open Light format that landed last commit: • WoweeLightLoader::sampleAtTime(light, timeMin) returns the linearly-interpolated keyframe at any time-of-day, correctly handling wrap-around between the last keyframe and the first (e.g. 21:00 blends from dusk toward midnight by going forward through 00:00). • --validate-wol [--json] walks every keyframe and reports structural problems: time bounds (must be [0, 1440)), strict-ascending sort order, fogEnd > fogStart, finite color components. Exit code 0 PASS / 1 FAIL — CI-friendly. • --info-wol-at samples the interpolated state at a specific time of day. Useful for previewing what the renderer would feed in at a given moment, debugging keyframe gaps, or previewing a sub-range of the cycle. Smoke-tested: dawn-to-midnight blend at 03:00 yields a plausible mid-fade ambient (0.18, 0.16, 0.15) and dusk-to- midnight wrap at 21:00 yields the symmetric (0.19, 0.145, 0.14). The default 4-keyframe day/night cycle from makeDefaultDayNight passes --validate-wol cleanly. --- include/pipeline/wowee_light.hpp | 7 ++ src/pipeline/wowee_light.cpp | 48 ++++++++++ tools/editor/cli_arg_required.cpp | 2 +- tools/editor/cli_help.cpp | 4 + tools/editor/cli_world_info.cpp | 142 ++++++++++++++++++++++++++++++ 5 files changed, 202 insertions(+), 1 deletion(-) diff --git a/include/pipeline/wowee_light.hpp b/include/pipeline/wowee_light.hpp index 50b5efa8..91f530b5 100644 --- a/include/pipeline/wowee_light.hpp +++ b/include/pipeline/wowee_light.hpp @@ -58,6 +58,13 @@ public: // outdoor defaults. Used by --gen-light to create a starter // file users can edit. static WoweeLight makeDefaultDayNight(const std::string& zoneName); + + // Lookup the interpolated lighting state at any time-of-day + // (clamped to 0..1439 minutes). Linearly blends between the + // two adjacent keyframes; wraps around midnight if the query + // time falls between the last and first keyframe. + static WoweeLight::Keyframe sampleAtTime(const WoweeLight& light, + uint32_t timeMin); }; } // namespace pipeline diff --git a/src/pipeline/wowee_light.cpp b/src/pipeline/wowee_light.cpp index 67f97139..dafb4683 100644 --- a/src/pipeline/wowee_light.cpp +++ b/src/pipeline/wowee_light.cpp @@ -104,6 +104,54 @@ bool WoweeLightLoader::exists(const std::string& basePath) { return is.good(); } +WoweeLight::Keyframe WoweeLightLoader::sampleAtTime( + const WoweeLight& light, uint32_t timeMin) { + if (light.keyframes.empty()) return WoweeLight::Keyframe{}; + if (light.keyframes.size() == 1) return light.keyframes.front(); + timeMin = timeMin % 1440; + // Find the keyframe pair (a, b) such that a.t <= timeMin < b.t. + // Wrap: if timeMin is before the first keyframe or at/after the + // last, blend between (last, first + 1440). + const auto& kfs = light.keyframes; + auto it = std::upper_bound(kfs.begin(), kfs.end(), timeMin, + [](uint32_t t, const WoweeLight::Keyframe& kf) { + return t < kf.timeOfDayMin; + }); + const WoweeLight::Keyframe* a; + const WoweeLight::Keyframe* b; + uint32_t aT, bT; + if (it == kfs.begin() || it == kfs.end()) { + // Wrap-around: between last and first (+ 1440). + a = &kfs.back(); + b = &kfs.front(); + aT = a->timeOfDayMin; + bT = b->timeOfDayMin + 1440; + if (it == kfs.begin()) { + // timeMin is BEFORE the first keyframe, so we're in + // the wrap window. Shift query into [aT, bT) by adding + // 1440 to it. + timeMin += 1440; + } + } else { + b = &(*it); + a = &(*(it - 1)); + aT = a->timeOfDayMin; + bT = b->timeOfDayMin; + } + float t = (bT == aT) ? 0.0f + : static_cast(timeMin - aT) / + static_cast(bT - aT); + WoweeLight::Keyframe out; + out.timeOfDayMin = timeMin % 1440; + out.ambientColor = a->ambientColor + t * (b->ambientColor - a->ambientColor); + out.directionalColor = a->directionalColor + t * (b->directionalColor - a->directionalColor); + out.directionalDir = a->directionalDir + t * (b->directionalDir - a->directionalDir); + out.fogColor = a->fogColor + t * (b->fogColor - a->fogColor); + out.fogStart = a->fogStart + t * (b->fogStart - a->fogStart); + out.fogEnd = a->fogEnd + t * (b->fogEnd - a->fogEnd); + return out; +} + WoweeLight WoweeLightLoader::makeDefaultDayNight( const std::string& zoneName) { WoweeLight out; diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index d79c1b5d..af8501e2 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -15,7 +15,7 @@ const char* const kArgRequired[] = { "--list-zone-meshes-detail", "--list-project-meshes-detail", "--info-mesh", "--info-mesh-storage-budget", "--info-mesh-stats", "--info-wob", "--info-wob-stats", "--info-woc", "--info-wot", - "--info-wol", "--gen-light", + "--info-wol", "--info-wol-at", "--validate-wol", "--gen-light", "--info-creatures", "--info-objects", "--info-quests", "--info-extract", "--info-extract-tree", "--info-extract-budget", "--list-missing-sidecars", diff --git a/tools/editor/cli_help.cpp b/tools/editor/cli_help.cpp index af399bec..355be362 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -773,6 +773,10 @@ void printUsage(const char* argv0) { std::printf(" Print WOC collision metadata (triangle counts, bounds) and exit\n"); std::printf(" --info-wol [--json]\n"); std::printf(" Print WOL lighting keyframes (zone name + per-time-of-day ambient/directional/fog) and exit\n"); + std::printf(" --info-wol-at \n"); + std::printf(" Sample the WOL's interpolated lighting at a specific time-of-day (linear blend between keyframes)\n"); + std::printf(" --validate-wol [--json]\n"); + std::printf(" Walk every keyframe; check time bounds + sort order + fogEnd > fogStart + finite color components\n"); std::printf(" --gen-light [zoneName]\n"); std::printf(" Emit a starter .wol with the canonical 4-keyframe day/night cycle (midnight + dawn + noon + dusk)\n"); std::printf(" --info-wot [--json]\n"); diff --git a/tools/editor/cli_world_info.cpp b/tools/editor/cli_world_info.cpp index a09db6a2..75319907 100644 --- a/tools/editor/cli_world_info.cpp +++ b/tools/editor/cli_world_info.cpp @@ -389,6 +389,142 @@ int handleInfoWol(int& i, int argc, char** argv) { return 0; } +int handleValidateWol(int& i, int argc, char** argv) { + // Walk every keyframe in a .wol and report structural problems: + // • times outside [0, 1440) + // • unsorted timeOfDayMin + // • duplicate timestamps + // • zero-area fog distances (fogEnd <= fogStart) + // • non-finite color components + // Returns 0 PASS / 1 FAIL. + std::string base = argv[++i]; + bool jsonOut = (i + 1 < argc && + std::strcmp(argv[i + 1], "--json") == 0); + if (jsonOut) ++i; + if (base.size() >= 4 && base.substr(base.size() - 4) == ".wol") + base = base.substr(0, base.size() - 4); + if (!wowee::pipeline::WoweeLightLoader::exists(base)) { + std::fprintf(stderr, "WOL not found: %s.wol\n", base.c_str()); + return 1; + } + auto wol = wowee::pipeline::WoweeLightLoader::load(base); + std::vector errors; + if (wol.keyframes.empty()) { + errors.push_back("no keyframes"); + } + uint32_t prevTime = 0; + bool first = true; + auto checkColor = [&](const glm::vec3& c, const char* label, int idx) { + for (int k = 0; k < 3; ++k) { + float v = c[k]; + if (!std::isfinite(v)) { + errors.push_back("kf " + std::to_string(idx) + " " + + label + " channel " + std::to_string(k) + + " is non-finite"); + } + } + }; + for (std::size_t k = 0; k < wol.keyframes.size(); ++k) { + const auto& kf = wol.keyframes[k]; + if (kf.timeOfDayMin >= 1440) { + errors.push_back("kf " + std::to_string(k) + + " time " + std::to_string(kf.timeOfDayMin) + + " >= 1440"); + } + if (!first && kf.timeOfDayMin <= prevTime) { + errors.push_back("kf " + std::to_string(k) + + " time " + std::to_string(kf.timeOfDayMin) + + " <= previous " + std::to_string(prevTime)); + } + if (kf.fogEnd <= kf.fogStart) { + errors.push_back("kf " + std::to_string(k) + + " fogEnd " + std::to_string(kf.fogEnd) + + " <= fogStart " + + std::to_string(kf.fogStart)); + } + checkColor(kf.ambientColor, "ambient", static_cast(k)); + checkColor(kf.directionalColor, "directional", + static_cast(k)); + checkColor(kf.fogColor, "fog", static_cast(k)); + prevTime = kf.timeOfDayMin; + first = false; + } + if (jsonOut) { + nlohmann::json j; + j["wol"] = base + ".wol"; + j["passed"] = errors.empty(); + j["errorCount"] = errors.size(); + j["errors"] = errors; + std::printf("%s\n", j.dump(2).c_str()); + return errors.empty() ? 0 : 1; + } + if (errors.empty()) { + std::printf("WOL %s.wol PASSED — %zu keyframe(s) valid\n", + base.c_str(), wol.keyframes.size()); + return 0; + } + std::printf("WOL %s.wol FAILED — %zu error(s):\n", + base.c_str(), errors.size()); + for (const auto& e : errors) std::printf(" - %s\n", e.c_str()); + return 1; +} + +int handleInfoWolAt(int& i, int argc, char** argv) { + // Sample the WOL's interpolated lighting state at a specific + // time-of-day, given as HH:MM (24-hour) or as raw minutes. + std::string base = argv[++i]; + if (i + 1 >= argc) { + std::fprintf(stderr, "info-wol-at: missing time argument\n"); + return 1; + } + std::string timeStr = argv[++i]; + int timeMin = 0; + auto colon = timeStr.find(':'); + if (colon != std::string::npos) { + try { + int hh = std::stoi(timeStr.substr(0, colon)); + int mm = std::stoi(timeStr.substr(colon + 1)); + timeMin = (hh * 60 + mm) % 1440; + } catch (...) { + std::fprintf(stderr, "info-wol-at: bad time %s (use HH:MM)\n", + timeStr.c_str()); + return 1; + } + } else { + try { timeMin = std::stoi(timeStr) % 1440; } catch (...) { + std::fprintf(stderr, "info-wol-at: bad time %s (use minutes)\n", + timeStr.c_str()); + return 1; + } + } + if (timeMin < 0) timeMin += 1440; + if (base.size() >= 4 && base.substr(base.size() - 4) == ".wol") + base = base.substr(0, base.size() - 4); + if (!wowee::pipeline::WoweeLightLoader::exists(base)) { + std::fprintf(stderr, "WOL not found: %s.wol\n", base.c_str()); + return 1; + } + auto wol = wowee::pipeline::WoweeLightLoader::load(base); + if (!wol.isValid()) { + std::fprintf(stderr, "WOL parse failed: %s.wol\n", base.c_str()); + return 1; + } + auto kf = wowee::pipeline::WoweeLightLoader::sampleAtTime( + wol, static_cast(timeMin)); + std::printf("WOL %s.wol sample at %02d:%02d\n", + base.c_str(), timeMin / 60, timeMin % 60); + std::printf(" ambient : (%.3f, %.3f, %.3f)\n", + kf.ambientColor.r, kf.ambientColor.g, kf.ambientColor.b); + std::printf(" directional: (%.3f, %.3f, %.3f) dir (%.2f, %.2f, %.2f)\n", + kf.directionalColor.r, kf.directionalColor.g, + kf.directionalColor.b, + kf.directionalDir.x, kf.directionalDir.y, kf.directionalDir.z); + std::printf(" fog : (%.3f, %.3f, %.3f) [%.1f..%.1f]\n", + kf.fogColor.r, kf.fogColor.g, kf.fogColor.b, + kf.fogStart, kf.fogEnd); + return 0; +} + int handleGenLight(int& i, int argc, char** argv) { // Emit a starter .wol file with the default 4-keyframe day/ // night cycle (midnight, dawn, noon, dusk). User can edit @@ -434,6 +570,12 @@ bool handleWorldInfo(int& i, int argc, char** argv, int& outRc) { if (std::strcmp(argv[i], "--info-wol") == 0 && i + 1 < argc) { outRc = handleInfoWol(i, argc, argv); return true; } + if (std::strcmp(argv[i], "--info-wol-at") == 0 && i + 2 < argc) { + outRc = handleInfoWolAt(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--validate-wol") == 0 && i + 1 < argc) { + outRc = handleValidateWol(i, argc, argv); return true; + } if (std::strcmp(argv[i], "--gen-light") == 0 && i + 1 < argc) { outRc = handleGenLight(i, argc, argv); return true; }