diff --git a/CMakeLists.txt b/CMakeLists.txt index 686a5ec..f0a04e1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -225,6 +225,7 @@ set(SKATE3_COMMON_SOURCES src/exception_compat.cpp src/skate3_app_common.cpp src/skate3_demo_path.cpp + src/skate3_draw_distance.cpp src/skate3_fov.cpp src/skate3_iso_installer.cpp src/native/skate3_native_diagnostics.cpp diff --git a/src/skate3_demo_path.cpp b/src/skate3_demo_path.cpp index 10eef87..fee2aa0 100644 --- a/src/skate3_demo_path.cpp +++ b/src/skate3_demo_path.cpp @@ -400,6 +400,10 @@ void InstallHooks(rex::runtime::FunctionDispatcher* dispatcher) { StartGameplayInputWorkerIfNeeded(); } +rex::input::InputSystem* GetUiInputSystem() { + return g_ui_input_provider ? g_ui_input_provider() : nullptr; +} + void SetUiInputProvider(std::function provider) { g_ui_input_provider = std::move(provider); } diff --git a/src/skate3_demo_path.h b/src/skate3_demo_path.h index e868f70..ec5b329 100644 --- a/src/skate3_demo_path.h +++ b/src/skate3_demo_path.h @@ -16,6 +16,9 @@ void InstallHooks(rex::runtime::FunctionDispatcher* dispatcher); // movie skip. Set during app setup, before the guest boots (the provider is // read from the guest thread without further synchronization). void SetUiInputProvider(std::function provider); +// The provider's current input system, or null before app setup. For other +// host features that poll the merged UI pad state. +rex::input::InputSystem* GetUiInputSystem(); bool ShouldForceIntroMovieComplete(); } // namespace skate3::demo_path diff --git a/src/skate3_draw_distance.cpp b/src/skate3_draw_distance.cpp new file mode 100644 index 0000000..f3924e0 --- /dev/null +++ b/src/skate3_draw_distance.cpp @@ -0,0 +1,671 @@ +// Extended draw distance and LOD switch ranges. +// +// Three guest systems decide how far away geometry stays visible/detailed: +// +// 1. Screen-contribution cull (Sk8::VectorizedCullingJob -> DoFrustumCulling): +// beyond an engage distance a mesh survives only while +// size * (engage / dist^2) >= threshold. The threshold lives at +// [cullObject+6064] and is programmed from config data by sub_8288DC58. +// Small world meshes (foliage, street furniture, props) hard-pop when the +// verdict flips; scaling the threshold by 1/k^2 moves the pop distance +// out by k. The engage distance at +6068 is left untouched. +// +// 2. Per-zone optimesh mesh cull: WorldPresEntityOptimesh rebuilds a +// {camX, drawDistance, camZ, 1} reference vector at entity+512 every +// frame (sub_82792900, attribute 0x2E9AAECD1C29F81F); the cull job +// compares world-mesh pieces against lane 1. +// +// 3. Character LOD bands: SceneRenderView::GetLODDistancesFromAttrib +// (sub_827E1AD8) rewrites six squared switch distances every frame, +// view+23760/23764/23768 for skaters/pedestrians and +23772/23776/23780 +// for vehicles, consumed by UpdateEntityLOD against the squared +// camera distance. Static world geometry is not routed through these +// bands (it fails the entity-type gate), hence the separate cull hooks. +// +// The game refreshes (2) and (3) every frame and (1) on config apply, so each +// hook rescales the freshly written values in place and nothing compounds. +// The cull threshold is additionally reapplied once per frame from the +// GetLODDistancesFromAttrib hook (the view's cull object is at view+8) so +// cvar changes take effect without a config reload. + +#include "generated/skate3_init.h" +#include "native/skate3_native_guest_read.h" +#include "skate3_demo_path.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#endif + +REXCVAR_DEFINE_DOUBLE(skate3_draw_distance_scale, 2.0, "Skate 3", + "Scale the distance at which small world meshes " + "(foliage, props, street furniture) stop being drawn. " + "1 = original console behavior. Larger values draw more " + "of the world and cost proportionally more GPU/CPU.") + .range(0.25, 16.0) + .lifecycle(rex::cvar::Lifecycle::kHotReload); +REXCVAR_DEFINE_DOUBLE(skate3_lod_distance_scale, 2.0, "Skate 3", + "Scale the distances at which skaters, pedestrians and " + "vehicles switch to lower-detail models. 1 = original " + "console behavior.") + .range(0.25, 16.0) + .lifecycle(rex::cvar::Lifecycle::kHotReload); +REXCVAR_DEFINE_DOUBLE(skate3_draw_distance_stream_probe, 0.0, "Skate 3", + "Pre-stream world detail cells this many metres ahead " + "of the camera focus. The game's detail collections " + "(foliage clusters, street furniture sets) are streamed " + "per grid cell only once the focus enters the cell, so " + "their content pops in at the cell boundary; probing " + "neighbouring cells at this radius loads them before " + "they are visible. 0 = original console behavior.") + .range(0.0, 1000.0) + .lifecycle(rex::cvar::Lifecycle::kHotReload); +REXCVAR_DEFINE_BOOL(skate3_draw_distance_debug, false, "Skate 3", + "Log draw-distance diagnostics: programmed cull thresholds, " + "world-mesh cull reference vectors, and asset-collection " + "stream load/unload requests. Used to attribute a specific " + "pop-in to the culling vs. streaming system.") + .lifecycle(rex::cvar::Lifecycle::kHotReload); + +namespace { + +constexpr double kScaleEpsilon = 1e-3; + +inline uint32_t LoadGuestU32(uint8_t* base, uint32_t addr) { + uint32_t raw; + std::memcpy(&raw, base + addr, 4); + return __builtin_bswap32(raw); +} + +inline uint64_t LoadGuestU64(uint8_t* base, uint32_t addr) { + uint64_t raw; + std::memcpy(&raw, base + addr, 8); + return __builtin_bswap64(raw); +} + +inline void StoreGuestU32(uint8_t* base, uint32_t addr, uint32_t value) { + const uint32_t raw = __builtin_bswap32(value); + std::memcpy(base + addr, &raw, 4); +} + +inline float LoadGuestF32(uint8_t* base, uint32_t addr) { + uint32_t raw; + std::memcpy(&raw, base + addr, 4); + raw = __builtin_bswap32(raw); + float value; + std::memcpy(&value, &raw, 4); + return value; +} + +inline void StoreGuestF32(uint8_t* base, uint32_t addr, float value) { + uint32_t raw; + std::memcpy(&raw, &value, 4); + raw = __builtin_bswap32(raw); + std::memcpy(base + addr, &raw, 4); +} + +inline bool PlausibleGuestAddr(uint32_t addr) { return addr >= 0x10000; } + +// Contribution-cull threshold slots we manage. The threshold is programmed +// by the game through more than one path (boot-time defaults as well as the +// config-apply setter), so instead of depending on catching every writer, +// each slot remembers the game's unscaled value and the exact bits we last +// wrote: a current value that differs from our last write is a fresh game +// value and becomes the new base. The scale is always derived from the base, +// never from the current value, so nothing compounds. +struct CullThresholdSlot { + uint32_t addr = 0; + uint32_t base_bits = 0; + uint32_t written_bits = 0; +}; + +std::mutex g_cull_slots_mutex; +CullThresholdSlot g_cull_slots[16]; +size_t g_cull_slot_count = 0; + +uint32_t ScaledThresholdBits(uint32_t base_bits) { + const double scale = REXCVAR_GET(skate3_draw_distance_scale); + if (std::abs(scale - 1.0) <= kScaleEpsilon || scale <= 0.0) { + return base_bits; + } + float value; + std::memcpy(&value, &base_bits, 4); + value = float(double(value) / (scale * scale)); + uint32_t bits; + std::memcpy(&bits, &value, 4); + return bits; +} + +void EnsureCullThresholdScaled(uint8_t* base, uint32_t cull_object) { + if (!PlausibleGuestAddr(cull_object)) { + return; + } + const uint32_t addr = cull_object + 6064; + uint32_t current; + std::memcpy(¤t, base + addr, 4); + current = __builtin_bswap32(current); + std::lock_guard lock(g_cull_slots_mutex); + CullThresholdSlot* slot = nullptr; + for (size_t i = 0; i < g_cull_slot_count; ++i) { + if (g_cull_slots[i].addr == addr) { + slot = &g_cull_slots[i]; + break; + } + } + if (slot == nullptr) { + if (g_cull_slot_count >= sizeof(g_cull_slots) / sizeof(g_cull_slots[0])) { + return; + } + slot = &g_cull_slots[g_cull_slot_count++]; + slot->addr = addr; + slot->base_bits = current; + slot->written_bits = current; + } else if (current != slot->written_bits) { + slot->base_bits = current; + } + const uint32_t scaled = ScaledThresholdBits(slot->base_bits); + if (scaled != current) { + const uint32_t raw = __builtin_bswap32(scaled); + std::memcpy(base + addr, &raw, 4); + } + slot->written_bits = scaled; +} + +void RecordCullThreshold(uint8_t* base, uint32_t cull_object) { + if (!PlausibleGuestAddr(cull_object)) { + return; + } + if (REXCVAR_GET(skate3_draw_distance_debug)) { + REXLOG_INFO( + "draw_distance: cull params obj=0x{:08X} flags=0x{:08X} " + "threshold={} engage={} axis=({}, {}, {})", + cull_object, LoadGuestU32(base, cull_object + 6060), + LoadGuestF32(base, cull_object + 6064), + LoadGuestF32(base, cull_object + 6068), + LoadGuestF32(base, cull_object + 5936), + LoadGuestF32(base, cull_object + 5940), + LoadGuestF32(base, cull_object + 5944)); + } + EnsureCullThresholdScaled(base, cull_object); +} + +struct ProbeRecord { + uint64_t id; + float dist; + bool vanilla; + uint8_t blob[16]; +}; + +// A probe-discovered record may only enter the stream set if its embedded +// collection pointer is live: the cell member tables can reference +// collections that are not registered (mid-load, or cells outside the built +// world), and the differ dereferences the pointer for the request's stream +// type; a junk pointer there indexes the per-type request queues with a +// wild value. Live means: plausible address, the collection's own id at +64 +// round-trips, and the stream type at +112 is a sane small index. +bool ProbeRecordIsLive(uint8_t* base, uint64_t id, const uint8_t* blob) { + uint32_t ptr_be; + std::memcpy(&ptr_be, blob + 8, 4); + const uint32_t ptr = __builtin_bswap32(ptr_be); + if (!PlausibleGuestAddr(ptr)) { + return false; + } + uint64_t live_id_raw; + if (!skate3::native_scene::GuestTryCopy(&live_id_raw, base + ptr + 64, 8) || + __builtin_bswap64(live_id_raw) != id) { + return false; + } + uint32_t type_raw; + if (!skate3::native_scene::GuestTryCopy(&type_raw, base + ptr + 112, 4) || + __builtin_bswap32(type_raw) > 7) { + return false; + } + return true; +} + +// Cached probe-fan results per probed focus (slot 0 = world stream, slot 1 = +// detail stream). Valid while the focus stays within half a grid cell of the +// cached position; cell membership cannot change inside that window, so the +// expensive gather fan only re-runs after real movement. +struct ProbeCache { + uint32_t focus = 0; + float x = 0.0f; + float z = 0.0f; + double radius = 0.0; + size_t count = 0; + ProbeRecord records[160]; +}; +ProbeCache g_probe_cache[2]; + +// F6 or gamepad LB+B (while skate3_draw_distance_debug is on): drop a +// numbered marker line into the log so a visual event ("the trees just +// popped in") can be lined up with the surrounding stream/cull entries. +// Edge-detected on the combined state; polled from the per-frame LOD hook +// below. +void MaybePollDebugMarkerKey() { + if (!REXCVAR_GET(skate3_draw_distance_debug)) { + return; + } + static bool was_down = false; + static int marker = 0; + bool down = false; +#if defined(_WIN32) + down = (GetAsyncKeyState(VK_F6) & 0x8000) != 0; +#endif + if (!down) { + if (rex::input::InputSystem* input = skate3::demo_path::GetUiInputSystem()) { + constexpr uint16_t kChord = rex::input::X_INPUT_GAMEPAD_LEFT_SHOULDER | + rex::input::X_INPUT_GAMEPAD_B; + rex::input::X_INPUT_GAMEPAD pad; + down = input->GetUiGamepadState(&pad) && (pad.buttons & kChord) == kChord; + } + } + if (down && !was_down) { + ++marker; + REXLOG_WARN( + "draw_distance: ======== USER MARKER {} (F6 / LB+B): pop-in observed " + "========", + marker); + } + was_down = down; +} + +} // namespace + +// Sk8 cull-parameter setter: [cullObject+6060] = flags, [cullObject+6064] = +// contribution-cull threshold (float). Record the game's value and apply the +// configured scale. +extern "C" REX_FUNC(sub_8288DC58) { + const uint32_t cull_object = ctx.r3.u32; + __imp__sub_8288DC58(ctx, base); + RecordCullThreshold(base, cull_object); +} + +// WorldPresEntityOptimesh per-frame update: rebuilds the size-cull reference +// vector at entity+512 from a global camera vector, with lane 1 replaced by +// the zone attribute. The cull job projects this vector onto the view axis +// as the synthetic mesh size (size * engage / dist^2 >= threshold), so only +// the lane-1 term is per-zone; scale it in place each frame. +extern "C" REX_FUNC(sub_82792900) { + const uint32_t entity = ctx.r3.u32; + __imp__sub_82792900(ctx, base); + if (!PlausibleGuestAddr(entity)) { + return; + } + if (REXCVAR_GET(skate3_draw_distance_debug)) { + static std::atomic log_budget{24}; + if (log_budget.fetch_sub(1, std::memory_order_relaxed) > 0) { + REXLOG_INFO("draw_distance: optimesh ref entity=0x{:08X} v=({}, {}, {})", + entity, LoadGuestF32(base, entity + 512), + LoadGuestF32(base, entity + 516), + LoadGuestF32(base, entity + 520)); + } + } + const double scale = REXCVAR_GET(skate3_draw_distance_scale); + if (std::abs(scale - 1.0) <= kScaleEpsilon) { + return; + } + const uint32_t addr = entity + 516; + StoreGuestF32(base, addr, float(double(LoadGuestF32(base, addr)) * scale)); +} + +// Asset-stream request logging (diagnostics only): a pop-in that coincides +// with a load request here is world-collection streaming (ProxyWorld swap), +// which is a separate system from the distance culls above. +extern "C" REX_FUNC(sub_8247AA88) { + if (REXCVAR_GET(skate3_draw_distance_debug)) { + REXLOG_INFO("draw_distance: stream LOAD id=0x{:016X} type={} prio={}", + ctx.r5.u64, ctx.r6.u32, float(ctx.f1.f64)); + } + __imp__sub_8247AA88(ctx, base); +} + +extern "C" REX_FUNC(sub_8247AC48) { + if (REXCVAR_GET(skate3_draw_distance_debug)) { + REXLOG_INFO("draw_distance: stream UNLOAD id=0x{:016X} type={}", + ctx.r5.u64, ctx.r6.u32); + } + __imp__sub_8247AC48(ctx, base); +} + +// tStreamFocus entry gather (candidate collections for the load/unload +// differ). World detail collections (stream type 2) are grid-cell quantized +// by an XZ position hash: vanilla gathers only the cell containing the focus +// point, so a cell's content (foliage clusters, street detail) pops exactly +// when the player reaches its boundary. After the vanilla gather, re-run it +// for eight displaced probe positions (the 3x3 cell neighbourhood at the +// probe radius) and union the results by asset id, so neighbouring cells are +// resident before they are visible; the differ unloads them again once every +// probe has left. The union is written back distance-sorted (record 0 must +// stay the nearest; FindCollections compares it against the focus's current +// id) and clamped to the 64-record capacity shared by the entry buffer and +// the focus descriptor array. The buffer's u64 header slot at +0 belongs to +// the caller and is left untouched. The caller holds the focus critical +// section for the whole call, so the temporary position writes cannot race +// MoveStreamFocus. +extern "C" REX_FUNC(sub_8247B3A0) { + const uint32_t sys = ctx.r3.u32; + const uint32_t focus = ctx.r4.u32; + const uint32_t out = ctx.r5.u32; + __imp__sub_8247B3A0(ctx, base); + const double probe = REXCVAR_GET(skate3_draw_distance_stream_probe); + if (probe < 1.0 || !PlausibleGuestAddr(focus) || !PlausibleGuestAddr(out)) { + return; + } + const uint32_t focus_type = LoadGuestU32(base, focus + 32); + const uint32_t list_mode = LoadGuestU32(base, focus + 20) >> 24; + const uint32_t list_count = LoadGuestU32(base, focus + 40); + const bool debug = REXCVAR_GET(skate3_draw_distance_debug); + // Grid cell size of the first collection group this focus gathers from; + // the probe pattern must step in cell multiples or it skips over the + // immediate neighbour cells entirely. + uint32_t cell_size = 0; + const uint32_t group_begin = LoadGuestU32(base, sys + focus_type * 16 + 36); + const uint32_t group_end = LoadGuestU32(base, sys + focus_type * 16 + 40); + if (PlausibleGuestAddr(group_begin) && group_end > group_begin) { + const uint32_t group = LoadGuestU32(base, group_begin); + if (PlausibleGuestAddr(group)) { + cell_size = LoadGuestU32(base, group + 600); + } + } + if (debug) { + REXLOG_INFO( + "draw_distance: stream gather focus=0x{:08X} type={} list_mode={} " + "list_count={} entries={} cell_size={}", + focus, focus_type, list_mode, list_count, + LoadGuestU32(base, out + 1032), cell_size); + } + // Probe the world stream (type 0) and the detail stream (type 2). Type 1 + // has extra bookkeeping in FindCollections and stays vanilla. The + // explicit-list path (mode byte set AND a non-empty list, mirroring the + // game's own condition) is position-independent; nothing to probe. A + // zero cell size means the group table is not populated yet (boot). + if (cell_size == 0) { + // Group table not populated (boot / level transition); drop the caches + // so nothing from a previous collection registry survives the rebuild. + g_probe_cache[0] = {}; + g_probe_cache[1] = {}; + return; + } + if ((focus_type != 0 && focus_type != 2) || + (list_mode != 0 && list_count != 0) || cell_size > 4096) { + return; + } + + ProbeRecord records[192]; + size_t record_count = 0; + bool is_vanilla_pass = true; + const auto absorb = [&]() { + const uint32_t count = + std::min(LoadGuestU32(base, out + 1032), 64); + for (uint32_t i = 0; i < count; ++i) { + const uint32_t rec = out + 8 + i * 16; + const uint64_t id = LoadGuestU64(base, rec); + const float dist = LoadGuestF32(base, rec + 12); + size_t j = 0; + while (j < record_count && records[j].id != id) { + ++j; + } + if (j < record_count) { + records[j].vanilla = records[j].vanilla || is_vanilla_pass; + if (dist < records[j].dist) { + records[j].dist = dist; + std::memcpy(records[j].blob, base + rec, 16); + } + continue; + } + if (record_count < sizeof(records) / sizeof(records[0])) { + records[record_count].id = id; + records[record_count].dist = dist; + records[record_count].vanilla = is_vanilla_pass; + std::memcpy(records[record_count].blob, base + rec, 16); + if (!is_vanilla_pass && + !ProbeRecordIsLive(base, id, records[record_count].blob)) { + continue; + } + ++record_count; + } + } + }; + absorb(); + is_vanilla_pass = false; + const size_t vanilla_count = record_count; + + const float fx = LoadGuestF32(base, focus + 0); + const float fz = LoadGuestF32(base, focus + 8); + ProbeCache& cache = g_probe_cache[focus_type == 2 ? 1 : 0]; + const bool cache_usable = cache.focus == focus && cache.radius == probe; + const bool cache_valid = cache_usable && + std::abs(fx - cache.x) < float(cell_size) * 0.5f && + std::abs(fz - cache.z) < float(cell_size) * 0.5f; + // Never fan while the focus's current descriptor set is still streaming: + // at map load the game's loading flow waits on its stream set, and a + // probe burst there multiplies the load time for content that is not + // visible yet. The focus tracks loaded-vs-total descriptor counts from + // its per-cycle rescan; once the vanilla set is resident the fan runs + // and the neighbourhood trickles in during play. A stale cache (moved + // more than half a cell while additions are still streaming) keeps being + // merged rather than refanned, so in-flight probe records are not + // dropped and re-requested by the differ. + const uint32_t desc_total = LoadGuestU32(base, focus + 336); + const uint32_t desc_loaded = LoadGuestU32(base, focus + 340); + const bool set_resident = desc_total > 0 && desc_loaded >= desc_total; + if (cache_valid || (cache_usable && cache.count > 0 && !set_resident)) { + // The neighbourhood cannot have changed (cell membership is quantized + // to the grid): merge the cached probe results instead of re-running + // the gather fan. Cached distances are slightly stale; they are only + // load priorities. Each cached record embeds a collection object + // pointer, and a level transition rebuilds the collection registry + // while the focus object (and often its position) survives; so every + // cached pointer must round-trip through the live object before it is + // reinjected: the collection stores its own asset id at +64, and a + // stale pointer will not reproduce the cached 64-bit id. + for (size_t c = 0; c < cache.count && record_count < 192; ++c) { + const ProbeRecord& cached = cache.records[c]; + if (!ProbeRecordIsLive(base, cached.id, cached.blob)) { + continue; + } + size_t j = 0; + while (j < record_count && records[j].id != cached.id) { + ++j; + } + if (j == record_count) { + records[record_count] = cached; + records[record_count].vanilla = false; + ++record_count; + } + } + } else if (!set_resident) { + // Initial load (or a fresh focus): let the vanilla set finish + // streaming first; the fan starts on a later cycle. + } else { + // Full probe fan: every grid cell within the probe radius, stepped in + // cell multiples. The 7x7 cap bounds the fan at 48 gather calls; the + // fan only re-runs after half a cell of movement, so the cost is a + // once-per-~50m spike, not a per-frame load. + uint8_t saved_pos[16]; + std::memcpy(saved_pos, base + focus, 16); + const int k = std::min( + 3, int(std::ceil(probe / double(cell_size) - 1e-6))); + for (int dx = -k; dx <= k; ++dx) { + for (int dz = -k; dz <= k; ++dz) { + if (dx == 0 && dz == 0) { + continue; + } + StoreGuestF32(base, focus + 0, fx + float(cell_size) * float(dx)); + StoreGuestF32(base, focus + 8, fz + float(cell_size) * float(dz)); + ctx.r3.u64 = sys; + ctx.r4.u64 = focus; + ctx.r5.u64 = out; + __imp__sub_8247B3A0(ctx, base); + absorb(); + } + } + std::memcpy(base + focus, saved_pos, 16); + // Refresh the cache with everything the probes found beyond the + // vanilla set. + cache.focus = focus; + cache.radius = probe; + cache.x = fx; + cache.z = fz; + cache.count = 0; + for (size_t i = 0; + i < record_count && cache.count < sizeof(cache.records) / + sizeof(cache.records[0]); + ++i) { + if (!records[i].vanilla) { + cache.records[cache.count++] = records[i]; + } + } + } + + std::sort(records, records + record_count, + [](const ProbeRecord& a, const ProbeRecord& b) { + return a.dist < b.dist; + }); + // Cap the union. The entry buffer, the focus descriptor array, and + // ProcessEntries' staging all hold at most 64 records; that limit is + // hard. Within it, a probe record is admitted only when its distance is + // inside the probe radius (plus a cell of margin for the coarse + // quantization): that is exactly the content that would otherwise pop + // into view at the cell boundary. Anything farther is the neighbour + // cells' long tail and streaming it is cost without visible benefit. + // Vanilla records are never dropped, or the differ would unload world + // content the game intends to keep. Records are distance-sorted, so a + // single ascending pass keeps the nearest qualifying additions. + // Type 0 swaps a cheap ProxyWorld stand-in for the full-detail chunk the + // moment a collection loads, so every admitted world chunk is rendered at + // full detail from then on; admit only within the probe radius itself. + // Type 2 detail cells are small payloads; give them a cell of margin for + // the grid quantization. + const double admit_radius = + focus_type == 2 ? probe + double(cell_size) : probe; + const float admit_dist_sq = float(admit_radius * admit_radius); + size_t kept = 0; + size_t dropped = 0; + size_t vanilla_seen = 0; + for (size_t i = 0; i < record_count; ++i) { + bool admit; + if (records[i].vanilla) { + admit = true; + ++vanilla_seen; + } else { + // Reserve room for every vanilla record still ahead in the sorted + // order; they are admitted unconditionally and the 64 cap is hard. + const size_t vanilla_ahead = vanilla_count - vanilla_seen; + admit = kept + vanilla_ahead < 64 && records[i].dist <= admit_dist_sq; + } + if (!admit) { + ++dropped; + continue; + } + if (kept != i) { + records[kept] = records[i]; + } + ++kept; + } + record_count = kept; + for (size_t i = 0; i < record_count; ++i) { + std::memcpy(base + out + 8 + i * 16, records[i].blob, 16); + } + StoreGuestU32(base, out + 1032, uint32_t(record_count)); + if (debug) { + REXLOG_INFO("draw_distance: stream probe type={} cells {} -> {} dropped={}", + focus_type, vanilla_count, record_count, dropped); + } +} + +// tStreamFocus::ProcessEntries: before the old/new merge-diff walks the +// focus's previous descriptor set, drop any old record whose collection +// pointer no longer resolves. A level transition destroys collection objects +// that can stay referenced by descriptors staged before it (the probe union +// holds more cells than the game's own set, including cells whose +// collections are torn down), and the differ dereferences each old-only +// record's pointer for the unload request's stream type; a dead pointer +// there indexes the per-type request queues with a wild value. Runs under +// the focus critical section held by the caller. +extern "C" REX_FUNC(sub_8247DCF0) { + const uint32_t focus = ctx.r3.u32; + if (PlausibleGuestAddr(focus)) { + const uint32_t count = LoadGuestU32(base, focus + 1400); + if (count <= 64) { + uint32_t kept = 0; + for (uint32_t i = 0; i < count; ++i) { + uint8_t blob[16]; + std::memcpy(blob, base + focus + 376 + i * 16, 16); + uint64_t id_raw; + std::memcpy(&id_raw, blob, 8); + if (!ProbeRecordIsLive(base, __builtin_bswap64(id_raw), blob)) { + continue; + } + if (kept != i) { + std::memcpy(base + focus + 376 + kept * 16, blob, 16); + } + ++kept; + } + if (kept != count) { + StoreGuestU32(base, focus + 1400, kept); + if (REXCVAR_GET(skate3_draw_distance_debug)) { + REXLOG_WARN( + "draw_distance: dropped {} dead stream descriptors " + "(focus=0x{:08X})", + count - kept, focus); + } + } + } + } + __imp__sub_8247DCF0(ctx, base); +} + +// Load/unload COMPLETION (the moment the streamed collection is swapped in +// or out of the world, including the ProxyWorld high/proxy exchange): this, +// not the request above, is when a streaming pop becomes visible. +extern "C" REX_FUNC(sub_8247BB50) { + if (REXCVAR_GET(skate3_draw_distance_debug)) { + REXLOG_WARN("draw_distance: stream LOAD-COMPLETE id=0x{:016X}", + ctx.r4.u64); + } + __imp__sub_8247BB50(ctx, base); +} + +extern "C" REX_FUNC(sub_8247BD28) { + if (REXCVAR_GET(skate3_draw_distance_debug)) { + REXLOG_WARN("draw_distance: stream UNLOAD-COMPLETE id=0x{:016X}", + ctx.r4.u64); + } + __imp__sub_8247BD28(ctx, base); +} + +// SceneRenderView::GetLODDistancesFromAttrib: rewrites the six squared LOD +// switch distances on the view every frame. Scale them by k^2 (squared +// domain), and take the per-frame opportunity to reapply the cull threshold +// on this view's cull object. +extern "C" REX_FUNC(sub_827E1AD8) { + const uint32_t view = ctx.r3.u32; + __imp__sub_827E1AD8(ctx, base); + MaybePollDebugMarkerKey(); + if (!PlausibleGuestAddr(view)) { + return; + } + const double lod_scale = REXCVAR_GET(skate3_lod_distance_scale); + if (std::abs(lod_scale - 1.0) > kScaleEpsilon) { + const double squared = lod_scale * lod_scale; + for (uint32_t offset = 23760; offset <= 23780; offset += 4) { + const uint32_t addr = view + offset; + StoreGuestF32(base, addr, + float(double(LoadGuestF32(base, addr)) * squared)); + } + } + EnsureCullThresholdScaled(base, LoadGuestU32(base, view + 8)); +}