diff --git a/CMakeLists.txt b/CMakeLists.txt index db3a0a9..e49dd85 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -234,6 +234,7 @@ set(SKATE3_COMMON_SOURCES src/skate3_shader_disasm.cpp src/skate3_title_update_installer.cpp src/skate3_user_settings.cpp + src/skate3_win_icon.cpp third_party/rexglue-sdk/thirdparty/crypto/sha256.cpp ) if(APPLE) diff --git a/src/skate3_app_common.cpp b/src/skate3_app_common.cpp index 10b5a7e..53e215a 100644 --- a/src/skate3_app_common.cpp +++ b/src/skate3_app_common.cpp @@ -7,6 +7,7 @@ #include "skate3_native_scene.h" #include "skate3_screenshot.h" #include "skate3_shader_disasm.h" +#include "skate3_win_icon.h" #include "skate3_title_update_installer.h" #include "skate3_user_settings.h" @@ -687,6 +688,15 @@ std::optional Skate3BaseApp::OnFinalizePaths( } runtime_paths = std::move(tu_paths); } +#endif +#if defined(_WIN32) + // Window/taskbar + Explorer icon sourced from the user's OWN game art at + // runtime (game/nxeart); the shipped exe and the repo carry no EA + // artwork (see skate3_win_icon.h). Game files are guaranteed installed by + // this point on Windows (the install wizards above run blocking). + skate3::ApplyGameIconFromGameData( + runtime_paths.game_data_root, + window() ? window()->GetNativeWindowHandle() : nullptr); #endif return runtime_paths; } diff --git a/src/skate3_native_scene.cpp b/src/skate3_native_scene.cpp index e222900..a72e881 100644 --- a/src/skate3_native_scene.cpp +++ b/src/skate3_native_scene.cpp @@ -5332,7 +5332,8 @@ void InterpolateDynamicItems(uint8_t* base, FrameScene& scene, double now) { }; uint64_t key = (uint64_t(item.mesh) << 8) | (k & 0xFF); DynHist* hp = &s_hist[key]; - if (hp->seen == s_frame || hist_dist2(*hp) > 2.25f) { + const float own_d2 = hp->seen == s_frame ? 1e30f : hist_dist2(*hp); + if (own_d2 > 1e-4f) { // The k-th slot mispairs: the game's sort lists RESHUFFLE same-mesh // clones as they and the camera move. For static props the // reset-on-jump guard below was enough (a mispair rendered raw for a @@ -5341,7 +5342,24 @@ void InterpolateDynamicItems(uint8_t* base, FrameScene& scene, double now) { // stepped poses (the vehicle judder/catch-up). Re-pair by POSITION // instead: claim the unclaimed history of this mesh whose newest // pose is nearest, within the same 1.5 m one-tick jump gate. - float best = 2.25f; + // The search runs whenever the slot is not an (almost) EXACT + // continuation, not only past the 1.5 m gate: clone placements + // CLOSER than 1.5 m (the paired newspaper holders, 0.78 m apart) + // otherwise inherit each other's history on every reshuffle, and the + // interpolator renders them sliding between the two placements ("the + // props jitter in and out of position"). Seeding `best` with the own + // slot's distance keeps a claim strictly-nearer-only, so a genuinely + // moving entity still prefers its own ring. + // RIGID claims get a far tighter cap than the vehicles' 1.5 m: when a + // close clone's OWN ring goes stale (its submission flapped for a few + // frames), strictly-nearer alone still claims the TWIN's fresh ring + // (own = 1e30) and slides once, the residual single jitter seen + // after the strictly-nearer fix. No rigid prop moves 0.3 m in one sim + // tick (18 m/s at 60 Hz), while clone placements sit >= 0.78 m apart; + // a rigid item with no history inside 0.3 m starts a fresh ring and + // renders raw at its correct placement instead. + const float claim_cap = skinned ? 2.25f : 0.09f; + float best = std::min(own_d2, claim_cap); DynHist* alt = nullptr; uint64_t alt_key = key; uint32_t fresh_k = 256; // first index past the dense key range @@ -5596,7 +5614,23 @@ void InterpolateDynamicItems(uint8_t* base, FrameScene& scene, double now) { const float dz = item.world[14] - latest.w[14]; d2 = dx * dx + dy * dy + dz * dz; } - discontinuity = d2 > 2.25f; // > 1.5 m + // Rigid uses the same tight one-tick bound as the claim cap + // above: 0.78 m clone placements sit inside the vehicles' 1.5 m + // gate, and lerping across a mispair IS the prop jitter. + discontinuity = d2 > (skinned ? 2.25f : 0.09f); + if (discontinuity && !skinned && d2 < 2.25f) { + // A rigid step that only the tightened bound caught = a + // close-clone mispair that would have LERPED (the prop jitter). + static std::atomic s_rigid_mispair{0}; + const uint64_t n = + s_rigid_mispair.fetch_add(1, std::memory_order_relaxed); + if (n < 24 || (n & 255u) == 0) { + REXLOG_INFO( + "native-scene: rigid close-clone mispair reset mesh={:08X} " + "k={} d={:.2f}m (n={})", + item.mesh, k, std::sqrt(d2), n); + } + } } if (discontinuity) { h.count = 0; diff --git a/src/skate3_win_icon.cpp b/src/skate3_win_icon.cpp new file mode 100644 index 0000000..2222346 --- /dev/null +++ b/src/skate3_win_icon.cpp @@ -0,0 +1,307 @@ +// Application icon from the user's own game files, see skate3_win_icon.h. + +#include "skate3_win_icon.h" + +#if defined(_WIN32) + +#include +// initguid must precede wincodec so the WIC CLSIDs/IIDs are defined in this +// translation unit (no dependency on uuid.lib link order). +#include +#include + +#include +#include +#include +#include +#include + +#include + +namespace skate3 { +namespace { + +// First embedded PNG in the nxeart dashboard-art package = the 64x64 title +// tile (two identical copies exist; either is fine). +std::vector ExtractFirstPng(const std::filesystem::path& path) { + std::ifstream f(path, std::ios::binary); + if (!f) { + return {}; + } + std::vector data((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + static const uint8_t kMagic[8] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'}; + static const uint8_t kIend[4] = {'I', 'E', 'N', 'D'}; + for (size_t i = 0; i + 8 <= data.size(); ++i) { + if (std::memcmp(data.data() + i, kMagic, 8) != 0) { + continue; + } + for (size_t j = i + 8; j + 8 <= data.size(); ++j) { + if (std::memcmp(data.data() + j, kIend, 4) == 0) { + // IEND chunk data end + 4-byte CRC. + return std::vector(data.begin() + i, data.begin() + j + 8); + } + } + break; + } + return {}; +} + +// Decode the PNG and scale to `size` x `size` 32bpp BGRA (top-down rows). +bool WicDecodeScaled(IWICImagingFactory* wic, const std::vector& png, + uint32_t size, std::vector& bgra_out) { + IWICStream* stream = nullptr; + if (FAILED(wic->CreateStream(&stream))) { + return false; + } + bool ok = false; + IWICBitmapDecoder* decoder = nullptr; + IWICBitmapFrameDecode* frame = nullptr; + IWICBitmapScaler* scaler = nullptr; + IWICFormatConverter* converter = nullptr; + do { + if (FAILED(stream->InitializeFromMemory( + const_cast(png.data()), DWORD(png.size())))) { + break; + } + if (FAILED(wic->CreateDecoderFromStream(stream, nullptr, + WICDecodeMetadataCacheOnDemand, + &decoder))) { + break; + } + if (FAILED(decoder->GetFrame(0, &frame))) { + break; + } + if (FAILED(wic->CreateBitmapScaler(&scaler)) || + FAILED(scaler->Initialize(frame, size, size, + WICBitmapInterpolationModeFant))) { + break; + } + if (FAILED(wic->CreateFormatConverter(&converter)) || + FAILED(converter->Initialize(scaler, GUID_WICPixelFormat32bppBGRA, + WICBitmapDitherTypeNone, nullptr, 0.0, + WICBitmapPaletteTypeCustom))) { + break; + } + bgra_out.resize(size_t(size) * size * 4); + if (FAILED(converter->CopyPixels(nullptr, size * 4, + UINT(bgra_out.size()), bgra_out.data()))) { + break; + } + ok = true; + } while (false); + if (converter) converter->Release(); + if (scaler) scaler->Release(); + if (frame) frame->Release(); + if (decoder) decoder->Release(); + if (stream) stream->Release(); + return ok; +} + +// Icon-resource DIB image: BITMAPINFOHEADER with DOUBLED height, XOR (BGRA) +// rows bottom-up, then an all-zero AND mask (alpha carries transparency). +std::vector BuildIconDib(uint32_t size, + const std::vector& bgra_topdown) { + const uint32_t xor_bytes = size * size * 4; + const uint32_t and_stride = ((size + 31) / 32) * 4; + const uint32_t and_bytes = and_stride * size; + std::vector out(sizeof(BITMAPINFOHEADER) + xor_bytes + and_bytes, 0); + auto* bih = reinterpret_cast(out.data()); + bih->biSize = sizeof(BITMAPINFOHEADER); + bih->biWidth = LONG(size); + bih->biHeight = LONG(size) * 2; // XOR + AND, both bottom-up + bih->biPlanes = 1; + bih->biBitCount = 32; + bih->biCompression = BI_RGB; + bih->biSizeImage = xor_bytes + and_bytes; + uint8_t* xor_dst = out.data() + sizeof(BITMAPINFOHEADER); + for (uint32_t y = 0; y < size; ++y) { + std::memcpy(xor_dst + size_t(y) * size * 4, + bgra_topdown.data() + size_t(size - 1 - y) * size * 4, + size_t(size) * 4); + } + return out; +} + +#pragma pack(push, 2) +struct GrpIconDirEntry { + BYTE bWidth; + BYTE bHeight; + BYTE bColorCount; + BYTE bReserved; + WORD wPlanes; + WORD wBitCount; + DWORD dwBytesInRes; + WORD nId; +}; +struct GrpIconDir { + WORD idReserved; + WORD idType; + WORD idCount; +}; +#pragma pack(pop) + +// One-time self-patch: write RT_ICON entries + a MAINICON RT_GROUP_ICON into +// the exe file. The running image is write-locked but RENAMABLE: patch a +// copy, then swap it into place. The renamed original stays locked until +// exit and is deleted by the NEXT (already-patched) run. +void EnsureExeIconResource(const std::vector& png) { + wchar_t exe_w[MAX_PATH]; + if (GetModuleFileNameW(nullptr, exe_w, MAX_PATH) == 0) { + return; + } + const std::filesystem::path exe(exe_w); + const std::filesystem::path pre = exe.wstring() + L".pre-icon"; + if (FindResourceW(GetModuleHandleW(nullptr), L"MAINICON", + reinterpret_cast(RT_GROUP_ICON)) != nullptr) { + // Already patched (or a build that ships an icon): just clean up any + // leftover pre-patch original from the run that performed the swap. + DeleteFileW(pre.c_str()); + return; + } + + // Build the multi-size images. WIC failure falls back to a single raw-PNG + // entry (valid icon image format since Vista). + struct IconImage { + uint32_t size; + std::vector data; + }; + std::vector images; + const HRESULT co = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + { + IWICImagingFactory* wic = nullptr; + if (SUCCEEDED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, + CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(&wic)))) { + for (uint32_t s : {16u, 24u, 32u, 48u, 64u, 256u}) { + std::vector bgra; + if (WicDecodeScaled(wic, png, s, bgra)) { + images.push_back({s, BuildIconDib(s, bgra)}); + } + } + wic->Release(); + } + } + if (SUCCEEDED(co)) { + CoUninitialize(); + } + if (images.empty()) { + images.push_back({64, png}); + } + + GrpIconDir dir{0, 1, WORD(images.size())}; + std::vector group(sizeof(GrpIconDir) + + images.size() * sizeof(GrpIconDirEntry)); + std::memcpy(group.data(), &dir, sizeof(dir)); + for (size_t i = 0; i < images.size(); ++i) { + GrpIconDirEntry e{}; + e.bWidth = BYTE(images[i].size >= 256 ? 0 : images[i].size); + e.bHeight = e.bWidth; + e.wPlanes = 1; + e.wBitCount = 32; + e.dwBytesInRes = DWORD(images[i].data.size()); + e.nId = WORD(i + 1); + std::memcpy(group.data() + sizeof(GrpIconDir) + i * sizeof(e), &e, + sizeof(e)); + } + + // Patch a COPY first so a failure changes nothing. + const std::filesystem::path temp = exe.wstring() + L".icon-new"; + if (!CopyFileW(exe.c_str(), temp.c_str(), FALSE)) { + REXLOG_INFO("win-icon: exe copy for icon patch failed ({}); skipping", + GetLastError()); + return; + } + const WORD lang = MAKELANGID(LANG_ENGLISH, SUBLANG_ENGLISH_US); + HANDLE update = BeginUpdateResourceW(temp.c_str(), FALSE); + bool ok = update != nullptr; + for (size_t i = 0; ok && i < images.size(); ++i) { + ok = UpdateResourceW(update, reinterpret_cast(RT_ICON), + MAKEINTRESOURCEW(WORD(i + 1)), lang, + images[i].data.data(), DWORD(images[i].data.size())); + } + if (ok) { + ok = UpdateResourceW(update, reinterpret_cast(RT_GROUP_ICON), + L"MAINICON", lang, group.data(), DWORD(group.size())); + } + if (update != nullptr) { + // Commit (discard on failure). + ok = EndUpdateResourceW(update, !ok) && ok; + } + if (!ok) { + REXLOG_INFO("win-icon: icon resource patch failed ({}); skipping", + GetLastError()); + DeleteFileW(temp.c_str()); + return; + } + // Swap: rename the (locked but renamable) running exe aside, move the + // patched copy into place. On the second step failing, restore. + if (!MoveFileExW(exe.c_str(), pre.c_str(), MOVEFILE_REPLACE_EXISTING)) { + REXLOG_INFO("win-icon: could not move running exe aside ({}); skipping", + GetLastError()); + DeleteFileW(temp.c_str()); + return; + } + if (!MoveFileW(temp.c_str(), exe.c_str())) { + MoveFileW(pre.c_str(), exe.c_str()); + return; + } + REXLOG_INFO( + "win-icon: Explorer icon patched into {} from the game's own art " + "(one-time; {} removed on next launch)", + exe.filename().string(), pre.filename().string()); +} + +} // namespace + +void ApplyGameIconFromGameData(const std::filesystem::path& game_data_root, + void* native_window_handle) { + std::filesystem::path nxeart = game_data_root / "nxeart"; + std::error_code ec; + if (!std::filesystem::exists(nxeart, ec)) { + nxeart = game_data_root / "game" / "nxeart"; + } + HWND hwnd = static_cast(native_window_handle); + std::thread([nxeart, hwnd] { + const std::vector png = ExtractFirstPng(nxeart); + if (png.empty()) { + REXLOG_INFO("win-icon: no title icon found in {}", nxeart.string()); + return; + } + if (hwnd != nullptr) { + // CreateIconFromResourceEx accepts PNG image data (Vista+). Explicit + // small size for crisp titlebar/taskbar small renditions. + HICON icon_big = CreateIconFromResourceEx( + const_cast(png.data()), DWORD(png.size()), TRUE, 0x00030000, + 0, 0, LR_DEFAULTCOLOR); + HICON icon_small = CreateIconFromResourceEx( + const_cast(png.data()), DWORD(png.size()), TRUE, 0x00030000, + GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON), + LR_DEFAULTCOLOR); + DWORD_PTR unused = 0; + if (icon_big) { + SendMessageTimeoutW(hwnd, WM_SETICON, ICON_BIG, + reinterpret_cast(icon_big), SMTO_NORMAL, + 2000, &unused); + } + if (icon_small) { + SendMessageTimeoutW(hwnd, WM_SETICON, ICON_SMALL, + reinterpret_cast(icon_small), SMTO_NORMAL, + 2000, &unused); + } + // HICONs stay alive for the window's lifetime (never destroyed: + // process-lifetime singletons). + } + EnsureExeIconResource(png); + }).detach(); +} + +} // namespace skate3 + +#else // !_WIN32 + +namespace skate3 { +void ApplyGameIconFromGameData(const std::filesystem::path&, void*) {} +} // namespace skate3 + +#endif diff --git a/src/skate3_win_icon.h b/src/skate3_win_icon.h new file mode 100644 index 0000000..72bf133 --- /dev/null +++ b/src/skate3_win_icon.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +namespace skate3 { + +// Application icon sourced from the USER'S OWN game files at runtime; the +// shipped executable and the repository deliberately contain no EA artwork +// (same clean-distribution rule as every other game asset). +// +// Windows only (no-op elsewhere). Two halves, both fed by the 64x64 title +// tile PNG embedded in game/nxeart (the Xbox dashboard-art package): +// - window/taskbar/Alt-Tab icon: WM_SETICON on the game window, every run; +// - Explorer file icon: a one-time self-patch that writes a MAINICON +// resource group into the exe ON DISK (the running image is rename-and- +// copy swapped since it is write-locked). Idempotent: subsequent runs see +// the resource and skip; overwriting the exe with an update simply +// re-patches on the next run. +// +// Runs on a background thread; failures only log (the icon stays default). +void ApplyGameIconFromGameData(const std::filesystem::path& game_data_root, + void* native_window_handle); + +} // namespace skate3